From a34caba9eab7ac2fb090f3f6571d226866d2ad5a Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 16 Jul 2026 11:09:28 +0000 Subject: [PATCH] feat(domain): withdrawal cancels the registratie process (S-11b, refs #12) (#89) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why Second sub-slice of **S-11 · Withdrawal (Flow 3)** (#12). S-11a (#88) made a withdrawal advance the aggregate to INGETROKKEN; this sub-slice **cancels the running Flowable process** so the withdrawn case leaves the behandelaar's werkbak. - **BPMN** (`registratie.bpmn`): an interrupting message boundary event (`RegistratieIngetrokken`) on the `Beoordelen` task, routing to a dedicated "Registratie ingetrokken" end event. - **Workflow Client**: `WithdrawBeoordelingAsync(executionId)` delivers `messageEventReceived` to the task's execution (PUT); `BeoordelingTask` now carries its `executionId`. - **`WithdrawRegistration` handler**: after the domain transition, finds the open `Beoordelen` task for the registration and delivers the withdrawal message — best-effort, mirroring how the beoordeling completes its task. - **Werkbak**: also filters out registrations that are no longer open, so a withdrawn case never surfaces even in the brief window before cancellation lands. - **ADR-0014** records the decision (message event in BPMN vs. deleting the instance from code). - **verify (`run-domain-check.sh`)**: a second registration parks at `Beoordelen`, is withdrawn via the domain, and the check asserts its `Beoordelen` task disappears — so verify-stack validates the live Flowable message correlation. Refs #12 (S-11c — the BFF + self-service "trek aanvraag in" button + e2e — closes it). ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation (red → green per commit). - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected (BPMN redeploys on a fresh CI DB via flowable-init). - [x] ADR added (ADR-0014). - [x] Docs — the user-visible demo note lands with S-11c. ## Notes for reviewers - Verified locally: `Big.Tests` 94/94 pass; `Big.Api` builds; `registratie.bpmn` is well-formed. - The Flowable message-correlation REST shape is validated **live** by verify-stack (the Workflow Client unit tests stub the exchange and assert only the request shape, per ADR-0009) — the new `run-domain-check.sh` withdrawal step is that live check. - Known gap (ADR-0014): a withdrawal that races ahead of the process reaching `Beoordelen` finds no task to cancel; the aggregate is still INGETROKKEN and the werkbak filter hides it, but that instance parks unattended. A process-level event subprocess would close the gap — deferred. Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/89 --- ...adr-0014-withdrawal-cancels-the-process.md | 72 +++++++++++++++++++ infra/run-domain-check.sh | 47 ++++++++++-- services/domain/Big.Application/Ports.cs | 8 +++ services/domain/Big.Application/Werkbak.cs | 12 +++- .../Big.Application/WithdrawRegistration.cs | 19 +++-- .../FlowableWorkflowClient.cs | 43 ++++++++++- services/domain/Big.Tests/Fakes.cs | 7 ++ .../Big.Tests/FlowableWorkflowClientTests.cs | 55 ++++++++++++++ services/domain/Big.Tests/WerkbakTests.cs | 15 ++++ .../Big.Tests/WithdrawRegistrationTests.cs | 52 ++++++++++++-- .../acceptance/Support/InMemoryDomainPorts.cs | 7 ++ workflows/registratie.bpmn | 28 +++++++- 12 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 docs/architecture/adr-0014-withdrawal-cancels-the-process.md diff --git a/docs/architecture/adr-0014-withdrawal-cancels-the-process.md b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md new file mode 100644 index 0000000..51cf3a6 --- /dev/null +++ b/docs/architecture/adr-0014-withdrawal-cancels-the-process.md @@ -0,0 +1,72 @@ +# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event + +- **Status:** Accepted +- **Date:** 2026-07-16 +- **Deciders:** Respellion engineering +- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013 + (behandel-portal wiring, the Beoordelen user task) + +## Context + +S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already +advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in +Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration +would linger as work for a behandelaar. The withdrawal must also **cancel the running process**. + +Two questions shape this sub-slice. + +1. **How does the case get cancelled — in code, or in the BPMN model?** +2. **How does a withdrawal correlate to the right running process instance?** + +## Decision + +**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen` +task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.** + +- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting + message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie + ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow + 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 process ended via the ingetrokken path, not a raw delete). +- **Correlated by the registration's own process instance.** The aggregate records its Flowable + process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that + id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the + `RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it. + Targeting the subscribed execution (not the user task's execution — a message boundary event's + 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 + 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 + the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie` + completes its task best-effort. + +## Consequences + +**Positive** + +- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code + that talks to Flowable and does not delete instances behind the model's back. +- Reuses the existing task-query correlation — no new plumbing, no correlation store. +- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also + filters non-open registrations as a belt-and-braces for the brief window before cancellation lands. + +**Negative / costs** + +- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds) + finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no + one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this + reference at these volumes; a process-level interrupting event subprocess would close the gap and + is an additive follow-up if it matters. +- The Flowable message-correlation REST shape is validated live (verify-stack), not in the + Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape + (consistent with ADR-0009). + +## Alternatives considered + +- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`) + — rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code, + not the process. The message event keeps the cancellation a first-class part of the workflow. +- **Interrupting message event subprocess at process level** — more robust (correlates anytime, + closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive + change if the race proves to matter. diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index a701b21..74d6122 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -79,9 +79,9 @@ fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)" fl_base="http://$(ip "$fl"):8080/flowable-rest/service" reg_id="${loc##*/}" -# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin. -# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. -task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json +# Extracts the Beoordelen task id for a given registration from a Flowable task-query response on +# stdin. Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. +task_for_reg() { REG_ID="$1" python3 -c "import os,sys,json try: d=json.load(sys.stdin) except Exception: @@ -98,7 +98,7 @@ echo ">> polling Flowable for the Beoordelen user task (werkbak)" task_id="" for _ in $(seq 1 30); do resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" - task_id="$(printf '%s' "$resp" | task_for_reg)" + task_id="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -n "$task_id" ] && break sleep 2 done @@ -115,7 +115,44 @@ flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/j echo ">> asserting the process finished (no Beoordelen task remains for the registration)" resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" -still="$(printf '%s' "$resp" | task_for_reg)" +still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" + +# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the +# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the +# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ──────────── +echo ">> submitting a second registration to withdraw" +loc2="$(docker run --rm --network "$net" curlimages/curl:latest \ + -fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \ + -H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \ + | sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)" +[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; } +reg_id2="${loc2##*/}" +echo ">> second registration $reg_id2" + +echo ">> polling Flowable for its Beoordelen task" +task_id2="" +for _ in $(seq 1 30); do + resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" + task_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")" + [ -n "$task_id2" ] && break + sleep 2 +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; } +echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain" + +docker run --rm --network "$net" curlimages/curl:latest \ + -fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null + +echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)" +gone="" +for _ in $(seq 1 15); do + resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" + still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")" + [ -z "$still2" ] && { gone=1; break; } + sleep 2 +done +[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } +echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)" exit 0 diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 0859d7c..4a26dd5 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -15,6 +15,14 @@ public interface IWorkflowClient /// aggregate. Returns the process instance id. /// Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); + + /// + /// Cancel a running registratie process on withdrawal (S-11): correlate the + /// RegistratieIngetrokken 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. + /// + Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Application/Werkbak.cs b/services/domain/Big.Application/Werkbak.cs index 4275d50..dfd6cf8 100644 --- a/services/domain/Big.Application/Werkbak.cs +++ b/services/domain/Big.Application/Werkbak.cs @@ -1,3 +1,5 @@ +using Big.Domain; + namespace Big.Application; /// One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the @@ -8,7 +10,8 @@ public sealed record WerkbakItem(string RegistrationId, string Bsn, string Statu /// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the /// open Beoordelen tasks from the workflow engine (§8.2, via ) — /// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task -/// whose registration the domain doesn't know is skipped rather than invented. +/// whose registration the domain doesn't know, or whose registration is no longer open for beoordeling +/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped. /// public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) { @@ -20,7 +23,7 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) foreach (var task in open) { var registration = await store.GetAsync(task.RegistrationId, ct); - if (registration is null) + if (registration is null || !IsOpenForBeoordeling(registration.Status)) continue; items.Add(new WerkbakItem( @@ -29,4 +32,9 @@ public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) return items; } + + // Only registrations still open for a decision belong in the werkbak; a terminal one (decided or + // withdrawn — S-11) whose Beoordelen task has not yet been cleared must not surface to a behandelaar. + private static bool IsOpenForBeoordeling(RegistrationStatus status) + => status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling; } diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs index 282d35a..92f632f 100644 --- a/services/domain/Big.Application/WithdrawRegistration.cs +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -7,13 +7,14 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); /// /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It -/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered -/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again). -/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later -/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only — -/// mirroring how the beoordeling's rejection deferred its zaak propagation. +/// 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 +/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn +/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the +/// registration never started a process the withdrawal still stands (the process cancel is skipped), +/// mirroring how completes its task best-effort. /// -public sealed class WithdrawRegistration(IRegistrationStore store) +public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow) { public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) { @@ -22,11 +23,15 @@ public sealed class WithdrawRegistration(IRegistrationStore store) var registration = await store.GetAsync(command.RegistrationId, ct) ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw."); - // A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again. + // A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again. if (registration.Status == RegistrationStatus.Ingetrokken) return; registration.Withdraw(); await store.SaveAsync(registration, ct); + + // Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak. + if (registration.ProcessInstanceId is not null) + await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct); } } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index f782cea..6a723c8 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -23,6 +23,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; private const string BesluitVariable = "besluit"; + private const string IngetrokkenMessage = "RegistratieIngetrokken"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -85,6 +86,35 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) + { + // Correlate the withdrawal message to the instance: find the execution subscribed to it (the + // interrupting message event's own execution — NOT the user task's), then deliver + // messageEventReceived to that execution so the process ends (ADR-0014). If nothing is + // subscribed (the process is not parked at Beoordelen) this is a best-effort no-op. + var subscribed = await GetAsync( + $"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}", + ct); + + var execution = subscribed?.Data?.FirstOrDefault(); + if (execution is null) + return; + + 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 GetAsync(string path, CancellationToken ct) + { + 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(); + return await response.Content.ReadFromJsonAsync(ct); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -92,9 +122,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti return await response.Content.ReadFromJsonAsync(ct); } - private Task SendAsync(string path, TRequest body, CancellationToken ct) + private Task SendAsync(string path, TRequest body, CancellationToken ct, HttpMethod? method = null) { - var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) + var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path)) { Content = JsonContent.Create(body), }; @@ -132,6 +162,10 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("action")] string Action, [property: JsonPropertyName("variables")] IReadOnlyList Variables); + private sealed record MessageEventRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("messageName")] string MessageName); + private sealed record TaskQueryResult( [property: JsonPropertyName("data")] IReadOnlyList? Data); @@ -154,6 +188,11 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); + private sealed record ExecutionQueryResult( + [property: JsonPropertyName("data")] IReadOnlyList? Data); + + private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id); + private sealed record AcquiredJob( [property: JsonPropertyName("id")] string Id, [property: JsonPropertyName("variables")] IReadOnlyList Variables) diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index bfd0a54..539f554 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -32,6 +32,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac : IWorkflowClient { public RegistrationId? StartedFor { get; private set; } + public string? WithdrawnProcessInstanceId { get; private set; } public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -39,6 +40,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac StartedFor = registrationId; return Task.FromResult(processInstanceId); } + + public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) + { + WithdrawnProcessInstanceId = processInstanceId; + return Task.CompletedTask; + } } /// A fake user-task client for the werkbak/decision use cases: returns a scripted set of diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index f1bf579..aff3bf3 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -242,6 +242,61 @@ public class FlowableWorkflowClientTests Assert.Contains($"\"value\":\"{expected}\"", capture.Body); } + [Fact] + public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution() + { + HttpRequestMessage? getReq = null; + HttpRequestMessage? putReq = null; + 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"); + + // 1. Find the execution subscribed to the withdrawal message for this instance. + Assert.Equal(HttpMethod.Get, getReq!.Method); + Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString()); + Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query); + Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query); + // 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event. + Assert.Equal(HttpMethod.Put, putReq!.Method); + 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] + public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed() + { + var methods = new List(); + var client = Client(new StubHandler(req => + { + methods.Add(req.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"), + }); + })); + + await client.WithdrawProcessAsync("pi-1"); + + // No subscribed execution → no message delivered (best-effort), no throw. + Assert.DoesNotContain(HttpMethod.Put, methods); + } + [Fact] public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() { diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs index bfbf182..0b3bdcf 100644 --- a/services/domain/Big.Tests/WerkbakTests.cs +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -46,4 +46,19 @@ public class WerkbakTests Assert.Empty(await werkbak.GetAsync()); } + + [Fact] + public async Task Skips_a_task_whose_registration_is_no_longer_open() + { + // A withdrawn registration (S-11) may still have a Beoordelen task lingering until the workflow + // cancels it; the werkbak lists only registrations still open for beoordeling, so it drops off. + var store = new FakeRegistrationStore(); + var registration = Registration.Submit("123456782"); + registration.Withdraw(); + store.Seed(registration); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); + var werkbak = new Werkbak(tasks, store); + + Assert.Empty(await werkbak.GetAsync()); + } } diff --git a/services/domain/Big.Tests/WithdrawRegistrationTests.cs b/services/domain/Big.Tests/WithdrawRegistrationTests.cs index 0506db0..99de8b9 100644 --- a/services/domain/Big.Tests/WithdrawRegistrationTests.cs +++ b/services/domain/Big.Tests/WithdrawRegistrationTests.cs @@ -5,13 +5,20 @@ namespace Big.Tests; public class WithdrawRegistrationTests { + private static Registration Submitted(string processInstanceId = "proc-1") + { + var registration = Registration.Submit("123456782"); + registration.RecordProcessStarted(processInstanceId); + return registration; + } + [Fact] public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() { var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Submitted(); store.Seed(registration); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); @@ -20,11 +27,43 @@ public class WithdrawRegistrationTests Assert.Equal(1, store.SaveCount); } + [Fact] + public async Task Withdrawing_cancels_the_running_process() + { + var store = new FakeRegistrationStore(); + var registration = Submitted("proc-42"); + store.Seed(registration); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); + + await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + + // The process the registration recorded at submit is cancelled (ADR-0014). + Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId); + } + + [Fact] + public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken() + { + // A registration with no recorded process (e.g. start never happened) can still be withdrawn; + // there is simply no process to cancel. + var store = new FakeRegistrationStore(); + var registration = Registration.Submit("123456782"); // no RecordProcessStarted + store.Seed(registration); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); + + await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + + Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); + Assert.Null(workflow.WithdrawnProcessInstanceId); + } + [Fact] public async Task Rejects_a_null_command_without_touching_the_store() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); Assert.Equal(0, store.SaveCount); @@ -34,7 +73,7 @@ public class WithdrawRegistrationTests public async Task Withdrawing_an_unknown_registration_throws() { var store = new FakeRegistrationStore(); - var handler = new WithdrawRegistration(store); + var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); @@ -46,9 +85,10 @@ public class WithdrawRegistrationTests public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent() { var store = new FakeRegistrationStore(); - var registration = Registration.Submit("123456782"); + var registration = Submitted(); store.Seed(registration); - var handler = new WithdrawRegistration(store); + var workflow = new FakeWorkflowClient(); + var handler = new WithdrawRegistration(store, workflow); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index b5714ef..3e21752 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -11,12 +11,19 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient public const string StartedProcessInstanceId = "proc-acc-1"; public RegistrationId? StartedFor { get; private set; } + public string? WithdrawnProcessInstanceId { get; private set; } public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { StartedFor = registrationId; return Task.FromResult(StartedProcessInstanceId); } + + public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default) + { + WithdrawnProcessInstanceId = processInstanceId; + return Task.CompletedTask; + } } /// An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index 51f7de9..99a7ec7 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -11,7 +11,13 @@ S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling as a user task: the process parks here until a behandelaar claims and completes it with a `besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process - variable so the werkbak can correlate each task back to its aggregate. --> + variable so the werkbak can correlate each task back to its aggregate. + S-11 adds withdrawal: while parked at Beoordelen the citizen can trek de aanvraag in — an + interrupting message boundary event (RegistratieIngetrokken) cancels the task and ends the + process via a dedicated "ingetrokken" end (ADR-0014). The Workflow Client delivers the message + to the task's execution; the BPMN owns the cancellation path. --> + + @@ -29,6 +35,16 @@ + + + + + + + + + @@ -57,6 +73,16 @@ + + + + + + + + + +