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>
231 lines
10 KiB
C#
231 lines
10 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
/// <summary>
|
|
/// Org templates (WP-23): admin-only endpoints, draft→publish versioning, and the
|
|
/// sent-brief immutability invariant (pin at send, republish touches unsent only).
|
|
/// Same reset discipline as BriefEndpointTests — the stores are process-global.
|
|
/// </summary>
|
|
public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private const string Registers = OrgTemplateSeed.Registers;
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private HttpRequestMessage Req(HttpMethod method, string path, string? role = null, object? body = null)
|
|
{
|
|
var req = new HttpRequestMessage(method, path);
|
|
if (role is not null) req.Headers.Add("X-Role", role);
|
|
if (body is not null) req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
private async Task<OrgTemplateAdminViewDto> AdminView(string subOrgId = Registers)
|
|
{
|
|
var res = await _client.SendAsync(Req(HttpMethod.Get, $"/api/v1/admin/org-template/{subOrgId}", role: "admin"));
|
|
res.EnsureSuccessStatusCode();
|
|
return (await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>())!;
|
|
}
|
|
|
|
private static void ResetStores()
|
|
{
|
|
OrgTemplateStore.Reset();
|
|
BriefStore.Reset();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_endpoints_are_admin_only()
|
|
{
|
|
ResetStores();
|
|
// drafter (no header) and approver both bounce off every admin endpoint.
|
|
Assert.Equal(HttpStatusCode.Forbidden,
|
|
(await _client.GetAsync("/api/v1/admin/org-templates")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.Forbidden,
|
|
(await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "approver"))).StatusCode);
|
|
|
|
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/admin/org-templates", role: "admin"));
|
|
res.EnsureSuccessStatusCode();
|
|
var list = await res.Content.ReadFromJsonAsync<List<SubOrgSummaryDto>>();
|
|
Assert.Equal(new[] { Registers, OrgTemplateSeed.Vakbekwaamheid }, list!.Select(s => s.SubOrgId));
|
|
Assert.All(list!, s => Assert.Equal(1, s.PublishedVersion));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Publish_increments_the_version()
|
|
{
|
|
ResetStores();
|
|
// 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();
|
|
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
|
|
Assert.Equal(2, published!.Version);
|
|
|
|
var view = await AdminView();
|
|
Assert.Equal(2, view.PublishedVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Publish_appends_to_the_version_history()
|
|
{
|
|
ResetStores();
|
|
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();
|
|
|
|
var view = await AdminView();
|
|
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Publish_counts_the_unsent_briefs_it_affects()
|
|
{
|
|
ResetStores();
|
|
// 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();
|
|
var published = await res.Content.ReadFromJsonAsync<PublishOrgTemplateResponse>();
|
|
Assert.Equal(1, published!.AffectedUnsentBriefs);
|
|
|
|
var view = await AdminView();
|
|
Assert.Equal(1, view.UnsentBriefs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_draft_validates_margins()
|
|
{
|
|
ResetStores();
|
|
var draft = (await AdminView()).Draft;
|
|
|
|
var invalid = draft with { Margins = new MarginsDto(5, 20, 20, 25) };
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await _client.SendAsync(
|
|
Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
|
body: new SaveOrgTemplateRequest(invalid)))).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_draft_round_trips_the_edited_values()
|
|
{
|
|
ResetStores();
|
|
var draft = (await AdminView()).Draft;
|
|
|
|
var valid = draft with { OrgName = "BIG-register (nieuw)", Margins = new MarginsDto(30, 20, 20, 25) };
|
|
var res = await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
|
body: new SaveOrgTemplateRequest(valid)));
|
|
res.EnsureSuccessStatusCode();
|
|
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
|
Assert.Equal("BIG-register (nieuw)", view!.Draft.OrgName);
|
|
Assert.Equal(30, view.Draft.Margins.TopMm);
|
|
// Saving a draft publishes nothing.
|
|
Assert.Equal(1, view.PublishedVersion);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rollback_copies_an_old_version_into_the_draft_without_rewriting_history()
|
|
{
|
|
ResetStores();
|
|
var draft = (await AdminView()).Draft;
|
|
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
|
body: new SaveOrgTemplateRequest(draft with { OrgName = "Versie twee" })));
|
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
|
|
|
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/1", role: "admin"));
|
|
res.EnsureSuccessStatusCode();
|
|
var view = await res.Content.ReadFromJsonAsync<OrgTemplateAdminViewDto>();
|
|
Assert.Equal("BIG-register", view!.Draft.OrgName); // v1 content back in the draft
|
|
Assert.Equal(2, view.PublishedVersion); // still live: v2 (rollback ≠ publish)
|
|
Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); // append-only, untouched
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.SendAsync(
|
|
Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/rollback/99", role: "admin"))).StatusCode);
|
|
}
|
|
|
|
// Walk one brief all the way to sent under template v1, then republish the org
|
|
// template under a new name. Both invariant tests below share this exact
|
|
// precondition (the stores are process-global, so each sets it up).
|
|
private async Task WalkBriefToSentThenRepublish()
|
|
{
|
|
ResetStores();
|
|
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
|
|
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
|
: s.Blocks))
|
|
.ToList();
|
|
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
|
|
var sent = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/send"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal(1, sent!.OrgTemplate.Version);
|
|
|
|
// Republish with a new org name.
|
|
var draft = (await AdminView()).Draft;
|
|
await _client.SendAsync(Req(HttpMethod.Put, $"/api/v1/admin/org-template/{Registers}", role: "admin",
|
|
body: new SaveOrgTemplateRequest(draft with { OrgName = "Hertitelde organisatie" })));
|
|
await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sent_brief_keeps_its_pinned_template_after_a_republish()
|
|
{
|
|
await WalkBriefToSentThenRepublish();
|
|
|
|
// The sent brief still renders v1 with the old name (immutable).
|
|
var sentView = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
|
Assert.Equal(1, sentView!.OrgTemplate.Version);
|
|
Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unsent_brief_follows_a_republish()
|
|
{
|
|
await WalkBriefToSentThenRepublish();
|
|
|
|
// A fresh (unsent) brief follows the new published version.
|
|
var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal(2, freshView!.OrgTemplate.Version);
|
|
Assert.Equal("Hertitelde organisatie", freshView.OrgTemplate.OrgName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Me_returns_the_orgtemplate_capability_for_admin()
|
|
{
|
|
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/me", role: "admin"));
|
|
var me = await res.Content.ReadFromJsonAsync<MeDto>();
|
|
Assert.Equal(
|
|
new[] { "orgtemplate:edit", "stamdata:edit", "cases:manage", "flags:manage" },
|
|
me!.Capabilities);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Admin_cannot_slip_into_the_brief_review_flow()
|
|
{
|
|
ResetStores();
|
|
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
|
|
? new[] { new LetterBlockDto("freeText", "b1", new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })) }
|
|
: s.Blocks))
|
|
.ToList();
|
|
await _client.PutAsJsonAsync("/api/v1/brief", new SaveBriefRequest(filled));
|
|
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
|
|
|
// Approve/reject require the Approver role explicitly — admin passes SoD
|
|
// (different identity) but must still be Forbidden.
|
|
Assert.Equal(HttpStatusCode.Forbidden,
|
|
(await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "admin"))).StatusCode);
|
|
}
|
|
}
|