fix(domain): correlate withdrawal by process instance to the subscribed execution (refs #12)
All checks were successful
CI / lint (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 1m1s
CI / unit (pull_request) Successful in 1m20s
CI / frontend (pull_request) Successful in 2m50s
CI / mutation (pull_request) Successful in 5m22s
CI / verify-stack (pull_request) Successful in 7m18s

verify-stack surfaced a Flowable 500: delivering messageEventReceived to the Beoordelen task's

execution is wrong — a message boundary event's subscription lives on its own execution. Correlate

instead by the registration's process instance id (recorded at submit): query the execution

subscribed to RegistratieIngetrokken and deliver the message there. Withdrawal moves from

IUserTaskClient to IWorkflowClient.WithdrawProcessAsync; the handler no longer needs a task lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 12:13:47 +02:00
parent 66af21853f
commit ddfdedc302
10 changed files with 144 additions and 97 deletions

View File

@@ -29,11 +29,12 @@ 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 via the Beoordelen task's execution.** The `WithdrawRegistration` handler already - **Correlated by the registration's own process instance.** The aggregate records its Flowable
knows the registration; it finds the open `Beoordelen` task for it (the same task-query the werkbak process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
uses, §8.2) and asks the Workflow Client to deliver the withdrawal message to that task's execution id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
(`messageEventReceived`). No separate correlation store is needed — the werkbak task set is the `RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
authoritative correlation, exactly as the beoordeling decision reuses it (ADR-0013). 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 - **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

View File

@@ -15,6 +15,14 @@ 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>
@@ -54,18 +62,11 @@ 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), the execution it runs in (needed to deliver the withdrawal message to its boundary /// complete it) and the registration it carries as a process variable.</summary>
/// event), and the registration it carries as a process variable.</summary> public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
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

View File

@@ -8,13 +8,13 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <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 delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case /// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an /// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is /// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
/// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has /// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>. /// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary> /// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks) public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
{ {
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default) public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{ {
@@ -29,16 +29,9 @@ public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClie
registration.Withdraw(); registration.Withdraw();
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
await CancelWorkflowTaskAsync(command.RegistrationId, ct);
}
// Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so // Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
// the process's boundary event ends it. If none is open the withdrawal still stands. if (registration.ProcessInstanceId is not null)
private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct) await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
{
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);
} }
} }

View File

@@ -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, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))]; return [.. tasks.Select(t => new BeoordelingTask(t.Id, 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,18 +86,35 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default) public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{ {
// Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's // Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable // interrupting message event's own execution — NOT the user task's), then deliver
// takes messageEventReceived as a PUT on the subscribed execution. // messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage); // 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);
using var response = await SendAsync( using var response = await SendAsync(
$"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put); $"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
private async Task<TResponse?> GetAsync<TResponse>(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<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)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -154,7 +171,6 @@ 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)
@@ -172,6 +188,11 @@ 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)

View File

@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
} }
private static FakeUserTaskClient TaskFor(Registration registration) => private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", "exec-1", registration.Id)]); new([new BeoordelingTask("task-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()

View File

@@ -32,6 +32,7 @@ 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)
{ {
@@ -39,6 +40,12 @@ 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
@@ -47,7 +54,6 @@ 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);
@@ -63,12 +69,6 @@ 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

View File

@@ -158,14 +158,13 @@ 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","executionId":"exec-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} {"data":[{"id":"task-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",
@@ -244,29 +243,58 @@ public class FlowableWorkflowClientTests
} }
[Fact] [Fact]
public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution() public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{ {
var capture = new RequestCapture(); HttpRequestMessage? getReq = null;
var client = Client(capture.Responds(HttpStatusCode.OK)); 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.WithdrawBeoordelingAsync("exec-1"); await client.WithdrawProcessAsync("pi-1");
// A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014). // 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Put, capture.Seen!.Method); Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1", Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
capture.Seen.RequestUri!.ToString()); Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen)); Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body); // 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body); 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] [Fact]
public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request() public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{ {
var capture = new RequestCapture(); var methods = new List<HttpMethod>();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); 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 Assert.ThrowsAsync<HttpRequestException>(() => client.WithdrawBeoordelingAsync("exec-1")); await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
} }
[Fact] [Fact]

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", "exec-1", registration.Id)]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-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", "exec-1", RegistrationId.New())]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-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", "exec-1", registration.Id)]); var tasks = new FakeUserTaskClient([new BeoordelingTask("task-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());

View File

@@ -5,15 +5,20 @@ namespace Big.Tests;
public class WithdrawRegistrationTests public class WithdrawRegistrationTests
{ {
private static FakeUserTaskClient NoTasks() => new([]); private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit("123456782");
registration.RecordProcessStarted(processInstanceId);
return registration;
}
[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 = Registration.Submit("123456782"); var registration = Submitted();
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store, NoTasks()); var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
@@ -23,43 +28,42 @@ public class WithdrawRegistrationTests
} }
[Fact] [Fact]
public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution() public async Task Withdrawing_cancels_the_running_process()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); var registration = Submitted("proc-42");
store.Seed(registration); store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, tasks); var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The withdrawal message is delivered to the Beoordelen task's execution, tripping the // The process the registration recorded at submit is cancelled (ADR-0014).
// boundary event that ends the process (ADR-0014). Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
} }
[Fact] [Fact]
public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken() public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
{ {
// If the process has not parked at Beoordelen yet (or already ended), the withdrawal stands: // A registration with no recorded process (e.g. start never happened) can still be withdrawn;
// the aggregate is INGETROKKEN and nothing is cancelled. // there is simply no process to cancel.
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782"); // no RecordProcessStarted
store.Seed(registration); store.Seed(registration);
var tasks = NoTasks(); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, tasks); var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(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(tasks.WithdrawnExecutionId); 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, NoTasks()); var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
@@ -69,7 +73,7 @@ public class WithdrawRegistrationTests
public async Task Withdrawing_an_unknown_registration_throws() public async Task Withdrawing_an_unknown_registration_throws()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, NoTasks()); var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
@@ -81,9 +85,10 @@ 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 = Registration.Submit("123456782"); var registration = Submitted();
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store, NoTasks()); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));

View File

@@ -11,12 +11,19 @@ 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,
@@ -50,10 +57,8 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
private readonly List<BeoordelingTask> _open = []; private readonly List<BeoordelingTask> _open = [];
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 void Open(RegistrationId registrationId) => public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId));
_open.Add(new BeoordelingTask($"task-{registrationId}", $"exec-{registrationId}", registrationId));
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default) public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open); => Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open);
@@ -66,13 +71,6 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
_open.RemoveAll(t => t.TaskId == taskId); _open.RemoveAll(t => t.TaskId == taskId);
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
WithdrawnExecutionId = executionId;
_open.RemoveAll(t => t.ExecutionId == executionId);
return Task.CompletedTask;
}
} }
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary> /// <summary>An in-memory registration store for the domain acceptance scenario.</summary>