using System.Net; using System.Net.Http.Json; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; /// /// 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. /// public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClassFixture { 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 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())!; } 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>(); 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_version_appends_history_and_counts_unsent_briefs() { ResetStores(); // One unsent brief for this sub-org (GetOrCreate on first read). await _client.GetAsync("/api/v1/brief"); 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(); Assert.Equal(2, published!.Version); Assert.Equal(1, published.AffectedUnsentBriefs); var view = await AdminView(); Assert.Equal(2, view.PublishedVersion); Assert.Equal(new[] { 1, 2 }, view.History.Select(h => h.Version)); Assert.Equal(1, view.UnsentBriefs); } [Fact] public async Task Save_draft_validates_margins_and_round_trips() { 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); 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(); 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(); 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); } [Fact] public async Task Sent_brief_keeps_its_pinned_template_while_an_unsent_brief_follows_a_republish() { ResetStores(); // Walk one brief to sent under template v1. var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.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(); 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")); // The sent brief still renders v1 with the old name (immutable)... var sentView = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.Equal(1, sentView!.OrgTemplate.Version); Assert.Equal("BIG-register", sentView.OrgTemplate.OrgName); // ...while a fresh (unsent) brief follows the new published version. var freshView = await (await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/reset"))).Content.ReadFromJsonAsync(); 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(); Assert.Equal(new[] { "orgtemplate:edit" }, me!.Capabilities); } [Fact] public async Task Admin_cannot_slip_into_the_brief_review_flow() { ResetStores(); var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.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); } }