feat(#13): S-12c-2 — behandel decide → domain + complete workflow task (#86)
All checks were successful
All checks were successful
## What & why
Second half of **S-12c** (behandel-portal backend), completing the decision path per **ADR-0013**:
- **Domain:** `BeoordeelRegistratie` now, after applying the decision (aggregate + ACL for approval), **completes the open Flowable `Beoordelen` task** for that registration (found by registrationId) with the besluit, so the workflow advances. No open task → the decision still stands (completes nothing); idempotent.
- **BFF:** `POST /behandel/registrations/{id}/decide` behind the medewerker/`behandelaar` policy, forwarding `goedkeuren`/`afwijzen` to the domain. Validates the besluit vocabulary (400 on unknown) without troubling the domain.
Behavior: decide is **401** without a token, **403** without the role, **400** for an unknown besluit, **204** (forwarded) for a behandelaar.
This completes the behandel backend. **S-12d** (the Angular behandel-portal + Playwright e2e) closes umbrella #13 and retires the temporary `/approve`.
## Definition of Done
- [x] Linked issue: #13 (umbrella, `refs`)
- [x] Tests first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 79, bff 27, acceptance 9 (acl/event-subscriber unaffected)
- [x] Beoordeling acceptance scenario asserts task completion (goedkeuren + afwijzen)
- [x] openapi.json + api-client regenerated (drift guard passes)
- [x] Mutation ≥ break(90): **domain 100%, bff 100%**
- [ ] CI green (pending)
Part of #13.
Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
@@ -24,6 +24,9 @@ public interface IDomainClient
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
|
||||
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Port to the read projection.</summary>
|
||||
@@ -47,6 +50,13 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"registrations/{registrationId}/decide", new { besluit }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,8 +103,28 @@ app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken c
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
|
||||
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
|
||||
app.MapPost("/behandel/registrations/{id}/decide",
|
||||
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
|
||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||
|
||||
await domain.DecideAsync(id, body.Besluit, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(BehandelAuth.Policy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
|
||||
internal static class BehandelAuth
|
||||
{
|
||||
@@ -112,6 +132,12 @@ internal static class BehandelAuth
|
||||
public const string Policy = "behandelaar";
|
||||
public const string BehandelaarRole = "behandelaar";
|
||||
|
||||
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
|
||||
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
|
||||
public static bool IsKnownBesluit(string? besluit) =>
|
||||
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||
public static void AddRealmRoles(ClaimsPrincipal? principal)
|
||||
|
||||
@@ -54,4 +54,61 @@ public class BehandelEndpointTests
|
||||
Assert.Equal("reg-1", item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
|
||||
{
|
||||
Content = JsonContent.Create(new { besluit }),
|
||||
};
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_decision_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forwards_a_behandelaar_decision_to_the_domain()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient()
|
||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient()
|
||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,16 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
||||
|
||||
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||
{
|
||||
Decided = (registrationId, besluit);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||
|
||||
@@ -88,10 +88,62 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/behandel/registrations/{id}/decide": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DecideRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"DecideRequest": {
|
||||
"required": [
|
||||
"besluit"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"besluit": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpenbaarEntry": {
|
||||
"required": [
|
||||
"id",
|
||||
|
||||
@@ -20,10 +20,12 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
|
||||
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
|
||||
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
|
||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
|
||||
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
|
||||
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
|
||||
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
|
||||
/// <c>Beoordelen</c> task (found by registrationId) so the workflow advances (ADR-0013). Both
|
||||
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
|
||||
/// state is a no-op, so the ACL is not called and the task not completed twice.
|
||||
/// </summary>
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
|
||||
{
|
||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
||||
{
|
||||
@@ -53,5 +55,17 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
|
||||
}
|
||||
|
||||
await store.SaveAsync(registration, ct);
|
||||
await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct);
|
||||
}
|
||||
|
||||
// Advance the workflow: complete the open Beoordelen task for this registration. If none is open
|
||||
// (already completed, or the process hasn't parked yet) the decision still stands — we complete
|
||||
// nothing rather than fail.
|
||||
private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
|
||||
if (task is not null)
|
||||
await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ namespace Big.Tests;
|
||||
/// <summary>
|
||||
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
|
||||
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
|
||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
|
||||
/// idempotent so a repeated or redelivered decision is a no-op.
|
||||
/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by
|
||||
/// registrationId) so the process advances. All idempotent — a repeated decision is a no-op.
|
||||
/// </summary>
|
||||
public class BeoordeelRegistratieTests
|
||||
{
|
||||
@@ -18,6 +18,9 @@ public class BeoordeelRegistratieTests
|
||||
return registration;
|
||||
}
|
||||
|
||||
private static FakeUserTaskClient TaskFor(Registration registration) =>
|
||||
new([new BeoordelingTask("task-1", registration.Id)]);
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
||||
{
|
||||
@@ -25,7 +28,8 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
@@ -34,6 +38,8 @@ public class BeoordeelRegistratieTests
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
// The behandelaar's decision advances the workflow: the Beoordelen task is completed.
|
||||
Assert.Equal(("task-1", BeoordelingsBesluit.Goedkeuren), tasks.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,7 +49,8 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
@@ -51,6 +58,7 @@ public class BeoordeelRegistratieTests
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,7 +69,7 @@ public class BeoordeelRegistratieTests
|
||||
var registration = WithZaak();
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
@@ -73,7 +81,7 @@ public class BeoordeelRegistratieTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
@@ -85,7 +93,7 @@ public class BeoordeelRegistratieTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
||||
@@ -100,7 +108,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
||||
@@ -115,7 +123,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
@@ -131,7 +139,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
@@ -139,4 +147,22 @@ public class BeoordeelRegistratieTests
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration()
|
||||
{
|
||||
// The task may already be gone (redelivery / manual completion). The decision still applies
|
||||
// and simply completes nothing rather than failing.
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([]); // no open task for this registration
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
Assert.Null(tasks.Completed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user