Skip to content

Commit 8eb5ef3

Browse files
committed
Add high-value lifecycle and coordinator validation tests
1 parent d688228 commit 8eb5ef3

File tree

2 files changed

+347
-0
lines changed

2 files changed

+347
-0
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
using System.Collections.Concurrent;
2+
using System.Text.Json.Nodes;
3+
using MemNet.MemoryService.Core;
4+
using MemNet.MemoryService.Infrastructure;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
7+
namespace MemNet.MemoryService.UnitTests;
8+
9+
public class LifecycleAndReplayServiceTests
10+
{
11+
[Fact]
12+
public async Task ApplyRetention_NegativeEventsDays_Returns400()
13+
{
14+
var service = new DataLifecycleService(new FakeMaintenanceStore());
15+
16+
var ex = await Assert.ThrowsAsync<ApiException>(
17+
() => service.ApplyRetentionAsync(
18+
"tenant",
19+
"user",
20+
new ApplyRetentionRequest(
21+
EventsDays: -1,
22+
AuditDays: 30,
23+
SnapshotsDays: 30,
24+
AsOfUtc: DateTimeOffset.UtcNow)));
25+
26+
Assert.Equal(400, ex.StatusCode);
27+
Assert.Equal("INVALID_RETENTION_VALUE", ex.Code);
28+
}
29+
30+
[Fact]
31+
public async Task ApplyRetention_UsesUtcAsOfAndForwardsRules()
32+
{
33+
var store = new FakeMaintenanceStore();
34+
var service = new DataLifecycleService(store);
35+
var asOfWithOffset = new DateTimeOffset(2026, 1, 2, 8, 30, 0, TimeSpan.FromHours(5));
36+
37+
await service.ApplyRetentionAsync(
38+
"tenant-a",
39+
"user-a",
40+
new ApplyRetentionRequest(
41+
EventsDays: 365,
42+
AuditDays: 90,
43+
SnapshotsDays: 30,
44+
AsOfUtc: asOfWithOffset));
45+
46+
Assert.Equal("tenant-a", store.LastTenantId);
47+
Assert.Equal("user-a", store.LastUserId);
48+
Assert.Equal(new RetentionRules(30, 365, 90), store.LastRules);
49+
Assert.Equal(asOfWithOffset.ToUniversalTime(), store.LastAsOfUtc);
50+
}
51+
52+
[Fact]
53+
public async Task ForgetUser_ForwardsIdentifiersToStore()
54+
{
55+
var store = new FakeMaintenanceStore();
56+
var service = new DataLifecycleService(store);
57+
58+
var result = await service.ForgetUserAsync("tenant-z", "user-z");
59+
60+
Assert.Equal("tenant-z", store.LastTenantId);
61+
Assert.Equal("user-z", store.LastUserId);
62+
Assert.Equal(3, result.DocumentsDeleted);
63+
}
64+
65+
[Fact]
66+
public async Task ApplyReplayPatchAsync_UsesReplayPayloadAndReplayEtag()
67+
{
68+
var documentStore = new FakeDocumentStore();
69+
var auditStore = new RecordingAuditStore();
70+
var coordinator = new MemoryCoordinator(
71+
documentStore,
72+
new FakeEventStore(),
73+
auditStore,
74+
NullLogger<MemoryCoordinator>.Instance);
75+
76+
var key = new DocumentKey("tenant", "user", "user/profile.json");
77+
var seeded = await documentStore.UpsertAsync(key, CreateEnvelope("before"), "*");
78+
79+
var replay = new ReplayPatchRecord(
80+
ReplayId: "rpl_1",
81+
TargetBindingId: "binding_1",
82+
TargetPath: key.Path,
83+
BaseETag: seeded.ETag,
84+
Ops:
85+
[
86+
new PatchOperation("replace", "/content/text", JsonValue.Create("after"))
87+
],
88+
Evidence: new JsonObject { ["source"] = "replay" });
89+
90+
var replayService = new ReplayService(coordinator);
91+
var response = await replayService.ApplyReplayPatchAsync(key, replay, actor: "replay-agent");
92+
93+
Assert.Equal("after", response.Document.Content["text"]?.GetValue<string>());
94+
Assert.Single(auditStore.Records);
95+
Assert.Equal("replay_update", auditStore.Records[0].Reason);
96+
Assert.Equal(seeded.ETag, auditStore.Records[0].PreviousETag);
97+
}
98+
99+
private static DocumentEnvelope CreateEnvelope(string text)
100+
{
101+
var now = DateTimeOffset.UtcNow;
102+
return new DocumentEnvelope(
103+
DocId: $"doc-{Guid.NewGuid():N}",
104+
SchemaId: "memnet.file",
105+
SchemaVersion: "1.0.0",
106+
CreatedAt: now,
107+
UpdatedAt: now,
108+
UpdatedBy: "seed",
109+
Content: new JsonObject { ["text"] = text });
110+
}
111+
112+
private sealed class FakeMaintenanceStore : IUserDataMaintenanceStore
113+
{
114+
public string? LastTenantId { get; private set; }
115+
public string? LastUserId { get; private set; }
116+
public RetentionRules? LastRules { get; private set; }
117+
public DateTimeOffset? LastAsOfUtc { get; private set; }
118+
119+
public Task<ForgetUserResult> ForgetUserAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
120+
{
121+
LastTenantId = tenantId;
122+
LastUserId = userId;
123+
return Task.FromResult(new ForgetUserResult(3, 2, 1, 0, 0));
124+
}
125+
126+
public Task<RetentionSweepResult> ApplyRetentionAsync(
127+
string tenantId,
128+
string userId,
129+
RetentionRules rules,
130+
DateTimeOffset asOfUtc,
131+
CancellationToken cancellationToken = default)
132+
{
133+
LastTenantId = tenantId;
134+
LastUserId = userId;
135+
LastRules = rules;
136+
LastAsOfUtc = asOfUtc;
137+
138+
return Task.FromResult(new RetentionSweepResult(1, 1, 1, 0, asOfUtc, asOfUtc, asOfUtc));
139+
}
140+
}
141+
142+
private sealed class FakeDocumentStore : IDocumentStore
143+
{
144+
private readonly ConcurrentDictionary<string, DocumentRecord> _records = new(StringComparer.Ordinal);
145+
private int _version;
146+
147+
public Task<DocumentRecord?> GetAsync(DocumentKey key, CancellationToken cancellationToken = default)
148+
{
149+
_records.TryGetValue(Key(key), out var record);
150+
return Task.FromResult(record);
151+
}
152+
153+
public Task<DocumentRecord> UpsertAsync(DocumentKey key, DocumentEnvelope envelope, string? ifMatch, CancellationToken cancellationToken = default)
154+
{
155+
var id = Key(key);
156+
if (_records.TryGetValue(id, out var existing))
157+
{
158+
if (!string.Equals(existing.ETag, ifMatch, StringComparison.Ordinal))
159+
{
160+
throw new ApiException(412, "ETAG_MISMATCH", "stale");
161+
}
162+
}
163+
else if (!string.IsNullOrWhiteSpace(ifMatch) && ifMatch != "*")
164+
{
165+
throw new ApiException(412, "ETAG_MISMATCH", "missing");
166+
}
167+
168+
var etag = $"\"v{Interlocked.Increment(ref _version)}\"";
169+
var stored = new DocumentRecord(envelope, etag);
170+
_records[id] = stored;
171+
return Task.FromResult(stored);
172+
}
173+
174+
public Task<IReadOnlyList<FileListItem>> ListAsync(string tenantId, string userId, string? prefix, int limit, CancellationToken cancellationToken = default)
175+
=> Task.FromResult<IReadOnlyList<FileListItem>>(Array.Empty<FileListItem>());
176+
177+
public Task<bool> ExistsAsync(DocumentKey key, CancellationToken cancellationToken = default)
178+
=> Task.FromResult(_records.ContainsKey(Key(key)));
179+
180+
private static string Key(DocumentKey key) => $"{key.TenantId}/{key.UserId}/{key.Path}";
181+
}
182+
183+
private sealed class FakeEventStore : IEventStore
184+
{
185+
public Task WriteAsync(EventDigest digest, CancellationToken cancellationToken = default) => Task.CompletedTask;
186+
187+
public Task<IReadOnlyList<EventDigest>> QueryAsync(string tenantId, string userId, EventSearchRequest request, CancellationToken cancellationToken = default)
188+
=> Task.FromResult<IReadOnlyList<EventDigest>>(Array.Empty<EventDigest>());
189+
}
190+
191+
private sealed class RecordingAuditStore : IAuditStore
192+
{
193+
public List<AuditRecord> Records { get; } = [];
194+
195+
public Task WriteAsync(AuditRecord record, CancellationToken cancellationToken = default)
196+
{
197+
Records.Add(record);
198+
return Task.CompletedTask;
199+
}
200+
}
201+
}

