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
@@ -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"));