feat(behandelportal): WP-65b beoordeling besluit (decision write)
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>
This commit is contained in:
eho
2026-08-03 09:46:20 +02:00
co-authored by Claude Sonnet 5
parent 4133b30e5d
commit af8a011819
20 changed files with 1303 additions and 20 deletions
@@ -127,4 +127,113 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
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);
}
}
}