tests/MemNet.MemoryService.UnitTests/MemoryCoordinatorValidationTests.cs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,64 @@ public async Task PatchDocument_TextEdits_AmbiguousMatch_Returns422()
8282
Assert.Equal("PATCH_MATCH_AMBIGUOUS", ex.Code);
8383
}
8484

85+
[Fact]
86+
public async Task PatchDocument_TextEdits_OccurrenceOutOfRange_Returns422()
87+
{
88+
var store = new FakeDocumentStore();
89+
var key = TestKey;
90+
var seeded = await store.UpsertAsync(
91+
key,
92+
CreateEnvelope(new JsonObject
93+
{
94+
["text"] = "alpha\nalpha\n"
95+
}),
96+
"*");
97+
var coordinator = CreateCoordinator(store);
98+
99+
var ex = await Assert.ThrowsAsync<ApiException>(
100+
() => coordinator.PatchDocumentAsync(
101+
key,
102+
new PatchDocumentRequest(
103+
Ops: [],
104+
Reason: "text_patch",
105+
Evidence: null,
106+
Edits: [new TextPatchEdit("alpha\n", "beta\n", 3)]),
107+
ifMatch: seeded.ETag,
108+
actor: "tests"));
109+
110+
Assert.Equal(422, ex.StatusCode);
111+
Assert.Equal("PATCH_OCCURRENCE_OUT_OF_RANGE", ex.Code);
112+
}
113+
114+
[Fact]
115+
public async Task PatchDocument_TextEdits_WithoutContentText_Returns422()
116+
{
117+
var store = new FakeDocumentStore();
118+
var key = TestKey;
119+
var seeded = await store.UpsertAsync(
120+
key,
121+
CreateEnvelope(new JsonObject
122+
{
123+
["title"] = "missing text"
124+
}),
125+
"*");
126+
var coordinator = CreateCoordinator(store);
127+
128+
var ex = await Assert.ThrowsAsync<ApiException>(
129+
() => coordinator.PatchDocumentAsync(
130+
key,
131+
new PatchDocumentRequest(
132+
Ops: [],
133+
Reason: "text_patch",
134+
Evidence: null,
135+
Edits: [new TextPatchEdit("alpha", "beta", 1)]),
136+
ifMatch: seeded.ETag,
137+
actor: "tests"));
138+
139+
Assert.Equal(422, ex.StatusCode);
140+
Assert.Equal("PATCH_TEXT_NOT_FOUND", ex.Code);
141+
}
142+
85143
[Fact]
86144
public async Task PatchDocument_TextEdits_ApplyDeterministicallyWithOccurrence()
87145
{
@@ -109,6 +167,31 @@ public async Task PatchDocument_TextEdits_ApplyDeterministicallyWithOccurrence()
109167
Assert.Equal("line A\nline X\nline B\n", patched.Document.Content["text"]?.GetValue<string>());
110168
}
111169

