Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 92b19e5d7b arch(workflow): ADR-0014 — withdrawal cancels the process via a message event (refs #12)
CI / lint (pull_request) Failing after 1m16s
CI / mutation (pull_request) Has been cancelled
CI / unit (pull_request) Failing after 1m1s
CI / build (pull_request) Failing after 53s
CI / verify-stack (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 18ab868724 test(verify): the domain check exercises withdrawal cancelling the Beoordelen task (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 edffbb4575 feat(domain): withdrawal cancels the registratie process via a BPMN message event (refs #12)
Add an interrupting message boundary event (RegistratieIngetrokken) on the Beoordelen task that

ends the process; the Workflow Client delivers the message to the task's execution, and the

withdraw handler triggers it best-effort after the domain transition (ADR-0014).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 2a6874ef6d test(domain): withdrawal cancels the workflow via the Beoordelen task's execution (refs #12)
The WithdrawRegistration handler delivers the withdrawal message to the open Beoordelen task's

execution; the Workflow Client PUTs messageEventReceived (RegistratieIngetrokken). BeoordelingTask

now carries its executionId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 435db81bdf feat(domain): the werkbak lists only registrations still open for beoordeling (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
notandClaude Opus 4.8 345191b1df test(domain): the werkbak drops a registration that is no longer open (refs #12)
A withdrawn registration may keep a lingering Beoordelen task until the workflow cancels it; the

werkbak must list only registrations still open for beoordeling, so it drops off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
22 changed files with 111 additions and 495 deletions
@@ -29,12 +29,11 @@ task; the Workflow Client correlates a `RegistratieIngetrokken` message to the t
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete). (the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable - **Correlated via the Beoordelen task's execution.** The `WithdrawRegistration` handler already
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that knows the registration; it finds the open `Beoordelen` task for it (the same task-query the werkbak
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the uses, §8.2) and asks the Workflow Client to deliver the withdrawal message to that task's execution
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it. (`messageEventReceived`). No separate correlation store is needed — the werkbak task set is the
Targeting the subscribed execution (not the user task's execution — a message boundary event's authoritative correlation, exactly as the beoordeling decision reuses it (ADR-0013).
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has - **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
+1 -120
View File
@@ -17,12 +17,7 @@
# #
# Port map (host): # Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST # 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only) # 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
services: services:
@@ -210,12 +205,6 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true" KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true" KC_HTTP_ENABLED: "true"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports: ports:
- "8180:8080" - "8180:8080"
volumes: volumes:
@@ -306,14 +295,6 @@ services:
context: ../services/bff context: ../services/bff
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -322,39 +303,6 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
start_period: 10s start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg] networks: [cg]
# ── Read projection (S-06) ──────────────────────────────────────────────── # ── Read projection (S-06) ────────────────────────────────────────────────
@@ -414,73 +362,6 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}
+1 -3
View File
@@ -142,10 +142,8 @@ done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } [ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain" echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
docker run --rm --network "$net" curlimages/curl:latest \ docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \ -fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)" echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone="" gone=""
@@ -192,40 +192,6 @@ export class BffApiV1Service {
); );
} }
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>; getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
-16
View File
@@ -22,11 +22,6 @@ public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary> /// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default); Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
@@ -52,17 +47,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? []; => await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
-18
View File
@@ -86,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09). // Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) => app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{ {
-12
View File
@@ -82,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
/// (false → 404). Tests set this to exercise the relay.</summary>
public bool WithdrawSucceeds { get; set; } = true;
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
Withdrawn = (registrationId, bsn);
return Task.FromResult(WithdrawSucceeds);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; } public (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
@@ -71,46 +71,5 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId); Assert.Equal("reg-123", body!.RegistrationId);
} }
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status); private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
} }
-31
View File
@@ -30,37 +30,6 @@
} }
} }
}, },
"/self-service/registrations/{id}/withdraw": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/openbaar/register": { "/openbaar/register": {
"get": { "get": {
"tags": [ "tags": [
+6 -11
View File
@@ -76,19 +76,16 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
}); });
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back, // Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF // advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is // to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
// 404 (indistinguishable, so ownership isn't leaked). Idempotent. // process is a later sub-slice (S-11b).
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) => app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
{ {
if (!Guid.TryParse(id, out var guid)) if (!Guid.TryParse(id, out var guid))
return Results.NotFound(); return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn)) await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." }); return Results.NoContent();
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
}); });
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
@@ -116,8 +113,6 @@ public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit); public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program; public partial class Program;
+9 -10
View File
@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id. /// aggregate. Returns the process instance id.
/// </summary> /// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -62,11 +54,18 @@ public interface IUserTaskClient
/// <summary>Complete a beoordeling task, carrying the decision into the process as the /// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary> /// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
/// <summary>Deliver the withdrawal message to a <c>Beoordelen</c> task's execution, tripping the
/// process's interrupting message boundary event so the registratie process cancels (S-11,
/// ADR-0014). The registration itself is already INGETROKKEN in the domain; this ends its
/// workflow so the case leaves the werkbak.</summary>
Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default);
} }
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and /// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary> /// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); /// event), and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
/// <summary> /// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice /// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
@@ -2,54 +2,43 @@ using Big.Domain;
namespace Big.Application; namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in"). /// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF): public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary> /// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process /// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the /// by delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn /// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the /// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is
/// registration never started a process the withdrawal still stands (the process cancel is skipped), /// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort. /// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>.
/// </summary> /// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow) public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks)
{ {
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(command); ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct); var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return WithdrawOutcome.NotFound;
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again. // A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken) if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn; return;
registration.Withdraw(); registration.Withdraw();
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
await CancelWorkflowTaskAsync(command.RegistrationId, ct);
}
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak. // Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so
if (registration.ProcessInstanceId is not null) // the process's boundary event ends it. If none is open the withdrawal still stands.
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct); private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct)
{
return WithdrawOutcome.Withdrawn; var open = await tasks.GetOpenBeoordelingenAsync(ct);
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
if (task is not null)
await tasks.WithdrawBeoordelingAsync(task.ExecutionId, ct);
} }
} }
@@ -66,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
"service/query/tasks", request, ct); "service/query/tasks", request, ct);
var tasks = page?.Data ?? []; var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))];
} }
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
@@ -86,33 +86,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{ {
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the // Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's
// interrupting message event's own execution — NOT the user task's), then deliver // interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is // takes messageEventReceived as a PUT on the subscribed execution.
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage); var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct) using var response = await SendAsync(
{ $"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put);
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
} }
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
@@ -171,6 +154,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record UserTaskDto( private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("executionId")] string ExecutionId,
// Flowable's task-query returns the (included) process variables under "variables", not // Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables. // "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
@@ -188,11 +172,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredJob( private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables) [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
} }
private static FakeUserTaskClient TaskFor(Registration registration) => private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", registration.Id)]); new([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
[Fact] [Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
+7 -7
View File
@@ -32,7 +32,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient : IWorkflowClient
{ {
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -40,12 +39,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(processInstanceId); return Task.FromResult(processInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of /// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
@@ -54,6 +47,7 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
{ {
public (string TaskId, string Behandelaar)? Claimed { get; private set; } public (string TaskId, string Behandelaar)? Claimed { get; private set; }
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public string? WithdrawnExecutionId { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default) public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open); => Task.FromResult(open);
@@ -69,6 +63,12 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
Completed = (taskId, besluit); Completed = (taskId, besluit);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
WithdrawnExecutionId = executionId;
return Task.CompletedTask;
}
} }
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a /// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
@@ -158,13 +158,14 @@ public class FlowableWorkflowClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, var client = Client(capture.Responds(HttpStatusCode.OK,
$$""" $$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} {"data":[{"id":"task-1","executionId":"exec-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
""")); """));
var tasks = await client.GetOpenBeoordelingenAsync(); var tasks = await client.GetOpenBeoordelingenAsync();
var task = Assert.Single(tasks); var task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId); Assert.Equal("task-1", task.TaskId);
Assert.Equal("exec-1", task.ExecutionId);
Assert.Equal(rid, task.RegistrationId); Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks", Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
@@ -243,58 +244,29 @@ public class FlowableWorkflowClientTests
} }
[Fact] [Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution() public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution()
{ {
HttpRequestMessage? getReq = null; var capture = new RequestCapture();
HttpRequestMessage? putReq = null; var client = Client(capture.Responds(HttpStatusCode.OK));
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1"); await client.WithdrawBeoordelingAsync("exec-1");
// 1. Find the execution subscribed to the withdrawal message for this instance. // A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014).
Assert.Equal(HttpMethod.Get, getReq!.Method); Assert.Equal(HttpMethod.Put, capture.Seen!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString()); Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1",
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query); capture.Seen.RequestUri!.ToString());
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query); Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event. Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body);
Assert.Equal(HttpMethod.Put, putReq!.Method); Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
} }
[Fact] [Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed() public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request()
{ {
var methods = new List<HttpMethod>(); var capture = new RequestCapture();
var client = Client(new StubHandler(req => var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1"); await Assert.ThrowsAsync<HttpRequestException>(() => client.WithdrawBeoordelingAsync("exec-1"));
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
} }
[Fact] [Fact]
+3 -3
View File
@@ -18,7 +18,7 @@ public class WerkbakTests
registration.AttachZaak(FakeAclClient.DefaultZaakUrl); registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling(); registration.TakeIntoBehandeling();
store.Seed(registration); store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store); var werkbak = new Werkbak(tasks, store);
var items = await werkbak.GetAsync(); var items = await werkbak.GetAsync();
@@ -41,7 +41,7 @@ public class WerkbakTests
public async Task Skips_a_task_whose_registration_is_unknown() public async Task Skips_a_task_whose_registration_is_unknown()
{ {
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for. // Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",RegistrationId.New())]);
var werkbak = new Werkbak(tasks, new FakeRegistrationStore()); var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync()); Assert.Empty(await werkbak.GetAsync());
@@ -56,7 +56,7 @@ public class WerkbakTests
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.Withdraw(); registration.Withdraw();
store.Seed(registration); store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store); var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync()); Assert.Empty(await werkbak.GetAsync());
@@ -5,101 +5,75 @@ namespace Big.Tests;
public class WithdrawRegistrationTests public class WithdrawRegistrationTests
{ {
private const string Bsn = "123456782"; private static FakeUserTaskClient NoTasks() => new([]);
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
return registration;
}
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact] [Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store, NoTasks());
var outcome = await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
} }
[Fact] [Fact]
public async Task Withdrawing_cancels_the_running_process() public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted("proc-42"); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var workflow = new FakeWorkflowClient(); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
var handler = new WithdrawRegistration(store, workflow); var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014). // The withdrawal message is delivered to the Beoordelen task's execution, tripping the
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId); // boundary event that ends the process (ADR-0014).
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
} }
[Fact] [Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken() public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken()
{ {
// If the process has not parked at Beoordelen yet (or already ended), the withdrawal stands:
// the aggregate is INGETROKKEN and nothing is cancelled.
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var workflow = new FakeWorkflowClient(); var tasks = NoTasks();
var handler = new WithdrawRegistration(store, workflow); var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId); Assert.Null(tasks.WithdrawnExecutionId);
}
[Fact]
public async Task A_different_bsn_cannot_withdraw_the_registration()
{
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
// NotFound (we don't reveal the registration exists) and nothing is changed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal(0, store.SaveCount);
Assert.Null(workflow.WithdrawnProcessInstanceId);
} }
[Fact] [Fact]
public async Task Rejects_a_null_command_without_touching_the_store() public async Task Rejects_a_null_command_without_touching_the_store()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store, NoTasks());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
} }
[Fact] [Fact]
public async Task Withdrawing_an_unknown_registration_is_not_found() public async Task Withdrawing_an_unknown_registration_throws()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store, NoTasks());
var outcome = await handler.HandleAsync(Command(RegistrationId.New())); var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Equal(WithdrawOutcome.NotFound, outcome); Assert.Contains("No registration", ex.Message);
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
} }
@@ -107,16 +81,14 @@ public class WithdrawRegistrationTests
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Registration.Submit("123456782");
store.Seed(registration); store.Seed(registration);
var workflow = new FakeWorkflowClient(); var handler = new WithdrawRegistration(store, NoTasks());
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
var second = await handler.HandleAsync(Command(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again. // The second withdrawal is a no-op: the aggregate is not persisted again.
Assert.Equal(WithdrawOutcome.Withdrawn, second);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
} }
@@ -69,9 +69,6 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]); => Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
@@ -11,19 +11,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1"; public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; } public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
StartedFor = registrationId; StartedFor = registrationId;
return Task.FromResult(StartedProcessInstanceId); return Task.FromResult(StartedProcessInstanceId);
} }
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, /// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,