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

@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
}
private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
new([new BeoordelingTask("task-1", registration.Id)]);
[Fact]
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
{
public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> 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;
}
}
/// <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, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public string? WithdrawnExecutionId { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open);
@@ -63,12 +69,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
Completed = (taskId, besluit);
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

View File

@@ -158,14 +158,13 @@ public class FlowableWorkflowClientTests
var capture = new RequestCapture();
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 task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId);
Assert.Equal("exec-1", task.ExecutionId);
Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
@@ -244,29 +243,58 @@ public class FlowableWorkflowClientTests
}
[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();
var client = Client(capture.Responds(HttpStatusCode.OK));
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.WithdrawBeoordelingAsync("exec-1");
await client.WithdrawProcessAsync("pi-1");
// A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014).
Assert.Equal(HttpMethod.Put, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body);
// 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_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 client = Client(capture.Responds(HttpStatusCode.InternalServerError));
var methods = new List<HttpMethod>();
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]

View File

@@ -18,7 +18,7 @@ public class WerkbakTests
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling();
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 items = await werkbak.GetAsync();
@@ -41,7 +41,7 @@ public class WerkbakTests
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.
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());
Assert.Empty(await werkbak.GetAsync());
@@ -56,7 +56,7 @@ public class WerkbakTests
var registration = Registration.Submit("123456782");
registration.Withdraw();
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);
Assert.Empty(await werkbak.GetAsync());

View File

@@ -5,15 +5,20 @@ namespace Big.Tests;
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]
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, NoTasks());
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
@@ -23,43 +28,42 @@ public class WithdrawRegistrationTests
}
[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 registration = Registration.Submit("123456782");
var registration = Submitted("proc-42");
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
var handler = new WithdrawRegistration(store, tasks);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The withdrawal message is delivered to the Beoordelen task's execution, tripping the
// boundary event that ends the process (ADR-0014).
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
// The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
}
[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:
// the aggregate is INGETROKKEN and nothing is cancelled.
// 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");
var registration = Registration.Submit("123456782"); // no RecordProcessStarted
store.Seed(registration);
var tasks = NoTasks();
var handler = new WithdrawRegistration(store, tasks);
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(tasks.WithdrawnExecutionId);
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, NoTasks());
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount);
@@ -69,7 +73,7 @@ public class WithdrawRegistrationTests
public async Task Withdrawing_an_unknown_registration_throws()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, NoTasks());
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
@@ -81,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, 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));