170+
[Fact]
171+
public async Task PatchDocument_WithMoreThanMaxOperations_Returns422()
172+
{
173+
var store = new FakeDocumentStore();
174+
var key = TestKey;
175+
var seeded = await store.UpsertAsync(key, CreateEnvelope(new JsonObject { ["value"] = "seed" }), "*");
176+
var coordinator = CreateCoordinator(store);
177+
var ops = Enumerable.Range(0, 101)
178+
.Select(_ => new PatchOperation("replace", "/content/value", JsonValue.Create("updated")))
179+
.ToArray();
180+
181+
var ex = await Assert.ThrowsAsync<ApiException>(
182+
() => coordinator.PatchDocumentAsync(
183+
key,
184+
new PatchDocumentRequest(
185+
Ops: ops,
186+
Reason: "test",
187+
Evidence: null),
188+
ifMatch: seeded.ETag,
189+
actor: "tests"));
190+
191+
Assert.Equal(422, ex.StatusCode);
192+
Assert.Equal("PATCH_TOO_LARGE", ex.Code);
193+
}
194+
112195
[Fact]
113196
public async Task AssembleContext_EmptyFiles_Returns400()
114197
{
@@ -139,6 +222,46 @@ public async Task ListFiles_InvalidLimit_Returns400()
139222
Assert.Equal("INVALID_LIMIT", ex.Code);
140223
}
141224

