fix(brief): make GET /brief a pure query, 404 when absent (RB-23)

GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.

Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.

RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.

Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 19:01:06 +02:00
co-authored by Claude Opus 5
parent 05dff974bf
commit d0fda08bcc
13 changed files with 305 additions and 78 deletions
@@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
modelBuilder.Entity<BriefEntity>(e =>
{
e.HasKey(b => b.BriefId);
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant)
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
@@ -47,17 +47,15 @@ public static class BriefStore
private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner)
/// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null —
/// the owner's first-ever draft is created only through the explicit `ResetAndCreate`
/// command (`POST /brief/reset`), never as a side effect of a read.
public static BriefEntity? Get(string owner)
{
lock (_gate)
{
using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (existing is not null) return existing;
var created = BriefSeed.NewBrief(owner);
db.Briefs.Add(created);
db.SaveChanges();
return created;
return db.Briefs.FirstOrDefault(e => e.Owner == owner);
}
}
+14 -4
View File
@@ -675,10 +675,15 @@ api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpCon
api.MapGet("/brief", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
return ToView(ctx, e);
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
// draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
// — this GET is a pure query and 404s when there is nothing to read yet.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
if (e is null) return Results.NotFound();
return Results.Ok(ToView(ctx, e));
})
.Produces<BriefViewDto>();
.Produces<BriefViewDto>()
.Produces(StatusCodes.Status404NotFound);
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{
@@ -766,7 +771,12 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// letters serve their frozen archive; anything else renders live with a watermark.
api.MapGet("/brief/preview", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
// RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET
// must not create a brief as a side effect either, so it 404s under the same
// precondition as GET /brief — in the running app the FE only reaches this endpoint
// from the brief page, which has already loaded (and, if needed, reset) a brief.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
if (e is null) return Results.NotFound();
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
return Results.Content(archived, "text/html");
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
+3
View File
@@ -1000,6 +1000,9 @@
}
}
}
},
"404": {
"description": "Not Found"
}
}
},
@@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return new SaveBriefRequest(sections);
}
private async Task<BriefDto> Get()
/// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that
/// needs one present creates it explicitly through `POST /brief/reset`
/// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses.
private async Task<BriefDto> SeedBrief()
{
BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
var res = await _client.PostAsync("/api/v1/brief/reset", null);
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(view);
return view.Brief;
}
@@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return req;
}
// --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. ---
[Fact]
public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty()
public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
{
var brief = await Get();
BriefStore.Reset();
var res = await _client.GetAsync("/api/v1/brief");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
// The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call.
// Assert directly against the store, not only the HTTP status, so a regression that
// reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404.
Assert.Null(BriefStore.Get(DocumentStore.DemoOwner));
}
[Fact]
public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty()
{
var brief = await SeedBrief();
Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
@@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// global passages + the arts-scoped one; no other-beroep passages leak in.
@@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// Case context is joined onto the screen DTO for the behandel scherm header.
@@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Save_is_drafter_only()
{
var brief = await Get();
var brief = await SeedBrief();
var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver");
@@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_blocks_on_empty_required_section()
{
await Get();
await SeedBrief();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
}
@@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Submit_succeeds_when_required_sections_filled()
{
await Get();
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief));
@@ -170,7 +190,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -187,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reject_returns_comments()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -202,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync(
@@ -218,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Send_only_from_approved()
{
var brief = await Get();
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -236,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await Get();
var brief = await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
@@ -269,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{
var brief = await Get();
var brief = await SeedBrief();
// Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_increments_the_version()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_appends_to_the_version_history()
{
ResetStores();
await _client.GetAsync("/api/v1/brief");
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_counts_the_unsent_briefs_it_affects()
{
ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read).
await _client.GetAsync("/api/v1/brief");
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
await _client.PostAsync("/api/v1/brief/reset", null);
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode();
@@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
private async Task WalkBriefToSentThenRepublish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
@@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Admin_cannot_slip_into_the_brief_review_flow()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0
@@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{
ResetStores();
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode();
@@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{
ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
@@ -72,14 +72,14 @@ public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixt
// enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's
// Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers,
// not a missing one. ---
new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn)."),
new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."),
new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."),
new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."),
new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."),
new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."),
new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."),
new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."),
new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); hand-written FE fetch."),
new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."),
new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."),
];