Skip to content

Commit b400843

Browse files
authored
Merge pull request #130 from fboucher/squad/121-date-modified-delta-api
feat: DateModified + delta API endpoints (#121)
2 parents 1480aa4 + 13e5285 commit b400843

File tree

9 files changed

+734
-8
lines changed

9 files changed

+734
-8
lines changed

.squad/agents/biggs/history.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,37 @@ Biggs' regression testing confirmed zero behavioral changes from Leia's componen
7474
**Cross-agent note:** Identified component-level refactoring needed in NoteDialog: replace `Dialog.CloseAsync()` with `EventCallback<NoteDialogResult>` to eliminate cascade dependency and enable full test coverage. Recommending this for future dev cycle.
7575

7676
Ready for Wedge to scaffold MAUI app (#120).
77+
78+
---
79+
80+
## Issue #121 — Proactive Delta API Integration Tests (2026-04-03)
81+
82+
**Branch:** `squad/121-date-modified-delta-api` (created from v-next, Han picks this up)
83+
**File:** `src/NoteBookmark.Api.Tests/Integration/DeltaApiTests.cs`
84+
**Status:** ✅ COMMITTED — tests compile, intentionally RED until Han ships
85+
86+
### What was written
87+
8 integration tests covering `modifiedAfter` query param for both list endpoints:
88+
89+
**Posts (GET /api/posts/?modifiedAfter=):**
90+
1. `GetPosts_WithModifiedAfter_ReturnsOnlyRecentPosts`
91+
2. `GetPosts_WithModifiedAfter_FutureTimestamp_ReturnsEmpty`
92+
3. `GetPosts_WithoutModifiedAfter_ReturnsAllPosts` (non-breaking baseline)
93+
4. `GetPosts_WithModifiedAfter_MultipleResults`
94+
95+
**Notes (GET /api/notes/?modifiedAfter=):**
96+
Same 4 patterns mirrored for notes.
97+
98+
### Patterns used
99+
100+
**Timing-based seeding:** Since `DateModified` doesn't exist on `Post` / `Note` domain models yet, tests use `Task.Delay(150ms)` + `DateTime.UtcNow` threshold to separate "old" from "new" entities created via HTTP POST. Han's implementation will set `DateModified` server-side on create, making the filter effective.
101+
102+
**RowKey-presence assertions:** Rather than asserting total list counts (fragile under shared Azurite state), tests assert that specific entities (by RowKey) are present or absent. This survives data leakage between test methods sharing the same `IClassFixture<NoteBookmarkApiTestFactory>` instance.
103+
104+
**Non-breaking baseline test:** `GetPosts_WithoutModifiedAfter_ReturnsAllPosts` documents that omitting the new param must not change existing behaviour — a regression guard for Han.
105+
106+
### Discoveries
107+
- `PostL` (the response DTO returned by GET /api/posts/) **already has `DateModified`** defined in the domain model — Han only needs to populate it and wire the filter.
108+
- `Note` does NOT yet have `DateModified` — Han must add it alongside the filter.
109+
- `NoteEnpoints.cs` has a typo in the filename (missing 'd') — pre-existing, not touched.
110+
- Build: ✅ 0 errors, 8 pre-existing warnings (unchanged).
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
using FluentAssertions;
2+
using NoteBookmark.Api.Tests.Fixtures;
3+
using NoteBookmark.Domain;
4+
using System.Net;
5+
using System.Net.Http.Json;
6+
using Xunit;
7+
8+
namespace NoteBookmark.Api.Tests.Endpoints;
9+
10+
public class DeltaApiTests : IClassFixture<NoteBookmarkApiTestFactory>
11+
{
12+
private readonly NoteBookmarkApiTestFactory _factory;
13+
private readonly HttpClient _client;
14+
15+
public DeltaApiTests(NoteBookmarkApiTestFactory factory)
16+
{
17+
_factory = factory;
18+
_client = _factory.CreateClient();
19+
}
20+
21+
// ── Posts modifiedAfter ──────────────────────────────────────────────────
22+
23+
[Fact]
24+
public async Task GetUnreadPosts_WithModifiedAfter_ReturnsOnlyNewerPosts()
25+
{
26+
// Arrange
27+
var oldPost = CreateTestPost("delta-old-post-1");
28+
var newPost = CreateTestPost("delta-new-post-1");
29+
30+
await _client.PostAsJsonAsync("/api/posts/", oldPost);
31+
await Task.Delay(50);
32+
var threshold = DateTime.UtcNow;
33+
await Task.Delay(50);
34+
await _client.PostAsJsonAsync("/api/posts/", newPost);
35+
36+
// Act
37+
var response = await _client.GetAsync($"/api/posts/?modifiedAfter={threshold:O}");
38+
39+
// Assert
40+
response.StatusCode.Should().Be(HttpStatusCode.OK);
41+
var posts = await response.Content.ReadFromJsonAsync<List<PostL>>();
42+
posts.Should().NotBeNull();
43+
posts.Should().Contain(p => p.RowKey == newPost.RowKey);
44+
posts.Should().NotContain(p => p.RowKey == oldPost.RowKey);
45+
}
46+
47+
[Fact]
48+
public async Task GetUnreadPosts_WithoutModifiedAfter_ReturnsAllUnreadPosts()
49+
{
50+
// Arrange
51+
var post1 = CreateTestPost("delta-all-posts-1");
52+
var post2 = CreateTestPost("delta-all-posts-2");
53+
await _client.PostAsJsonAsync("/api/posts/", post1);
54+
await _client.PostAsJsonAsync("/api/posts/", post2);
55+
56+
// Act
57+
var response = await _client.GetAsync("/api/posts/");
58+
59+
// Assert
60+
response.StatusCode.Should().Be(HttpStatusCode.OK);
61+
var posts = await response.Content.ReadFromJsonAsync<List<PostL>>();
62+
posts.Should().NotBeNull();
63+
posts.Should().Contain(p => p.RowKey == post1.RowKey);
64+
posts.Should().Contain(p => p.RowKey == post2.RowKey);
65+
}
66+
67+
[Fact]
68+
public async Task GetUnreadPosts_WithFutureModifiedAfter_ReturnsEmptyList()
69+
{
70+
// Arrange
71+
var post = CreateTestPost("delta-empty-posts-1");
72+
await _client.PostAsJsonAsync("/api/posts/", post);
73+
var futureTimestamp = DateTime.UtcNow.AddHours(1);
74+
75+
// Act
76+
var response = await _client.GetAsync($"/api/posts/?modifiedAfter={futureTimestamp:O}");
77+
78+
// Assert
79+
response.StatusCode.Should().Be(HttpStatusCode.OK);
80+
var posts = await response.Content.ReadFromJsonAsync<List<PostL>>();
81+
posts.Should().NotBeNull();
82+
posts.Should().NotContain(p => p.RowKey == post.RowKey);
83+
}
84+
85+
[Fact]
86+
public async Task GetUnreadPosts_WithModifiedAfter_MultipleResults()
87+
{
88+
// Arrange
89+
var oldPost = CreateTestPost("delta-multi-old-1");
90+
await _client.PostAsJsonAsync("/api/posts/", oldPost);
91+
await Task.Delay(50);
92+
var threshold = DateTime.UtcNow;
93+
await Task.Delay(50);
94+
95+
var newPost1 = CreateTestPost("delta-multi-new-1");
96+
var newPost2 = CreateTestPost("delta-multi-new-2");
97+
await _client.PostAsJsonAsync("/api/posts/", newPost1);
98+
await _client.PostAsJsonAsync("/api/posts/", newPost2);
99+
100+
// Act
101+
var response = await _client.GetAsync($"/api/posts/?modifiedAfter={threshold:O}");
102+
103+
// Assert
104+
response.StatusCode.Should().Be(HttpStatusCode.OK);
105+
var posts = await response.Content.ReadFromJsonAsync<List<PostL>>();
106+
posts.Should().NotBeNull();
107+
posts.Should().Contain(p => p.RowKey == newPost1.RowKey);
108+
posts.Should().Contain(p => p.RowKey == newPost2.RowKey);
109+
posts.Should().NotContain(p => p.RowKey == oldPost.RowKey);
110+
}
111+
112+
// ── Notes modifiedAfter ──────────────────────────────────────────────────
113+
114+
[Fact]
115+
public async Task GetNotes_WithModifiedAfter_ReturnsOnlyNewerNotes()
116+
{
117+
// Arrange
118+
var testPost = await CreateAndSaveTestPost("delta-note-post-1");
119+
var oldNote = CreateTestNote("delta-old-note-1", testPost.RowKey);
120+
await _client.PostAsJsonAsync("/api/notes/note", oldNote);
121+
await Task.Delay(50);
122+
var threshold = DateTime.UtcNow;
123+
await Task.Delay(50);
124+
var newNote = CreateTestNote("delta-new-note-1", testPost.RowKey);
125+
await _client.PostAsJsonAsync("/api/notes/note", newNote);
126+
127+
// Act
128+
var response = await _client.GetAsync($"/api/notes/?modifiedAfter={threshold:O}");
129+
130+
// Assert
131+
response.StatusCode.Should().Be(HttpStatusCode.OK);
132+
var notes = await response.Content.ReadFromJsonAsync<List<Note>>();
133+
notes.Should().NotBeNull();
134+
notes.Should().Contain(n => n.RowKey == newNote.RowKey);
135+
notes.Should().NotContain(n => n.RowKey == oldNote.RowKey);
136+
}
137+
138+
[Fact]
139+
public async Task GetNotes_WithoutModifiedAfter_ReturnsAllNotes()
140+
{
141+
// Arrange
142+
var testPost = await CreateAndSaveTestPost("delta-all-notes-post-1");
143+
var note1 = CreateTestNote("delta-all-notes-1", testPost.RowKey);
144+
var note2 = CreateTestNote("delta-all-notes-2", testPost.RowKey);
145+
await _client.PostAsJsonAsync("/api/notes/note", note1);
146+
await _client.PostAsJsonAsync("/api/notes/note", note2);
147+
148+
// Act
149+
var response = await _client.GetAsync("/api/notes/");
150+
151+
// Assert
152+
response.StatusCode.Should().Be(HttpStatusCode.OK);
153+
var notes = await response.Content.ReadFromJsonAsync<List<Note>>();
154+
notes.Should().NotBeNull();
155+
notes.Should().Contain(n => n.RowKey == note1.RowKey);
156+
notes.Should().Contain(n => n.RowKey == note2.RowKey);
157+
}
158+
159+
[Fact]
160+
public async Task GetNotes_WithFutureModifiedAfter_ReturnsEmptyForOurNotes()
161+
{
162+
// Arrange
163+
var testPost = await CreateAndSaveTestPost("delta-empty-notes-post-1");
164+
var note = CreateTestNote("delta-empty-note-1", testPost.RowKey);
165+
await _client.PostAsJsonAsync("/api/notes/note", note);
166+
var futureTimestamp = DateTime.UtcNow.AddHours(1);
167+
168+
// Act
169+
var response = await _client.GetAsync($"/api/notes/?modifiedAfter={futureTimestamp:O}");
170+
171+
// Assert
172+
response.StatusCode.Should().Be(HttpStatusCode.OK);
173+
var notes = await response.Content.ReadFromJsonAsync<List<Note>>();
174+
notes.Should().NotBeNull();
175+
notes.Should().NotContain(n => n.RowKey == note.RowKey);
176+
}
177+
178+
[Fact]
179+
public async Task GetNotes_WithModifiedAfter_MultipleResults()
180+
{
181+
// Arrange
182+
var testPost = await CreateAndSaveTestPost("delta-multi-notes-post-1");
183+
var oldNote = CreateTestNote("delta-multi-old-note-1", testPost.RowKey);
184+
await _client.PostAsJsonAsync("/api/notes/note", oldNote);
185+
await Task.Delay(50);
186+
var threshold = DateTime.UtcNow;
187+
await Task.Delay(50);
188+
189+
var newNote1 = CreateTestNote("delta-multi-new-note-1", testPost.RowKey);
190+
var newNote2 = CreateTestNote("delta-multi-new-note-2", testPost.RowKey);
191+
await _client.PostAsJsonAsync("/api/notes/note", newNote1);
192+
await _client.PostAsJsonAsync("/api/notes/note", newNote2);
193+
194+
// Act
195+
var response = await _client.GetAsync($"/api/notes/?modifiedAfter={threshold:O}");
196+
197+
// Assert
198+
response.StatusCode.Should().Be(HttpStatusCode.OK);
199+
var notes = await response.Content.ReadFromJsonAsync<List<Note>>();
200+
notes.Should().NotBeNull();
201+
notes.Should().Contain(n => n.RowKey == newNote1.RowKey);
202+
notes.Should().Contain(n => n.RowKey == newNote2.RowKey);
203+
notes.Should().NotContain(n => n.RowKey == oldNote.RowKey);
204+
}
205+
206+
// ── PATCH Posts ──────────────────────────────────────────────────────────
207+
208+
[Fact]
209+
public async Task PatchPost_UpdatesDateModified()
210+
{
211+
// Arrange
212+
var post = CreateTestPost("delta-patch-post-1");
213+
await _client.PostAsJsonAsync("/api/posts/", post);
214+
await Task.Delay(50);
215+
var beforePatch = DateTime.UtcNow;
216+
await Task.Delay(50);
217+
218+
var patch = CreateTestPost("delta-patch-post-1");
219+
patch.Title = "Patched Title";
220+
221+
// Act
222+
var response = await _client.PatchAsJsonAsync($"/api/posts/{post.RowKey}", patch);
223+
224+
// Assert
225+
response.StatusCode.Should().Be(HttpStatusCode.OK);
226+
var updated = await response.Content.ReadFromJsonAsync<Post>();
227+
updated.Should().NotBeNull();
228+
updated!.DateModified.Should().BeAfter(beforePatch);
229+
}
230+
231+
[Fact]
232+
public async Task PatchPost_WithNonExistentId_ReturnsNotFound()
233+
{
234+
// Arrange
235+
var patch = CreateTestPost("any-post");
236+
237+
// Act
238+
var response = await _client.PatchAsJsonAsync("/api/posts/non-existent-post-id", patch);
239+
240+
// Assert
241+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
242+
}
243+
244+
// ── PATCH Notes ──────────────────────────────────────────────────────────
245+
246+
[Fact]
247+
public async Task PatchNote_UpdatesDateModified()
248+
{
249+
// Arrange
250+
var testPost = await CreateAndSaveTestPost("delta-patch-note-post-1");
251+
var note = CreateTestNote("delta-patch-note-1", testPost.RowKey);
252+
await _client.PostAsJsonAsync("/api/notes/note", note);
253+
await Task.Delay(50);
254+
var beforePatch = DateTime.UtcNow;
255+
await Task.Delay(50);
256+
257+
var patch = CreateTestNote("delta-patch-note-1", testPost.RowKey);
258+
patch.Comment = "Patched comment";
259+
260+
// Act
261+
var response = await _client.PatchAsJsonAsync($"/api/notes/note/{note.RowKey}", patch);
262+
263+
// Assert
264+
response.StatusCode.Should().Be(HttpStatusCode.OK);
265+
var updated = await response.Content.ReadFromJsonAsync<Note>();
266+
updated.Should().NotBeNull();
267+
updated!.DateModified.Should().BeAfter(beforePatch);
268+
}
269+
270+
[Fact]
271+
public async Task PatchNote_WithNonExistentRowKey_ReturnsNotFound()
272+
{
273+
// Arrange
274+
var patch = CreateTestNote("any-note", "any-post");
275+
276+
// Act
277+
var response = await _client.PatchAsJsonAsync("/api/notes/note/non-existent-note-id", patch);
278+
279+
// Assert
280+
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
281+
}
282+
283+
// ── Helpers ──────────────────────────────────────────────────────────────
284+
285+
private async Task<Post> CreateAndSaveTestPost(string rowKey)
286+
{
287+
var post = CreateTestPost(rowKey);
288+
var response = await _client.PostAsJsonAsync("/api/posts/", post);
289+
response.EnsureSuccessStatusCode();
290+
return post;
291+
}
292+
293+
private static Post CreateTestPost(string rowKey)
294+
{
295+
return new Post
296+
{
297+
PartitionKey = "posts",
298+
RowKey = rowKey,
299+
Title = "Delta Test Post",
300+
Url = "https://example.com/delta-test",
301+
Author = "Delta Author",
302+
Date_published = "2025-06-03",
303+
is_read = false,
304+
Id = rowKey
305+
};
306+
}
307+
308+
private static Note CreateTestNote(string rowKey, string postId)
309+
{
310+
return new Note
311+
{
312+
PartitionKey = "test-delta-notes",
313+
RowKey = rowKey,
314+
PostId = postId,
315+
Comment = "Delta test comment",
316+
Tags = "delta, test",
317+
Category = "Technology"
318+
};
319+
}
320+
}

0 commit comments

Comments
 (0)