225+
[Fact]
226+
public async Task ListFiles_PathTraversalPrefix_Returns400()
227+
{
228+
var coordinator = CreateCoordinator(new FakeDocumentStore());
229+
230+
var ex = await Assert.ThrowsAsync<ApiException>(
231+
() => coordinator.ListFilesAsync(
232+
"tenant",
233+
"user",
234+
new ListFilesRequest(Prefix: "../secrets", Limit: 10)));
235+
236+
Assert.Equal(400, ex.StatusCode);
237+
Assert.Equal("INVALID_PATH_PREFIX", ex.Code);
238+
}
239+
240+
[Fact]
241+
public async Task ReplaceDocument_ExceedingMaxDocumentSize_Returns422()
242+
{
243+
var store = new FakeDocumentStore();
244+
var key = TestKey;
245+
var coordinator = CreateCoordinator(store);
246+
var oversizedText = new string('x', 260_000);
247+
248+
var ex = await Assert.ThrowsAsync<ApiException>(
249+
() => coordinator.ReplaceDocumentAsync(
250+
key,
251+
new ReplaceDocumentRequest(
252+
Document: CreateEnvelope(new JsonObject
253+
{
254+
["text"] = oversizedText
255+
}),
256+
Reason: "test",
257+
Evidence: null),
258+
ifMatch: "*",
259+
actor: "tests"));
260+
261+
Assert.Equal(422, ex.StatusCode);
262+
Assert.Equal("DOCUMENT_SIZE_EXCEEDED", ex.Code);
263+
}
264+
142265
[Fact]
143266
public async Task WriteEvent_KeywordsCountExceedsMax_Returns422()
144267
{
@@ -163,6 +286,29 @@ public async Task WriteEvent_KeywordsCountExceedsMax_Returns422()
163286
Assert.Equal("EVENT_METADATA_TOO_LARGE", ex.Code);
164287
}
165288

289+
[Fact]
290+
public async Task WriteEvent_MissingEventId_Returns400()
291+
{
292+
var coordinator = CreateCoordinator(new FakeDocumentStore());
293+
294+
var ex = await Assert.ThrowsAsync<ApiException>(
295+
() => coordinator.WriteEventAsync(
296+
new EventDigest(
297+
EventId: "",
298+
TenantId: "tenant",
299+
UserId: "user",
300+
ServiceId: "test-service",
301+
Timestamp: DateTimeOffset.UtcNow,
302+
SourceType: "test",
303+
Digest: "test digest",
304+
Keywords: [],
305+
ProjectIds: [],
306+
Evidence: null)));
307+
308+
Assert.Equal(400, ex.StatusCode);
309+
Assert.Equal("INVALID_EVENT", ex.Code);
310+
}
311+
166312
[Fact]
167313
public async Task WriteEvent_ProjectIdsCountExceedsMax_Returns422()
168314
{

0 commit comments

Comments
 (0)