From 8d176c2603ba23ceef9dec9494a96c32785ff97d Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 16:59:27 +0200 Subject: [PATCH] test(domain): SubmitRegistration + OpenZaakWorker use cases (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing application-layer tests over fake ports (IWorkflowClient, IAclClient, IRegistrationStore): - Submit persists an INGEDIEND registration and starts the registratie process, recording the instance id — and persists *before* starting, so the worker can correlate the OpenZaakAanmaken job back to its aggregate (ADR-0009). - The worker opens a zaak via the ACL and attaches it; an unknown registration throws (job left for redelivery); a redelivered job is idempotent and opens no second zaak (§8.6). Handlers are stubs (no persistence / no ACL call) so the tests compile and fail on their assertions; the green commit implements them. Co-Authored-By: Claude Opus 4.8 (1M context) --- register-referentie.slnx | 1 + .../Big.Application/Big.Application.csproj | 15 +++++ .../domain/Big.Application/OpenZaakWorker.cs | 27 +++++++++ services/domain/Big.Application/Ports.cs | 47 +++++++++++++++ .../Big.Application/SubmitRegistration.cs | 25 ++++++++ services/domain/Big.Tests/Big.Tests.csproj | 1 + services/domain/Big.Tests/Fakes.cs | 60 +++++++++++++++++++ .../domain/Big.Tests/OpenZaakWorkerTests.cs | 60 +++++++++++++++++++ .../Big.Tests/SubmitRegistrationTests.cs | 52 ++++++++++++++++ services/domain/Big.slnx | 1 + 10 files changed, 289 insertions(+) create mode 100644 services/domain/Big.Application/Big.Application.csproj create mode 100644 services/domain/Big.Application/OpenZaakWorker.cs create mode 100644 services/domain/Big.Application/Ports.cs create mode 100644 services/domain/Big.Application/SubmitRegistration.cs create mode 100644 services/domain/Big.Tests/Fakes.cs create mode 100644 services/domain/Big.Tests/OpenZaakWorkerTests.cs create mode 100644 services/domain/Big.Tests/SubmitRegistrationTests.cs diff --git a/register-referentie.slnx b/register-referentie.slnx index 40d5e27..072c997 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -9,6 +9,7 @@ + diff --git a/services/domain/Big.Application/Big.Application.csproj b/services/domain/Big.Application/Big.Application.csproj new file mode 100644 index 0000000..b877d01 --- /dev/null +++ b/services/domain/Big.Application/Big.Application.csproj @@ -0,0 +1,15 @@ + + + + + net10.0 + enable + enable + + + + + + + diff --git a/services/domain/Big.Application/OpenZaakWorker.cs b/services/domain/Big.Application/OpenZaakWorker.cs new file mode 100644 index 0000000..6f01c36 --- /dev/null +++ b/services/domain/Big.Application/OpenZaakWorker.cs @@ -0,0 +1,27 @@ +using Big.Domain; + +namespace Big.Application; + +/// +/// Handles one acquired OpenZaakAanmaken external-worker job (ADR-0009): load the registration +/// the job correlates to, open a zaak for it via the ACL (§8.1), attach the zaak to the aggregate, and +/// return the zaak URL so the caller can complete the Flowable job. Pure application logic over ports — +/// it knows nothing of Flowable; the polling loop that feeds it jobs lives in Infrastructure. +/// +public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl) +{ + /// + /// Process the job and return the URL of the (existing or newly opened) zaak. Idempotent: if the + /// registration already has a zaak — a job redelivered after its completion was lost — it returns + /// that zaak without opening a second one (§8.6, at-least-once delivery). An unknown registration + /// is an error: it throws, leaving the job un-completed for Flowable to redeliver. + /// + public async Task HandleAsync(OpenZaakJob job, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(job); + + // STUB (red): does not load, call the ACL, or attach. + await Task.CompletedTask; + return new Uri("about:blank"); + } +} diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs new file mode 100644 index 0000000..800b036 --- /dev/null +++ b/services/domain/Big.Application/Ports.cs @@ -0,0 +1,47 @@ +using Big.Domain; + +namespace Big.Application; + +/// +/// The port to the workflow engine. Implemented by the Workflow Client in Infrastructure — the +/// only code that talks to Flowable (CLAUDE.md §8.2). The application asks it to start the +/// registratie process; it never knows Flowable exists. +/// +public interface IWorkflowClient +{ + /// + /// Start one registratie process instance for the given registration, carrying the + /// registration id so the OpenZaakAanmaken external task can be correlated back to its + /// aggregate. Returns the process instance id. + /// + Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default); +} + +/// +/// The port to the Anti-Corruption Layer. Implemented in Infrastructure by an HTTP client to the +/// ACL service — the only code that talks to ZGW (CLAUDE.md §8.1). The domain hands over a +/// bsn; the ACL default-fills the ZGW-mandatory fields (ADR-0003) and returns the created zaak URL. +/// +public interface IAclClient +{ + Task OpenZaakAsync(string bsn, CancellationToken ct = default); +} + +/// +/// Persistence port for the aggregate. In-memory for the minimal slice +/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. +/// +public interface IRegistrationStore +{ + /// Insert or update the registration, keyed on its id. + Task SaveAsync(Registration registration, CancellationToken ct = default); + + /// Load a registration by id, or null if none exists. + Task GetAsync(RegistrationId id, CancellationToken ct = default); +} + +/// +/// An acquired OpenZaakAanmaken external-worker job: the Flowable job id (needed to complete +/// it) and the registration id it carries as a process variable. +/// +public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); diff --git a/services/domain/Big.Application/SubmitRegistration.cs b/services/domain/Big.Application/SubmitRegistration.cs new file mode 100644 index 0000000..c4f3555 --- /dev/null +++ b/services/domain/Big.Application/SubmitRegistration.cs @@ -0,0 +1,25 @@ +using Big.Domain; + +namespace Big.Application; + +/// A zorgprofessional's request to register, in domain language. No ZGW concepts. +public sealed record SubmitRegistrationCommand(string Bsn); + +/// +/// The submit use case: create the aggregate (INGEDIEND), persist it, +/// then start the registratie workflow process and record its instance id. It returns as soon as +/// the process is started — opening the zaak happens later, off the request path, in the external-task +/// worker (ADR-0009). Persisting before starting the process closes the race where the worker +/// acquires the OpenZaakAanmaken job before the aggregate it correlates to exists. +/// +public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient workflow) +{ + public async Task HandleAsync(SubmitRegistrationCommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + + // STUB (red): no persistence, no process start. + await Task.CompletedTask; + return RegistrationId.New(); + } +} diff --git a/services/domain/Big.Tests/Big.Tests.csproj b/services/domain/Big.Tests/Big.Tests.csproj index 575ce15..52fba41 100644 --- a/services/domain/Big.Tests/Big.Tests.csproj +++ b/services/domain/Big.Tests/Big.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs new file mode 100644 index 0000000..d32397f --- /dev/null +++ b/services/domain/Big.Tests/Fakes.cs @@ -0,0 +1,60 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +/// An in-memory for the application-layer tests. Upserts +/// keyed on the registration id, mirroring the production in-memory store. +internal sealed class InMemoryRegistrationStore : IRegistrationStore +{ + private readonly Dictionary _byId = []; + + public int SaveCount { get; private set; } + + public Task SaveAsync(Registration registration, CancellationToken ct = default) + { + SaveCount++; + _byId[registration.Id] = registration; + return Task.CompletedTask; + } + + public Task GetAsync(RegistrationId id, CancellationToken ct = default) + => Task.FromResult(_byId.GetValueOrDefault(id)); + + public void Seed(Registration registration) => _byId[registration.Id] = registration; +} + +/// A fake Workflow Client that records the registration it was asked to start a process for +/// and returns a fixed process-instance id. An optional callback runs at start time, letting a test +/// assert ordering (e.g. that the registration was persisted before the process started). +internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Action? onStart = null) + : IWorkflowClient +{ + public RegistrationId? StartedFor { get; private set; } + + public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + { + onStart?.Invoke(registrationId); + StartedFor = registrationId; + return Task.FromResult(processInstanceId); + } +} + +/// A fake ACL client that records the bsn it was asked to open a zaak for and returns a +/// fixed zaak URL. +internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient +{ + public static readonly Uri DefaultZaakUrl = new("http://openzaak/zaken/api/v1/zaken/abc"); + + private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl; + + public string? OpenedForBsn { get; private set; } + public int CallCount { get; private set; } + + public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + { + CallCount++; + OpenedForBsn = bsn; + return Task.FromResult(_zaakUrl); + } +} diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs new file mode 100644 index 0000000..c00ba7a --- /dev/null +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -0,0 +1,60 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +public class OpenZaakWorkerTests +{ + private static Registration Submitted(string bsn = "123456782") + { + var registration = Registration.Submit(bsn); + registration.RecordProcessStarted("proc-1"); + return registration; + } + + [Fact] + public async Task Handling_a_job_opens_a_zaak_via_the_acl_and_attaches_it() + { + var registration = Submitted(); + var store = new InMemoryRegistrationStore(); + store.Seed(registration); + var acl = new FakeAclClient(); + var worker = new OpenZaakWorker(store, acl); + + var zaakUrl = await worker.HandleAsync(new OpenZaakJob("job-1", registration.Id)); + + Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl); + Assert.Equal("123456782", acl.OpenedForBsn); + var saved = await store.GetAsync(registration.Id); + Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); + } + + [Fact] + public async Task Handling_a_job_for_an_unknown_registration_throws_and_opens_no_zaak() + { + var store = new InMemoryRegistrationStore(); + var acl = new FakeAclClient(); + var worker = new OpenZaakWorker(store, acl); + + await Assert.ThrowsAsync( + () => worker.HandleAsync(new OpenZaakJob("job-1", RegistrationId.New()))); + Assert.Equal(0, acl.CallCount); + } + + [Fact] + public async Task Handling_a_redelivered_job_is_idempotent_and_does_not_open_a_second_zaak() + { + var registration = Submitted(); + var store = new InMemoryRegistrationStore(); + store.Seed(registration); + var acl = new FakeAclClient(); + var worker = new OpenZaakWorker(store, acl); + var job = new OpenZaakJob("job-1", registration.Id); + + var first = await worker.HandleAsync(job); + var second = await worker.HandleAsync(job); + + Assert.Equal(first, second); + Assert.Equal(1, acl.CallCount); + } +} diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs new file mode 100644 index 0000000..b1b15a7 --- /dev/null +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -0,0 +1,52 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +public class SubmitRegistrationTests +{ + [Fact] + public async Task Submitting_persists_an_ingediend_registration_and_starts_the_process() + { + var store = new InMemoryRegistrationStore(); + var workflow = new FakeWorkflowClient(processInstanceId: "proc-42"); + var handler = new SubmitRegistration(store, workflow); + + var id = await handler.HandleAsync(new SubmitRegistrationCommand("123456782")); + + var saved = await store.GetAsync(id); + Assert.NotNull(saved); + Assert.Equal("123456782", saved.Bsn); + Assert.Equal(RegistrationStatus.Ingediend, saved.Status); + Assert.Equal("proc-42", saved.ProcessInstanceId); + Assert.Equal(id, workflow.StartedFor); + Assert.Null(saved.ZaakUrl); + } + + [Fact] + public async Task Submitting_persists_the_registration_before_starting_the_process() + { + // The worker correlates the OpenZaakAanmaken job back to the aggregate by id, so the + // aggregate must already be persisted when the process starts (ADR-0009). + var store = new InMemoryRegistrationStore(); + Registration? visibleAtStart = null; + var workflow = new FakeWorkflowClient(onStart: id => visibleAtStart = store.GetAsync(id).Result); + var handler = new SubmitRegistration(store, workflow); + + await handler.HandleAsync(new SubmitRegistrationCommand("123456782")); + + Assert.NotNull(visibleAtStart); + } + + [Fact] + public async Task Submitting_without_a_bsn_is_rejected_and_starts_no_process() + { + var store = new InMemoryRegistrationStore(); + var workflow = new FakeWorkflowClient(); + var handler = new SubmitRegistration(store, workflow); + + await Assert.ThrowsAnyAsync( + () => handler.HandleAsync(new SubmitRegistrationCommand(""))); + Assert.Null(workflow.StartedFor); + } +} diff --git a/services/domain/Big.slnx b/services/domain/Big.slnx index 6125bff..78b7664 100644 --- a/services/domain/Big.slnx +++ b/services/domain/Big.slnx @@ -1,4 +1,5 @@ +