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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'."),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user