using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; /// WP-65 (read side): one aanvraag's case-treatment detail, gated by the same medewerker /// capability (`CanBeoordelen`, WP-62) as the werkvoorraad list (WP-64). public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture { private readonly HttpClient _client = factory.CreateClient(); private static HttpRequestMessage AsBehandelaar(HttpMethod method, string path) { var req = new HttpRequestMessage(method, path); req.Headers.Add("X-Medewerker", "medewerker-1"); return req; } private static MultipartFormDataContent UploadForm(string localId, string categoryId, string fileName) { var content = new MultipartFormDataContent(); var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); content.Add(file, "file", fileName); content.Add(new StringContent(categoryId), "categoryId"); content.Add(new StringContent(localId), "localId"); content.Add(new StringContent("registratie"), "wizardId"); return content; } /// A manual (never auto-approved) case with one linked document, so it stays /// InBehandeling/decidable regardless of test timing (the 8s auto-approval window /// would otherwise make a duo-registratie/herregistratie fixture flaky). private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument() { var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var a = (await created.Content.ReadFromJsonAsync())!; var localId = Guid.NewGuid().ToString(); var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf")); upload.EnsureSuccessStatusCode(); var doc = (await upload.Content.ReadFromJsonAsync())!; var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } }, }); submit.EnsureSuccessStatusCode(); return (a, doc.DocumentId!); } private Task DeleteAsAdmin(string id) => _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{id}") { Headers = { { "X-Role", "admin" } }, }); [Fact] public async Task Detail_shows_status_documents_and_a_masked_owner() { var (a, documentId) = await CreateManualCaseWithDocument(); try { var res = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); res.EnsureSuccessStatusCode(); var view = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("InBehandeling", view.Aanvraag.Status.Tag); Assert.Single(view.Documenten); Assert.Equal(documentId, view.Documenten[0].DocumentId); Assert.Equal("diploma", view.Documenten[0].CategoryId); Assert.True(view.Decisions.CanBesluiten); // masked: not empty, but not the full 9-digit BSN either var owner = view.Aanvraag.Owner!; Assert.NotEmpty(owner); Assert.Contains('*', owner); } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task Concept_and_unknown_id_are_not_found() { var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); var concept = (await created.Content.ReadFromJsonAsync())!; try { var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}")); Assert.Equal(HttpStatusCode.NotFound, conceptRes.StatusCode); var unknownRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, "/api/v1/beoordeling/does-not-exist")); Assert.Equal(HttpStatusCode.NotFound, unknownRes.StatusCode); } finally { await _client.DeleteAsync($"/api/v1/applications/{concept.Id}"); } } [Fact] public async Task Zorgverlener_is_forbidden_even_with_admin_role() { var (a, _) = await CreateManualCaseWithDocument(); try { var req = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"); req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task Medewerker_without_behandelaar_rol_is_forbidden() { var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/beoordeling/anything"); req.Headers.Add("X-Medewerker", "medewerker-2"); req.Headers.Add("X-Rollen", "geen"); Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); } private Task PostBesluit(string id, object body) { var req = AsBehandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit"); req.Content = JsonContent.Create(body); return _client.SendAsync(req); } [Fact] public async Task Goedkeuren_advances_status_to_Goedgekeurd() { var (a, _) = await CreateManualCaseWithDocument(); try { var res = await PostBesluit(a.Id, new { besluit = "Goedkeuren" }); res.EnsureSuccessStatusCode(); var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Goedgekeurd", body.Status.Tag); var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); var view = (await detail.Content.ReadFromJsonAsync())!; Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag); Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task Afwijzen_requires_a_toelichting() { var (a, _) = await CreateManualCaseWithDocument(); try { var missing = await PostBesluit(a.Id, new { besluit = "Afwijzen" }); Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); var res = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" }); res.EnsureSuccessStatusCode(); var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); Assert.Equal("Diploma niet erkend", body.Status.Reden); } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task MeerInfoOpvragen_is_still_decidable_afterwards() { var (a, _) = await CreateManualCaseWithDocument(); try { var res = await PostBesluit(a.Id, new { besluit = "MeerInfoOpvragen", toelichting = "Stuur een geldig diploma" }); res.EnsureSuccessStatusCode(); var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("MeerInfoGevraagd", body.Status.Tag); var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); var view = (await detail.Content.ReadFromJsonAsync())!; Assert.True(view.Decisions.CanBesluiten); // not terminal — a decision can still follow } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task Already_decided_case_rejects_a_further_besluit() { var (a, _) = await CreateManualCaseWithDocument(); try { (await PostBesluit(a.Id, new { besluit = "Goedkeuren" })).EnsureSuccessStatusCode(); var again = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "te laat" }); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); } finally { await DeleteAsAdmin(a.Id); } } // WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so // two besluiten racing on the same still-open aanvraag can't both pass the check before // either writes — exactly one commits, the other sees the now-terminal status. [Fact] public async Task Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success() { var (a, _) = await CreateManualCaseWithDocument(); try { var results = await Task.WhenAll( PostBesluit(a.Id, new { besluit = "Goedkeuren" }), PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" })); var winner = Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK); Assert.Single(results, r => r.StatusCode == HttpStatusCode.Conflict); // The persisted outcome must match whichever request actually won the race, not just // "some" besluit — the loser's write must never have landed. var winningTag = (await winner.Content.ReadFromJsonAsync())!.Status.Tag; var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}")); var finalTag = (await detail.Content.ReadFromJsonAsync())!.Aanvraag.Status.Tag; Assert.Equal(winningTag, finalTag); } finally { await DeleteAsAdmin(a.Id); } } [Fact] public async Task Unknown_id_404s_and_zorgverlener_is_forbidden() { var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" }); Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode); var (a, _) = await CreateManualCaseWithDocument(); try { var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{a.Id}/besluit") { Content = JsonContent.Create(new { besluit = "Goedkeuren" }), }; req.Headers.Add("X-Role", "admin"); // zorgverlener, no X-Medewerker Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode); } finally { await DeleteAsAdmin(a.Id); } } }