CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 56s
CI / frontend (pull_request) Successful in 2m36s
CI / storybook-a11y (pull_request) Failing after 3m19s
CI / backend (pull_request) Failing after 1m55s
CI / api-client-drift (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 40s
CI / semgrep (pull_request) Canceled after 24s
Adds POST /beoordeling/{id}/besluit: a Besluit enum (Goedkeuren/Afwijzen/
MeerInfoOpvragen) backed by new Aanvraag.BesluitStatus/BesluitToelichting
columns, gated by the same BeoordelingRules.CanDecide the read side's
canBesluiten flag already uses (409 on an illegal transition, 400 on a
missing required toelichting). Mappers.ToStatusDto gains the "a recorded
decision wins" branch. FE: besluit.machine.ts + besluit-form organism
(same form idiom as change-request-form), wired into the beoordeling page
behind the server's canBesluiten flag.
Completes WP-65 (65a + 65b) — verified end-to-end against a running
backend (werkvoorraad -> beoordeling -> besluit -> status reflected back).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
240 lines
8.6 KiB
C#
240 lines
8.6 KiB
C#
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<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<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
|
|
{
|
|
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
|
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
|
|
|
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/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<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/applications", new { type = "registratie" });
|
|
var concept = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
|
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<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
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|
|
}
|