Files
atomic-design-poc/backend/tests/BigRegister.Tests/BeoordelingTests.cs
T
ehoandClaude Sonnet 5 8560746d15 refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:48:08 +02:00

289 lines
11 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// One aanvraag's case-treatment detail (read side), gated by the same medewerker
/// capability (`CanBeoordelen`) as the werkvoorraad list.
public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
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<(AanvraagDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
{
var created = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
var a = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
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<UploadResponse>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{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<BeoordelingViewDto>())!;
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/aanvragen", new { type = "registratie" });
var concept = (await created.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
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/aanvragen/{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<HttpResponseMessage> 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<RecordBesluitResponse>())!;
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<BeoordelingViewDto>())!;
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
// BIO-007: the gate records that a behandelaar was allowed to act; this records
// what they decided, which is the question /beheer/audit exists to answer.
Assert.Contains(AuthzAuditStore.List(), e =>
e.Action == "aanvraag:besluit" && e.Decision == "allow" &&
e.Resource == $"aanvraag/{a.Id}/Goedkeuren");
}
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<RecordBesluitResponse>())!;
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<RecordBesluitResponse>())!;
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<BeoordelingViewDto>())!;
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);
}
}
// 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<RecordBesluitResponse>())!.Status.Tag;
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
var finalTag = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!.Aanvraag.Status.Tag;
Assert.Equal(winningTag, finalTag);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Unknown_id_404s()
{
// Given no case exists with this id.
// When a besluit is posted against it...
var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" });
// Then the endpoint answers 404, not a decision.
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
}
[Fact]
public async Task Zorgverlener_is_forbidden_from_deciding()
{
// Given a decidable case.
var (a, _) = await CreateManualCaseWithDocument();
try
{
// When a zorgverlener (no X-Medewerker) posts a besluit against it...
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
var response = await _client.SendAsync(req);
// Then the request is forbidden — deciding is a behandelaar-only capability.
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
}