From c9edf27a48fb032a5ad1a899e87e2373d65bc795 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 16:51:40 +0200 Subject: [PATCH 01/16] arch(domain): ADR-0009 external-task job-worker pattern (refs #6, #60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Domain Service drives the OpenZaakAanmaken external-worker task as a hosted job worker (PRD §36): POST /registrations starts the registratie process and returns; a polling worker acquires the job, opens a zaak via the ACL (§8.1), attaches the zaak URL to the aggregate, and completes the job. The Workflow Client is the only Flowable client (§8.2); the worker logic is an Application service over ports. Registration state is in-memory for the minimal slice (the read path is the projection, S-06). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr-0009-external-task-job-worker.md | 88 +++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 89 insertions(+) create mode 100644 docs/architecture/adr-0009-external-task-job-worker.md diff --git a/docs/architecture/adr-0009-external-task-job-worker.md b/docs/architecture/adr-0009-external-task-job-worker.md new file mode 100644 index 0000000..012c625 --- /dev/null +++ b/docs/architecture/adr-0009-external-task-job-worker.md @@ -0,0 +1,88 @@ +# ADR-0009: The Domain Service drives Flowable as an external-task job worker + +- **Status:** Accepted +- **Date:** 2026-06-30 +- **Deciders:** Respellion engineering +- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation) + +## Context + +S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a +`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the +`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL +back on the aggregate. + +`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`, +topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a +worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain +who may do what: + +- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed + OpenZaak knowledge; they ask the Workflow Client to execute external tasks. +- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*, + never by constructing ZGW URLs itself. + +This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are +exercised. The open question is *how* the external task is driven. + +## Decision + +**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker +(PRD §36). Orchestration is eventually consistent, not request-synchronous.** + +- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the + `Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start + one `registratie` process instance, recording the process-instance id on the aggregate. It returns + immediately; it does **not** wait for the zaak to be opened. +- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls + the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate + (`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end + event. +- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's + `Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete + external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable + exists. +- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker` + takes an acquired job (topic + the registration id it carries), calls `IAclClient` and + `IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin + `BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to + the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing + needs a container integration test. + +## Scope decisions for the minimal slice + +- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by + NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain + store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore` + port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.) +- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal, + beoordeling, herregistratie) add states in their own slices — they are out of scope here. +- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory + fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged. + +## Consequences + +- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common + Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the + Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable. +- **Negative / deferred:** + - Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet. + Acceptable — the read side is the projection, not the domain store. + - In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store + in a follow-up. + - The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since + Flowable holds the job until completed. + +## Alternatives considered + +- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and + deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the + external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is + briefly down, instead of the job simply staying parked for the worker to retry. +- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this + slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places + the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a + shared store for no current benefit. +- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker + model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker + delivery guarantees. diff --git a/mkdocs.yml b/mkdocs.yml index 32e7b71..73a9a55 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,6 +30,7 @@ nav: - "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md - "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md - "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md + - "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md - Working in Gitea: gitea-workflow.md - Demo script: demo-script.md - Runbooks: -- 2.49.1 From cc9e7852e1b61ddfe4ac7532d5ef0820f9845b14 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 16:54:59 +0200 Subject: [PATCH 02/16] test(domain): Registration aggregate invariants (refs #6) Failing unit tests for the Registration aggregate root: a submission starts in INGEDIEND carrying its bsn, an empty/whitespace/null bsn is rejected, the started process-instance id is remembered, and attaching the zaak the ACL opened records its URL idempotently (a conflicting URL is rejected) while the status stays INGEDIEND. The aggregate is a stub (no-op mutators, empty bsn) so the tests compile and fail on their assertions; the green commit implements the invariants. Co-Authored-By: Claude Opus 4.8 (1M context) --- register-referentie.slnx | 4 + services/domain/Big.Domain/Big.Domain.csproj | 11 +++ services/domain/Big.Domain/Registration.cs | 47 +++++++++++ services/domain/Big.Domain/RegistrationId.cs | 15 ++++ .../domain/Big.Domain/RegistrationStatus.cs | 10 +++ services/domain/Big.Tests/Big.Tests.csproj | 25 ++++++ .../domain/Big.Tests/RegistrationTests.cs | 77 +++++++++++++++++++ services/domain/Big.slnx | 4 + 8 files changed, 193 insertions(+) create mode 100644 services/domain/Big.Domain/Big.Domain.csproj create mode 100644 services/domain/Big.Domain/Registration.cs create mode 100644 services/domain/Big.Domain/RegistrationId.cs create mode 100644 services/domain/Big.Domain/RegistrationStatus.cs create mode 100644 services/domain/Big.Tests/Big.Tests.csproj create mode 100644 services/domain/Big.Tests/RegistrationTests.cs create mode 100644 services/domain/Big.slnx diff --git a/register-referentie.slnx b/register-referentie.slnx index 3a5661a..40d5e27 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -7,6 +7,10 @@ + + + + diff --git a/services/domain/Big.Domain/Big.Domain.csproj b/services/domain/Big.Domain/Big.Domain.csproj new file mode 100644 index 0000000..7fe1710 --- /dev/null +++ b/services/domain/Big.Domain/Big.Domain.csproj @@ -0,0 +1,11 @@ + + + + + net10.0 + enable + enable + + + diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs new file mode 100644 index 0000000..591d15e --- /dev/null +++ b/services/domain/Big.Domain/Registration.cs @@ -0,0 +1,47 @@ +namespace Big.Domain; + +/// +/// The Registration aggregate root (CLAUDE.md §2.2): a zorgprofessional's submission to the BIG +/// register. It owns its lifecycle invariants — it starts +/// on submission, remembers the Flowable process that drives it, and records the zaak the ACL opens. +/// +public sealed class Registration +{ + // STUB (red): mutators are no-ops and Submit leaves the bsn empty so the behaviour tests + // compile and fail on their assertions. The green commit implements the invariants. + private Registration(RegistrationId id, string bsn) + { + Id = id; + Bsn = bsn; + Status = RegistrationStatus.Ingediend; + } + + public RegistrationId Id { get; } + + /// The citizen-service number of the submitting zorgprofessional. Handed to the ACL + /// as the domain payload; the domain never constructs ZGW concepts from it (§8.1). + public string Bsn { get; } + + public RegistrationStatus Status { get; private set; } + + /// The Flowable process instance driving this registration, once started. + public string? ProcessInstanceId { get; private set; } + + /// The zaak the ACL opened for this registration, once the external task has run. + public Uri? ZaakUrl { get; private set; } + + /// Submit a new registration. It begins in . + public static Registration Submit(string bsn) => new(RegistrationId.New(), string.Empty); + + /// Record that the registratie workflow process has been started for this registration. + public void RecordProcessStarted(string processInstanceId) + { + // STUB (red). + } + + /// Attach the zaak the ACL opened. Idempotent on the same URL; a conflicting URL is rejected. + public void AttachZaak(Uri zaakUrl) + { + // STUB (red). + } +} diff --git a/services/domain/Big.Domain/RegistrationId.cs b/services/domain/Big.Domain/RegistrationId.cs new file mode 100644 index 0000000..e9c8505 --- /dev/null +++ b/services/domain/Big.Domain/RegistrationId.cs @@ -0,0 +1,15 @@ +namespace Big.Domain; + +/// The identity of a aggregate — opaque, server-assigned, +/// and distinct from the OpenZaak zaak id (which the projection keys on). A value object so the +/// id can travel as a Flowable process variable and back without ever being a bare string. +public readonly record struct RegistrationId(Guid Value) +{ + /// Mint a fresh identity for a newly submitted registration. + public static RegistrationId New() => new(Guid.NewGuid()); + + /// Rehydrate an id carried as a string (e.g. a Flowable process variable). + public static RegistrationId Parse(string value) => new(Guid.Parse(value)); + + public override string ToString() => Value.ToString(); +} diff --git a/services/domain/Big.Domain/RegistrationStatus.cs b/services/domain/Big.Domain/RegistrationStatus.cs new file mode 100644 index 0000000..3ddad57 --- /dev/null +++ b/services/domain/Big.Domain/RegistrationStatus.cs @@ -0,0 +1,10 @@ +namespace Big.Domain; + +/// The lifecycle states a moves through. The walking +/// skeleton knows only ; withdrawal, beoordeling and herregistratie +/// states arrive in their own slices (Iteration 2+). +public enum RegistrationStatus +{ + /// Submitted by the zorgprofessional; the registratie process has been started. + Ingediend, +} diff --git a/services/domain/Big.Tests/Big.Tests.csproj b/services/domain/Big.Tests/Big.Tests.csproj new file mode 100644 index 0000000..575ce15 --- /dev/null +++ b/services/domain/Big.Tests/Big.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs new file mode 100644 index 0000000..43b57f6 --- /dev/null +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -0,0 +1,77 @@ +using Big.Domain; + +namespace Big.Tests; + +public class RegistrationTests +{ + [Fact] + public void Submitting_a_registration_starts_in_ingediend() + { + var registration = Registration.Submit("123456782"); + + Assert.Equal(RegistrationStatus.Ingediend, registration.Status); + Assert.Equal("123456782", registration.Bsn); + Assert.NotEqual(Guid.Empty, registration.Id.Value); + Assert.Null(registration.ZaakUrl); + Assert.Null(registration.ProcessInstanceId); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Submitting_without_a_bsn_is_rejected(string? bsn) + => Assert.Throws(() => Registration.Submit(bsn!)); + + [Fact] + public void Recording_the_started_process_keeps_its_instance_id() + { + var registration = Registration.Submit("123456782"); + + registration.RecordProcessStarted("proc-42"); + + Assert.Equal("proc-42", registration.ProcessInstanceId); + } + + [Fact] + public void Attaching_a_zaak_records_its_url_and_keeps_the_status_ingediend() + { + var registration = Registration.Submit("123456782"); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + registration.AttachZaak(zaak); + + Assert.Equal(zaak, registration.ZaakUrl); + Assert.Equal(RegistrationStatus.Ingediend, registration.Status); + } + + [Fact] + public void Attaching_a_null_zaak_is_rejected() + { + var registration = Registration.Submit("123456782"); + + Assert.Throws(() => registration.AttachZaak(null!)); + } + + [Fact] + public void Re_attaching_the_same_zaak_is_idempotent() + { + var registration = Registration.Submit("123456782"); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + registration.AttachZaak(zaak); + registration.AttachZaak(zaak); + + Assert.Equal(zaak, registration.ZaakUrl); + } + + [Fact] + public void Attaching_a_conflicting_zaak_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.Throws( + () => registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/other"))); + } +} diff --git a/services/domain/Big.slnx b/services/domain/Big.slnx new file mode 100644 index 0000000..6125bff --- /dev/null +++ b/services/domain/Big.slnx @@ -0,0 +1,4 @@ + + + + -- 2.49.1 From 53751fd1bcd7b204da6a948adab14c6d3e0a99d7 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 16:56:31 +0200 Subject: [PATCH 03/16] feat(domain): implement the Registration aggregate invariants (refs #6) Submit requires a bsn and starts the aggregate in INGEDIEND; the started process-instance id is recorded; AttachZaak stores the ACL's zaak URL, tolerating a duplicate (at-least-once worker delivery) and rejecting a conflicting URL, without advancing the status. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Domain/Registration.cs | 30 +++++++++++++++---- .../domain/Big.Tests/RegistrationTests.cs | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index 591d15e..c5d6f8c 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -7,8 +7,6 @@ namespace Big.Domain; /// public sealed class Registration { - // STUB (red): mutators are no-ops and Submit leaves the bsn empty so the behaviour tests - // compile and fail on their assertions. The green commit implements the invariants. private Registration(RegistrationId id, string bsn) { Id = id; @@ -31,17 +29,37 @@ public sealed class Registration public Uri? ZaakUrl { get; private set; } /// Submit a new registration. It begins in . - public static Registration Submit(string bsn) => new(RegistrationId.New(), string.Empty); + public static Registration Submit(string bsn) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bsn); + return new Registration(RegistrationId.New(), bsn); + } /// Record that the registratie workflow process has been started for this registration. public void RecordProcessStarted(string processInstanceId) { - // STUB (red). + ArgumentException.ThrowIfNullOrWhiteSpace(processInstanceId); + ProcessInstanceId = processInstanceId; } - /// Attach the zaak the ACL opened. Idempotent on the same URL; a conflicting URL is rejected. + /// + /// Attach the zaak the ACL opened. The external-task worker may deliver the same job more than + /// once (at-least-once), so re-attaching the identical URL is a no-op; a different URL signals a + /// genuine conflict and is rejected. The status stays : + /// opening the zaak does not advance the registration's lifecycle in this slice. + /// public void AttachZaak(Uri zaakUrl) { - // STUB (red). + ArgumentNullException.ThrowIfNull(zaakUrl); + + if (ZaakUrl is not null) + { + if (ZaakUrl != zaakUrl) + throw new InvalidOperationException( + $"Registration {Id} already has zaak {ZaakUrl}; cannot attach a different zaak {zaakUrl}."); + return; + } + + ZaakUrl = zaakUrl; } } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 43b57f6..85b2e8a 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -21,7 +21,7 @@ public class RegistrationTests [InlineData(" ")] [InlineData(null)] public void Submitting_without_a_bsn_is_rejected(string? bsn) - => Assert.Throws(() => Registration.Submit(bsn!)); + => Assert.ThrowsAny(() => Registration.Submit(bsn!)); [Fact] public void Recording_the_started_process_keeps_its_instance_id() -- 2.49.1 From 8d176c2603ba23ceef9dec9494a96c32785ff97d Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 16:59:27 +0200 Subject: [PATCH 04/16] 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 @@ + -- 2.49.1 From 39b2388a9d31eb5fc8ec5e1a4bd176083b2bd384 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:00:05 +0200 Subject: [PATCH 05/16] feat(domain): implement SubmitRegistration and OpenZaakWorker (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SubmitRegistration creates the aggregate, persists it, starts the registratie process via the Workflow Client, records the instance id and upserts. OpenZaakWorker loads the correlated registration, opens a zaak via the ACL, attaches it and saves; an unknown registration throws (job redelivered), and an already-opened zaak short- circuits without opening a second one (§8.6). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Application/OpenZaakWorker.cs | 15 ++++++++++++--- .../domain/Big.Application/SubmitRegistration.cs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/services/domain/Big.Application/OpenZaakWorker.cs b/services/domain/Big.Application/OpenZaakWorker.cs index 6f01c36..be0688f 100644 --- a/services/domain/Big.Application/OpenZaakWorker.cs +++ b/services/domain/Big.Application/OpenZaakWorker.cs @@ -20,8 +20,17 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl) { ArgumentNullException.ThrowIfNull(job); - // STUB (red): does not load, call the ACL, or attach. - await Task.CompletedTask; - return new Uri("about:blank"); + var registration = await store.GetAsync(job.RegistrationId, ct) + ?? throw new InvalidOperationException( + $"No registration {job.RegistrationId} for OpenZaakAanmaken job {job.JobId}."); + + // A redelivered job whose zaak was already opened completes without opening a second one. + if (registration.ZaakUrl is not null) + return registration.ZaakUrl; + + var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct); + registration.AttachZaak(zaakUrl); + await store.SaveAsync(registration, ct); + return zaakUrl; } } diff --git a/services/domain/Big.Application/SubmitRegistration.cs b/services/domain/Big.Application/SubmitRegistration.cs index c4f3555..3e2f55f 100644 --- a/services/domain/Big.Application/SubmitRegistration.cs +++ b/services/domain/Big.Application/SubmitRegistration.cs @@ -18,8 +18,16 @@ public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient { ArgumentNullException.ThrowIfNull(command); - // STUB (red): no persistence, no process start. - await Task.CompletedTask; - return RegistrationId.New(); + var registration = Registration.Submit(command.Bsn); + + // Persist before starting the process so the worker can correlate the OpenZaakAanmaken + // job back to an aggregate that already exists (ADR-0009). + await store.SaveAsync(registration, ct); + + var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct); + registration.RecordProcessStarted(processInstanceId); + await store.SaveAsync(registration, ct); + + return registration.Id; } } -- 2.49.1 From 6d4adaf9579e481750068459745adeaf6de5fc07 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:08:56 +0200 Subject: [PATCH 06/16] test(domain): Workflow Client, ACL client, store and job processor (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing infrastructure unit tests (stub HttpMessageHandler, fakes): - FlowableWorkflowClient starts a process with the registrationId variable and returns the instance id; acquires OpenZaakAanmaken jobs (topic/workerId/lock) and parses their registrationId; completes a job with the zaakUrl variable — request URIs match flowable-rest's service/ and external-job-api/ paths. - AclHttpClient POSTs the bsn to the ACL and returns the zaak URL. - InMemoryRegistrationStore saves/reads/upserts by id. - OpenZaakJobProcessor acquires, opens a zaak, completes the job; leaves a failing job uncompleted for redelivery; polls harmlessly when idle. Adapters are stubs so the tests compile and fail on their assertions; the green commit implements them against the REST contract verified on a live Flowable. Co-Authored-By: Claude Opus 4.8 (1M context) --- register-referentie.slnx | 1 + .../Big.Infrastructure/AclHttpClient.cs | 17 ++++ .../Big.Infrastructure.csproj | 24 +++++ .../FlowableWorkflowClient.cs | 31 +++++++ .../IExternalWorkerClient.cs | 18 ++++ .../InMemoryRegistrationStore.cs | 27 ++++++ .../OpenZaakJobProcessor.cs | 26 ++++++ services/domain/Big.Infrastructure/Options.cs | 29 ++++++ .../domain/Big.Tests/AclHttpClientTests.cs | 43 +++++++++ services/domain/Big.Tests/Big.Tests.csproj | 1 + services/domain/Big.Tests/Fakes.cs | 5 +- .../Big.Tests/FlowableWorkflowClientTests.cs | 93 +++++++++++++++++++ services/domain/Big.Tests/HttpStubs.cs | 30 ++++++ .../InMemoryRegistrationStoreTests.cs | 36 +++++++ .../Big.Tests/OpenZaakJobProcessorTests.cs | 77 +++++++++++++++ .../domain/Big.Tests/OpenZaakWorkerTests.cs | 6 +- .../Big.Tests/SubmitRegistrationTests.cs | 6 +- services/domain/Big.slnx | 1 + 18 files changed, 463 insertions(+), 8 deletions(-) create mode 100644 services/domain/Big.Infrastructure/AclHttpClient.cs create mode 100644 services/domain/Big.Infrastructure/Big.Infrastructure.csproj create mode 100644 services/domain/Big.Infrastructure/FlowableWorkflowClient.cs create mode 100644 services/domain/Big.Infrastructure/IExternalWorkerClient.cs create mode 100644 services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs create mode 100644 services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs create mode 100644 services/domain/Big.Infrastructure/Options.cs create mode 100644 services/domain/Big.Tests/AclHttpClientTests.cs create mode 100644 services/domain/Big.Tests/FlowableWorkflowClientTests.cs create mode 100644 services/domain/Big.Tests/HttpStubs.cs create mode 100644 services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs create mode 100644 services/domain/Big.Tests/OpenZaakJobProcessorTests.cs diff --git a/register-referentie.slnx b/register-referentie.slnx index 072c997..960a92e 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -10,6 +10,7 @@ + diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs new file mode 100644 index 0000000..224cf41 --- /dev/null +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -0,0 +1,17 @@ +using Big.Application; + +namespace Big.Infrastructure; + +/// +/// HTTP client to the ACL service — the boundary the domain crosses to open a zaak (§8.1). It POSTs +/// the bsn to the ACL's /zaken endpoint and returns the created zaak URL; it never constructs +/// ZGW URLs or talks to OpenZaak itself. +/// +public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient +{ + public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(new Uri("about:blank")); + } +} diff --git a/services/domain/Big.Infrastructure/Big.Infrastructure.csproj b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj new file mode 100644 index 0000000..6a23067 --- /dev/null +++ b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs new file mode 100644 index 0000000..016a349 --- /dev/null +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -0,0 +1,31 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Infrastructure; + +/// +/// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process +/// instances and drives the OpenZaakAanmaken external-worker jobs over Flowable's REST API +/// (start: service/runtime/process-instances; acquire/complete: external-job-api/…). +/// +public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) + : IWorkflowClient, IExternalWorkerClient +{ + public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(""); + } + + public Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult>([]); + } + + public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) + { + // STUB (red). + return Task.CompletedTask; + } +} diff --git a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs new file mode 100644 index 0000000..c8fa2f6 --- /dev/null +++ b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs @@ -0,0 +1,18 @@ +using Big.Application; + +namespace Big.Infrastructure; + +/// +/// The worker-facing side of the Workflow Client: acquiring and completing Flowable external-worker +/// jobs (ADR-0009). Kept separate from the Application's because job +/// acquisition is a Flowable-specific polling mechanic the application never needs to know about. +/// Implemented by — the only code that talks to Flowable (§8.2). +/// +public interface IExternalWorkerClient +{ + /// Acquire and lock up to OpenZaakAanmaken jobs. + Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default); + + /// Complete an acquired job, passing the opened zaak URL back into the process. + Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default); +} diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs new file mode 100644 index 0000000..9e83fa9 --- /dev/null +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; +using Big.Application; +using Big.Domain; + +namespace Big.Infrastructure; + +/// +/// In-memory for the minimal slice (ADR-0009). The walking +/// skeleton's read path is the projection (S-06), not this store, so durable domain persistence is a +/// documented follow-up. Registered as a singleton so the submit endpoint and the worker share it. +/// +public sealed class InMemoryRegistrationStore : IRegistrationStore +{ + private readonly ConcurrentDictionary _byId = new(); + + public Task SaveAsync(Registration registration, CancellationToken ct = default) + { + // STUB (red). + return Task.CompletedTask; + } + + public Task GetAsync(RegistrationId id, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(null); + } +} diff --git a/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs new file mode 100644 index 0000000..32acf16 --- /dev/null +++ b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs @@ -0,0 +1,26 @@ +using Big.Application; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// One poll tick of the external-task worker (ADR-0009): acquire the parked OpenZaakAanmaken +/// jobs, hand each to the to open a zaak via the ACL, and complete the +/// Flowable job with the resulting zaak URL. A job that fails is logged and left un-completed so +/// Flowable redelivers it (§8.6). Split out from the hosted so the +/// acquire→process→complete logic is unit-testable without a running host. +/// +public sealed class OpenZaakJobProcessor( + IExternalWorkerClient client, + OpenZaakWorker worker, + ILogger logger) +{ + /// Process up to jobs. Returns the number completed. + public async Task PumpOnceAsync(int maxJobs, CancellationToken ct = default) + { + // STUB (red). + await Task.CompletedTask; + _ = logger; + return 0; + } +} diff --git a/services/domain/Big.Infrastructure/Options.cs b/services/domain/Big.Infrastructure/Options.cs new file mode 100644 index 0000000..ccbe7b1 --- /dev/null +++ b/services/domain/Big.Infrastructure/Options.cs @@ -0,0 +1,29 @@ +namespace Big.Infrastructure; + +/// Configuration for the Flowable Workflow Client. is the flowable-rest +/// root and must end with a slash (e.g. http://flowable-rest:8080/flowable-rest/) so the +/// service/… and external-job-api/… sub-paths resolve correctly. +public sealed class FlowableOptions +{ + public Uri BaseUrl { get; set; } = null!; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + + /// The id this worker locks jobs under, so two workers don't process the same job. + public string WorkerId { get; set; } = "big-domain-worker"; + + /// How long an acquired job stays locked to this worker (ISO-8601 duration). + public string LockDuration { get; set; } = "PT5M"; + + /// How many OpenZaakAanmaken jobs to acquire per poll. + public int MaxJobsPerPoll { get; set; } = 5; + + /// How often the worker polls Flowable for new jobs. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2); +} + +/// Configuration for the ACL HTTP client. is the ACL service root. +public sealed class AclOptions +{ + public Uri BaseUrl { get; set; } = null!; +} diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs new file mode 100644 index 0000000..a08f174 --- /dev/null +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using Big.Infrastructure; + +namespace Big.Tests; + +public class AclHttpClientTests +{ + private static AclHttpClient Client(StubHandler handler) => new( + new HttpClient(handler), new AclOptions { BaseUrl = new("http://acl/") }); + + [Fact] + public async Task Opens_a_zaak_by_posting_the_bsn_and_returns_the_zaak_url() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + """{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}""")); + + var url = await client.OpenZaakAsync("123456782"); + + Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString()); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"bsn\":\"123456782\"", capture.Body); + } + + [Fact] + public async Task Throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + } + + [Fact] + public async Task Throws_when_the_acl_returns_an_empty_body() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "null")); + + await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + } +} diff --git a/services/domain/Big.Tests/Big.Tests.csproj b/services/domain/Big.Tests/Big.Tests.csproj index 52fba41..930e2f9 100644 --- a/services/domain/Big.Tests/Big.Tests.csproj +++ b/services/domain/Big.Tests/Big.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index d32397f..38d1bae 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -4,8 +4,9 @@ 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 +/// keyed on the registration id, mirroring the production store (kept distinct from the production +/// InMemoryRegistrationStore so tests can seed and inspect save counts). +internal sealed class FakeRegistrationStore : IRegistrationStore { private readonly Dictionary _byId = []; diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs new file mode 100644 index 0000000..e1b3b23 --- /dev/null +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using Big.Domain; +using Big.Infrastructure; + +namespace Big.Tests; + +public class FlowableWorkflowClientTests +{ + private static readonly Uri Base = new("http://flowable/flowable-rest/"); + + private static FlowableWorkflowClient Client(StubHandler handler) => new( + new HttpClient(handler), + new FlowableOptions { BaseUrl = Base, Username = "rest-admin", Password = "test", WorkerId = "worker-x" }); + + [Fact] + public async Task Start_posts_the_registration_id_variable_and_returns_the_instance_id() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}""")); + var rid = RegistrationId.New(); + + var pid = await client.StartRegistrationProcessAsync(rid); + + Assert.Equal("pi-1", pid); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/service/runtime/process-instances", + capture.Seen.RequestUri!.ToString()); + Assert.Equal("Basic", capture.Seen.Headers.Authorization!.Scheme); + Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body); + Assert.Contains("\"name\":\"registrationId\"", capture.Body); + Assert.Contains($"\"value\":\"{rid}\"", capture.Body); + } + + [Fact] + public async Task Acquire_posts_the_topic_and_parses_jobs_with_their_registration_id() + { + var rid = RegistrationId.New(); + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + $$"""[{"id":"job-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]""")); + + var jobs = await client.AcquireOpenZaakJobsAsync(3); + + var job = Assert.Single(jobs); + Assert.Equal("job-1", job.JobId); + Assert.Equal(rid, job.RegistrationId); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs", + capture.Seen!.RequestUri!.ToString()); + Assert.Contains("\"topic\":\"OpenZaakAanmaken\"", capture.Body); + Assert.Contains("\"numberOfTasks\":3", capture.Body); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); + } + + [Fact] + public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "[]")); + + var jobs = await client.AcquireOpenZaakJobsAsync(1); + + Assert.NotNull(capture.Seen); + Assert.Empty(jobs); + } + + [Fact] + public async Task Complete_posts_the_zaak_url_variable_to_the_job_complete_endpoint() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.NotNull(capture.Seen); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-1/complete", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + Assert.Contains("\"name\":\"zaakUrl\"", capture.Body); + Assert.Contains("\"value\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Start_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + + await Assert.ThrowsAsync( + () => client.StartRegistrationProcessAsync(RegistrationId.New())); + } +} diff --git a/services/domain/Big.Tests/HttpStubs.cs b/services/domain/Big.Tests/HttpStubs.cs new file mode 100644 index 0000000..93b2d22 --- /dev/null +++ b/services/domain/Big.Tests/HttpStubs.cs @@ -0,0 +1,30 @@ +using System.Net; + +namespace Big.Tests; + +/// A test double for that records the last request and +/// returns a scripted response — the same approach the ACL gateway tests use. +internal sealed class StubHandler(Func> onSend) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); +} + +/// Captures the request a client sent, including the (buffered) body. +internal sealed class RequestCapture +{ + public HttpRequestMessage? Seen { get; private set; } + public string? Body { get; private set; } + + /// A handler that records the request, then replies with and + /// . + public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req => + { + Seen = req; + Body = req.Content is null ? null : await req.Content.ReadAsStringAsync(); + var response = new HttpResponseMessage(status); + if (json is not null) + response.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + return response; + }); +} diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs new file mode 100644 index 0000000..8faaae6 --- /dev/null +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -0,0 +1,36 @@ +using Big.Domain; +using Big.Infrastructure; + +namespace Big.Tests; + +public class InMemoryRegistrationStoreTests +{ + [Fact] + public async Task Saves_and_reads_back_a_registration_by_id() + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + + await store.SaveAsync(registration); + + var loaded = await store.GetAsync(registration.Id); + Assert.NotNull(loaded); + Assert.Equal(registration.Id, loaded.Id); + Assert.Null(await store.GetAsync(RegistrationId.New())); + } + + [Fact] + public async Task Saving_the_same_id_upserts() + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + await store.SaveAsync(registration); + + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + await store.SaveAsync(registration); + + var loaded = await store.GetAsync(registration.Id); + Assert.NotNull(loaded); + Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", loaded.ZaakUrl!.ToString()); + } +} diff --git a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs new file mode 100644 index 0000000..35a0c22 --- /dev/null +++ b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs @@ -0,0 +1,77 @@ +using Big.Application; +using Big.Domain; +using Big.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Big.Tests; + +public class OpenZaakJobProcessorTests +{ + /// A fake external-worker client: scripts the jobs to acquire and records completions. + private sealed class FakeExternalWorkerClient(params OpenZaakJob[] jobs) : IExternalWorkerClient + { + public int AcquireCount { get; private set; } + public List<(string JobId, Uri ZaakUrl)> Completed { get; } = []; + + public Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) + { + AcquireCount++; + return Task.FromResult>(jobs.Take(maxJobs).ToList()); + } + + public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) + { + Completed.Add((jobId, zaakUrl)); + return Task.CompletedTask; + } + } + + private static OpenZaakJobProcessor Processor(IExternalWorkerClient client, IRegistrationStore store, FakeAclClient acl) + => new(client, new OpenZaakWorker(store, acl), NullLogger.Instance); + + [Fact] + public async Task Acquires_a_job_opens_the_zaak_and_completes_it() + { + var registration = Registration.Submit("123456782"); + var store = new FakeRegistrationStore(); + store.Seed(registration); + var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", registration.Id)); + var acl = new FakeAclClient(); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + var completed = Assert.Single(client.Completed); + Assert.Equal("job-1", completed.JobId); + Assert.Equal(FakeAclClient.DefaultZaakUrl, completed.ZaakUrl); + Assert.Equal(FakeAclClient.DefaultZaakUrl, (await store.GetAsync(registration.Id))!.ZaakUrl); + } + + [Fact] + public async Task A_failing_job_is_left_uncompleted_for_flowable_to_redeliver() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + // The job correlates to a registration that is not in the store, so the worker throws. + var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", RegistrationId.New())); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + Assert.Empty(client.Completed); + } + + [Fact] + public async Task Does_nothing_but_poll_when_there_are_no_jobs() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var client = new FakeExternalWorkerClient(); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(0, acquired); + Assert.Equal(1, client.AcquireCount); + Assert.Empty(client.Completed); + } +} diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs index c00ba7a..2213d59 100644 --- a/services/domain/Big.Tests/OpenZaakWorkerTests.cs +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -16,7 +16,7 @@ public class OpenZaakWorkerTests public async Task Handling_a_job_opens_a_zaak_via_the_acl_and_attaches_it() { var registration = Submitted(); - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); store.Seed(registration); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); @@ -32,7 +32,7 @@ public class OpenZaakWorkerTests [Fact] public async Task Handling_a_job_for_an_unknown_registration_throws_and_opens_no_zaak() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); @@ -45,7 +45,7 @@ public class OpenZaakWorkerTests public async Task Handling_a_redelivered_job_is_idempotent_and_does_not_open_a_second_zaak() { var registration = Submitted(); - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); store.Seed(registration); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs index b1b15a7..fd3c097 100644 --- a/services/domain/Big.Tests/SubmitRegistrationTests.cs +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -8,7 +8,7 @@ public class SubmitRegistrationTests [Fact] public async Task Submitting_persists_an_ingediend_registration_and_starts_the_process() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var workflow = new FakeWorkflowClient(processInstanceId: "proc-42"); var handler = new SubmitRegistration(store, workflow); @@ -28,7 +28,7 @@ public class SubmitRegistrationTests { // 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(); + var store = new FakeRegistrationStore(); Registration? visibleAtStart = null; var workflow = new FakeWorkflowClient(onStart: id => visibleAtStart = store.GetAsync(id).Result); var handler = new SubmitRegistration(store, workflow); @@ -41,7 +41,7 @@ public class SubmitRegistrationTests [Fact] public async Task Submitting_without_a_bsn_is_rejected_and_starts_no_process() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var workflow = new FakeWorkflowClient(); var handler = new SubmitRegistration(store, workflow); diff --git a/services/domain/Big.slnx b/services/domain/Big.slnx index 78b7664..f60c02a 100644 --- a/services/domain/Big.slnx +++ b/services/domain/Big.slnx @@ -1,5 +1,6 @@ + -- 2.49.1 From 0d34d60797226942f062400a4143cc3c655ef104 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:10:21 +0200 Subject: [PATCH 07/16] feat(domain): implement the Flowable Workflow Client and ACL client (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowableWorkflowClient speaks flowable-rest's REST API (Basic auth): start a registratie process with the registrationId variable, acquire OpenZaakAanmaken external-worker jobs and parse their registrationId, complete a job with the zaakUrl variable — the contract verified against a live engine. AclHttpClient POSTs the bsn to the ACL and returns the zaak URL. InMemoryRegistrationStore is a concurrent-dictionary upsert. OpenZaakJobProcessor drains parked jobs, opening a zaak per job and completing it, leaving failures for redelivery; OpenZaakJobPump is the hosted polling shell that drives it on an interval (ADR-0009). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Big.Infrastructure/AclHttpClient.cs | 17 +++- .../FlowableWorkflowClient.cs | 95 +++++++++++++++++-- .../InMemoryRegistrationStore.cs | 8 +- .../OpenZaakJobProcessor.cs | 23 ++++- .../Big.Infrastructure/OpenZaakJobPump.cs | 48 ++++++++++ 5 files changed, 169 insertions(+), 22 deletions(-) create mode 100644 services/domain/Big.Infrastructure/OpenZaakJobPump.cs diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index 224cf41..293be9c 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -1,3 +1,5 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; using Big.Application; namespace Big.Infrastructure; @@ -9,9 +11,18 @@ namespace Big.Infrastructure; /// public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient { - public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + public async Task OpenZaakAsync(string bsn, CancellationToken ct = default) { - // STUB (red). - return Task.FromResult(new Uri("about:blank")); + using var response = await http.PostAsJsonAsync( + new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct); + response.EnsureSuccessStatusCode(); + + var opened = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("The ACL returned an empty zaak response."); + return new Uri(opened.ZaakUrl); } + + private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); + + private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 016a349..ee153ac 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -1,3 +1,8 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; using Big.Application; using Big.Domain; @@ -7,25 +12,97 @@ namespace Big.Infrastructure; /// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process /// instances and drives the OpenZaakAanmaken external-worker jobs over Flowable's REST API /// (start: service/runtime/process-instances; acquire/complete: external-job-api/…). +/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) : IWorkflowClient, IExternalWorkerClient { - public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + private const string Topic = "OpenZaakAanmaken"; + private const string ProcessDefinitionKey = "registratie"; + private const string RegistrationIdVariable = "registrationId"; + private const string ZaakUrlVariable = "zaakUrl"; + + public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { - // STUB (red). - return Task.FromResult(""); + var request = new StartProcessRequest( + ProcessDefinitionKey, + [new Variable(RegistrationIdVariable, "string", registrationId.ToString())]); + + var created = await PostAsync( + "service/runtime/process-instances", request, ct) + ?? throw new InvalidOperationException("Flowable returned an empty process-instance response."); + return created.Id; } - public Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) + public async Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) { - // STUB (red). - return Task.FromResult>([]); + var request = new AcquireJobsRequest(Topic, options.LockDuration, maxJobs, options.WorkerId); + + var jobs = await PostAsync>( + "external-job-api/acquire/jobs", request, ct) ?? []; + + return [.. jobs.Select(job => new OpenZaakJob(job.Id, RegistrationId.Parse(job.RegistrationId())))]; } - public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) + public async Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) { - // STUB (red). - return Task.CompletedTask; + var request = new CompleteJobRequest( + options.WorkerId, + [new Variable(ZaakUrlVariable, "string", zaakUrl.ToString())]); + + using var response = await SendAsync( + $"external-job-api/acquire/jobs/{jobId}/complete", request, ct); + response.EnsureSuccessStatusCode(); + } + + private async Task PostAsync(string path, TRequest body, CancellationToken ct) + { + using var response = await SendAsync(path, body, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(ct); + } + + private Task SendAsync(string path, TRequest body, CancellationToken ct) + { + var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) + { + Content = JsonContent.Create(body), + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials()); + return http.SendAsync(message, ct); + } + + private string BasicCredentials() + => Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}")); + + private sealed record StartProcessRequest( + [property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey, + [property: JsonPropertyName("variables")] IReadOnlyList Variables); + + private sealed record AcquireJobsRequest( + [property: JsonPropertyName("topic")] string Topic, + [property: JsonPropertyName("lockDuration")] string LockDuration, + [property: JsonPropertyName("numberOfTasks")] int NumberOfTasks, + [property: JsonPropertyName("workerId")] string WorkerId); + + private sealed record CompleteJobRequest( + [property: JsonPropertyName("workerId")] string WorkerId, + [property: JsonPropertyName("variables")] IReadOnlyList Variables); + + private sealed record Variable( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("value")] string Value); + + private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id); + + private sealed record AcquiredJob( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("variables")] IReadOnlyList Variables) + { + /// The registration id this job carries as a process variable. + public string RegistrationId() => + Variables.SingleOrDefault(v => v.Name == "registrationId")?.Value + ?? throw new InvalidOperationException($"OpenZaakAanmaken job {Id} carries no registrationId variable."); } } diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs index 9e83fa9..0acfe94 100644 --- a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -15,13 +15,11 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task SaveAsync(Registration registration, CancellationToken ct = default) { - // STUB (red). + ArgumentNullException.ThrowIfNull(registration); + _byId[registration.Id] = registration; return Task.CompletedTask; } public Task GetAsync(RegistrationId id, CancellationToken ct = default) - { - // STUB (red). - return Task.FromResult(null); - } + => Task.FromResult(_byId.GetValueOrDefault(id)); } diff --git a/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs index 32acf16..cea5576 100644 --- a/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs +++ b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs @@ -15,12 +15,25 @@ public sealed class OpenZaakJobProcessor( OpenZaakWorker worker, ILogger logger) { - /// Process up to jobs. Returns the number completed. + /// Acquire and process up to jobs. Returns the number acquired. public async Task PumpOnceAsync(int maxJobs, CancellationToken ct = default) { - // STUB (red). - await Task.CompletedTask; - _ = logger; - return 0; + var jobs = await client.AcquireOpenZaakJobsAsync(maxJobs, ct); + + foreach (var job in jobs) + { + try + { + var zaakUrl = await worker.HandleAsync(job, ct); + await client.CompleteOpenZaakJobAsync(job.JobId, zaakUrl, ct); + } + catch (Exception ex) + { + // Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6). + logger.LogError(ex, "OpenZaakAanmaken job {JobId} failed; leaving it for redelivery.", job.JobId); + } + } + + return jobs.Count; } } diff --git a/services/domain/Big.Infrastructure/OpenZaakJobPump.cs b/services/domain/Big.Infrastructure/OpenZaakJobPump.cs new file mode 100644 index 0000000..44bcdca --- /dev/null +++ b/services/domain/Big.Infrastructure/OpenZaakJobPump.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// The hosted polling loop of the external-task job worker (ADR-0009): on an interval it resolves a +/// scoped and asks it to drain the parked OpenZaakAanmaken +/// jobs. A deliberately thin shell — all acquire/process/complete logic lives in the processor, which +/// is unit-tested; this class only owns the timer, the per-tick scope, and loop resilience. +/// +public sealed class OpenZaakJobPump( + IServiceScopeFactory scopeFactory, + FlowableOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var processor = scope.ServiceProvider.GetRequiredService(); + await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + // A transient fault (e.g. Flowable briefly unreachable) must not kill the loop. + logger.LogError(ex, "OpenZaakAanmaken job poll failed; retrying after the poll interval."); + } + + try + { + await Task.Delay(options.PollInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } +} -- 2.49.1 From 22ab38f328abe60b3ca1c40256ab7f0ec92684e3 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:13:27 +0200 Subject: [PATCH 08/16] feat(domain): expose POST /registrations and the read endpoint (refs #6) The BIG Domain Service Api wires the use cases and the hosted job worker: POST /registrations creates the aggregate and starts the registratie process, returning 202 with a location; GET /registrations/{id} reads the aggregate so the eventually-opened zaak URL can be observed (ADR-0009). The Workflow Client is registered once behind both Flowable ports; the ACL client and in-memory store complete the wiring. Verified against a live flowable-rest: submit starts a parked process and the worker polls it. Co-Authored-By: Claude Opus 4.8 (1M context) --- register-referentie.slnx | 1 + services/domain/Big.Api/Big.Api.csproj | 14 ++++++ services/domain/Big.Api/Program.cs | 64 ++++++++++++++++++++++++ services/domain/Big.Api/appsettings.json | 9 ++++ services/domain/Big.slnx | 1 + 5 files changed, 89 insertions(+) create mode 100644 services/domain/Big.Api/Big.Api.csproj create mode 100644 services/domain/Big.Api/Program.cs create mode 100644 services/domain/Big.Api/appsettings.json diff --git a/register-referentie.slnx b/register-referentie.slnx index 960a92e..60bd708 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -11,6 +11,7 @@ + diff --git a/services/domain/Big.Api/Big.Api.csproj b/services/domain/Big.Api/Big.Api.csproj new file mode 100644 index 0000000..ae03b53 --- /dev/null +++ b/services/domain/Big.Api/Big.Api.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs new file mode 100644 index 0000000..6f2599d --- /dev/null +++ b/services/domain/Big.Api/Program.cs @@ -0,0 +1,64 @@ +using Big.Application; +using Big.Domain; +using Big.Infrastructure; + +var builder = WebApplication.CreateBuilder(args); + +// Options bound from configuration (compose sets Flowable__* and Acl__* env vars). +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Flowable").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Flowable'")); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl'")); + +// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009). +builder.Services.AddSingleton(); + +// The Workflow Client is one type behind two ports (start side + worker side); both resolve to the +// same HttpClient-backed implementation — the only code that talks to Flowable (§8.2). +builder.Services.AddHttpClient(); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient(); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. +builder.Services.AddHostedService(); + +var app = builder.Build(); + +app.MapGet("/health", () => "Healthy"); + +// Submit a registration. The aggregate is created (INGEDIEND) and the registratie process started; +// the zaak is opened later, off the request path, by the worker — so this returns 202 Accepted with +// a location to read the registration's progress (ADR-0009, eventual consistency). +app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) => +{ + var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct); + return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); +}); + +// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). +app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => +{ + if (!Guid.TryParse(id, out var guid)) + return Results.NotFound(); + + var registration = await store.GetAsync(new RegistrationId(guid), ct); + return registration is null + ? Results.NotFound() + : Results.Ok(new RegistrationResponse( + registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString())); +}); + +await app.RunAsync(); + +public sealed record SubmitRegistrationRequest(string Bsn); + +public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); + +public partial class Program; diff --git a/services/domain/Big.Api/appsettings.json b/services/domain/Big.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/services/domain/Big.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/services/domain/Big.slnx b/services/domain/Big.slnx index f60c02a..262d543 100644 --- a/services/domain/Big.slnx +++ b/services/domain/Big.slnx @@ -2,5 +2,6 @@ + -- 2.49.1 From 79dcd8f14bea09b507337e2af427be48d32a2ead Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:15:23 +0200 Subject: [PATCH 09/16] test(domain): acceptance scenario for submitting a registration (refs #6) Use-case-level BDD (Reqnroll) for S-05: a zorgprofessional submits a registration; the Domain Service starts the registratie process and the OpenZaakAanmaken external task opens a zaak via the ACL, recorded on the aggregate (ADR-0009). Driven against in-memory Workflow Client and ACL stand-ins; real Flowable+ACL+OpenZaak delivery is the live-stack verify-domain check. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/acceptance/Acceptance.csproj | 1 + .../Features/RegistratieIndienen.feature | 18 +++++++ .../Steps/RegistratieIndienenSteps.cs | 54 +++++++++++++++++++ .../acceptance/Support/InMemoryDomainPorts.cs | 50 +++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 tests/acceptance/Features/RegistratieIndienen.feature create mode 100644 tests/acceptance/Steps/RegistratieIndienenSteps.cs create mode 100644 tests/acceptance/Support/InMemoryDomainPorts.cs diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj index 7b795eb..08d182b 100644 --- a/tests/acceptance/Acceptance.csproj +++ b/tests/acceptance/Acceptance.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/acceptance/Features/RegistratieIndienen.feature b/tests/acceptance/Features/RegistratieIndienen.feature new file mode 100644 index 0000000..1f78c95 --- /dev/null +++ b/tests/acceptance/Features/RegistratieIndienen.feature @@ -0,0 +1,18 @@ +# language: en +# Drives S-05 (#6). A zorgprofessional submits a registration; the Domain Service starts the +# registratie process and, via the external-task worker, opens a zaak through the ACL and records +# it on the aggregate (ADR-0009). This scenario exercises the use cases against in-memory stand-ins +# for the Workflow Client and the ACL; real Flowable + ACL + OpenZaak delivery is verified by the +# live-stack check (verify-domain). +Feature: Een registratie indienen + Als zorgprofessional wil ik mijn registratie indienen + zodat er een registratieproces start en een zaak wordt geopend. + + Scenario: Indienen start het registratieproces en opent een zaak + Given a zorgprofessional submits a registration for BSN "123456782" + When the domain service handles the submission + Then a registratie process is started for the registration + And the registration has status "INGEDIEND" + When the OpenZaakAanmaken external task is handled for the registration + Then a zaak is opened via the ACL for BSN "123456782" + And the zaak is recorded on the registration diff --git a/tests/acceptance/Steps/RegistratieIndienenSteps.cs b/tests/acceptance/Steps/RegistratieIndienenSteps.cs new file mode 100644 index 0000000..a882e9e --- /dev/null +++ b/tests/acceptance/Steps/RegistratieIndienenSteps.cs @@ -0,0 +1,54 @@ +using Acceptance.Support; +using Big.Application; +using Big.Domain; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for RegistratieIndienen.feature (S-05). Drives the SubmitRegistration +/// and OpenZaakWorker use cases against in-memory ports; one instance per scenario. +[Binding] +public sealed class RegistratieIndienenSteps +{ + private readonly InMemoryWorkflowClient _workflow = new(); + private readonly InMemoryAclClient _acl = new(); + private readonly InMemoryRegistrationStore _store = new(); + private string _bsn = ""; + private RegistrationId _id; + + [Given("a zorgprofessional submits a registration for BSN \"(.*)\"")] + public void GivenAZorgprofessionalSubmitsARegistration(string bsn) => _bsn = bsn; + + [When("the domain service handles the submission")] + public async Task WhenTheDomainServiceHandlesTheSubmission() + => _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(_bsn)); + + [Then("a registratie process is started for the registration")] + public void ThenARegistratieProcessIsStarted() + => Assert.Equal(_id, _workflow.StartedFor); + + [Then("the registration has status \"(.*)\"")] + public async Task ThenTheRegistrationHasStatus(string expected) + { + var registration = await _store.GetAsync(_id); + Assert.NotNull(registration); + Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant()); + Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, registration.ProcessInstanceId); + } + + [When("the OpenZaakAanmaken external task is handled for the registration")] + public async Task WhenTheExternalTaskIsHandled() + => await new OpenZaakWorker(_store, _acl).HandleAsync(new OpenZaakJob("job-acc-1", _id)); + + [Then("a zaak is opened via the ACL for BSN \"(.*)\"")] + public void ThenAZaakIsOpenedViaTheAcl(string bsn) + => Assert.Equal(bsn, _acl.OpenedForBsn); + + [Then("the zaak is recorded on the registration")] + public async Task ThenTheZaakIsRecordedOnTheRegistration() + { + var registration = await _store.GetAsync(_id); + Assert.Equal(InMemoryAclClient.OpenedZaakUrl, registration!.ZaakUrl); + } +} diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs new file mode 100644 index 0000000..3059aa7 --- /dev/null +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -0,0 +1,50 @@ +using Big.Application; +using Big.Domain; + +namespace Acceptance.Support; + +/// An in-memory Workflow Client for the domain acceptance scenario: it records which +/// registration a process was started for and returns a fixed instance id. The acceptance test +/// drives the use case without a running Flowable (live verification is the verify-domain check). +public sealed class InMemoryWorkflowClient : IWorkflowClient +{ + public const string StartedProcessInstanceId = "proc-acc-1"; + + public RegistrationId? StartedFor { get; private set; } + + public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + { + StartedFor = registrationId; + return Task.FromResult(StartedProcessInstanceId); + } +} + +/// An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL, +/// so the scenario verifies the domain crosses the ACL boundary (§8.1) without a running OpenZaak. +public sealed class InMemoryAclClient : IAclClient +{ + public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); + + public string? OpenedForBsn { get; private set; } + + public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + { + OpenedForBsn = bsn; + return Task.FromResult(OpenedZaakUrl); + } +} + +/// An in-memory registration store for the domain acceptance scenario. +public sealed class InMemoryRegistrationStore : IRegistrationStore +{ + private readonly Dictionary _byId = []; + + public Task SaveAsync(Registration registration, CancellationToken ct = default) + { + _byId[registration.Id] = registration; + return Task.CompletedTask; + } + + public Task GetAsync(RegistrationId id, CancellationToken ct = default) + => Task.FromResult(_byId.GetValueOrDefault(id)); +} -- 2.49.1 From e9a873c152486d90b2805326c429712f5b7d2d0c Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:24:56 +0200 Subject: [PATCH 10/16] test(domain): mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stryker.NET config for the domain service (break 90, the repo's ratchet floor), excluding the OpenZaakJobPump hosted-shell from mutation. Hardened the unit tests to kill survivors — Basic-credential value, variable types, null/failure response paths, option defaults, guard clauses, save counts and log output — leaving only two documented equivalent mutants (Stryker-disabled). make mutation runs the domain ratchet and CI uploads its report alongside the others. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 6 +++ Makefile | 6 ++- services/domain/Big.Domain/Registration.cs | 2 + .../FlowableWorkflowClient.cs | 2 + .../domain/Big.Tests/AclHttpClientTests.cs | 3 +- services/domain/Big.Tests/CapturingLogger.cs | 24 +++++++++ .../Big.Tests/FlowableWorkflowClientTests.cs | 52 +++++++++++++++++-- .../InMemoryRegistrationStoreTests.cs | 8 +++ .../Big.Tests/OpenZaakJobProcessorTests.cs | 8 ++- .../domain/Big.Tests/OpenZaakWorkerTests.cs | 14 ++++- .../domain/Big.Tests/RegistrationTests.cs | 15 +++++- .../Big.Tests/SubmitRegistrationTests.cs | 14 +++++ services/domain/stryker-config.json | 15 ++++++ 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 services/domain/Big.Tests/CapturingLogger.cs create mode 100644 services/domain/stryker-config.json diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 8b90f88..9e3c95b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -68,6 +68,12 @@ jobs: name: event-subscriber-mutation-report path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn + - uses: https://github.com/actions/upload-artifact@v3 + if: always() + with: + name: domain-mutation-report + path: services/domain/StrykerOutput/**/reports/mutation-report.html + if-no-files-found: warn # One stage for every check that needs the live stack. On the single self-hosted # runner jobs run sequentially, so booting OpenZaak once (instead of once per job) diff --git a/Makefile b/Makefile index 0f8d469..0f65935 100644 --- a/Makefile +++ b/Makefile @@ -64,12 +64,14 @@ unit: ## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` # makes `make mutation` work from a fresh clone. Each service owns its config + break -# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json and -# services/event-subscriber/stryker-config.json. Scores never regress below baseline. +# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json, +# services/event-subscriber/stryker-config.json and services/domain/stryker-config.json. +# Scores never regress below baseline. mutation: dotnet tool restore cd services/acl && dotnet stryker cd services/event-subscriber && dotnet stryker + cd services/domain && dotnet stryker ## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down # SEED populates the external config volumes first (upstream images used verbatim; diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index c5d6f8c..d6f2d85 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -57,6 +57,8 @@ public sealed class Registration if (ZaakUrl != zaakUrl) throw new InvalidOperationException( $"Registration {Id} already has zaak {ZaakUrl}; cannot attach a different zaak {zaakUrl}."); + // Stryker disable once Statement : equivalent — re-assigning the identical URL below is a + // no-op, so removing this early return is behaviourally indistinguishable. return; } diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index ee153ac..0b6ba23 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -102,6 +102,8 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti { /// The registration id this job carries as a process variable. public string RegistrationId() => + // Stryker disable once Linq : equivalent — Single and SingleOrDefault both throw on a job + // with no registrationId variable (the only untested branch), so the mutant is indistinguishable. Variables.SingleOrDefault(v => v.Name == "registrationId")?.Value ?? throw new InvalidOperationException($"OpenZaakAanmaken job {Id} carries no registrationId variable."); } diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index a08f174..2818b7d 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -38,6 +38,7 @@ public class AclHttpClientTests var capture = new RequestCapture(); var client = Client(capture.Responds(HttpStatusCode.OK, "null")); - await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); } } diff --git a/services/domain/Big.Tests/CapturingLogger.cs b/services/domain/Big.Tests/CapturingLogger.cs new file mode 100644 index 0000000..d8231e0 --- /dev/null +++ b/services/domain/Big.Tests/CapturingLogger.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace Big.Tests; + +/// A minimal that records the level and rendered message of each +/// entry, so tests can assert that (for example) a failing job was logged. +internal sealed class CapturingLogger : ILogger +{ + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + => Entries.Add((logLevel, formatter(state, exception))); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } +} diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index e1b3b23..625d69f 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text; using Big.Domain; using Big.Infrastructure; @@ -12,6 +13,9 @@ public class FlowableWorkflowClientTests new HttpClient(handler), new FlowableOptions { BaseUrl = Base, Username = "rest-admin", Password = "test", WorkerId = "worker-x" }); + private static string DecodeBasic(HttpRequestMessage request) + => Encoding.ASCII.GetString(Convert.FromBase64String(request.Headers.Authorization!.Parameter!)); + [Fact] public async Task Start_posts_the_registration_id_variable_and_returns_the_instance_id() { @@ -26,11 +30,29 @@ public class FlowableWorkflowClientTests Assert.Equal("http://flowable/flowable-rest/service/runtime/process-instances", capture.Seen.RequestUri!.ToString()); Assert.Equal("Basic", capture.Seen.Headers.Authorization!.Scheme); + Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen)); Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body); Assert.Contains("\"name\":\"registrationId\"", capture.Body); + Assert.Contains("\"type\":\"string\"", capture.Body); Assert.Contains($"\"value\":\"{rid}\"", capture.Body); } + [Fact] + public async Task Start_uses_the_configured_worker_credentials_and_defaults() + { + var capture = new RequestCapture(); + // Only BaseUrl set — the worker id and (empty) credentials must come from the defaults. + var client = new FlowableWorkflowClient( + new HttpClient(capture.Responds(HttpStatusCode.OK, "[]")), + new FlowableOptions { BaseUrl = Base }); + + await client.AcquireOpenZaakJobsAsync(1); + + Assert.Equal(":", DecodeBasic(capture.Seen!)); + Assert.Contains("\"workerId\":\"big-domain-worker\"", capture.Body); + Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); + } + [Fact] public async Task Acquire_posts_the_topic_and_parses_jobs_with_their_registration_id() { @@ -52,11 +74,13 @@ public class FlowableWorkflowClientTests Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); } - [Fact] - public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs() + [Theory] + [InlineData("[]")] + [InlineData("null")] + public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs(string body) { var capture = new RequestCapture(); - var client = Client(capture.Responds(HttpStatusCode.OK, "[]")); + var client = Client(capture.Responds(HttpStatusCode.OK, body)); var jobs = await client.AcquireOpenZaakJobsAsync(1); @@ -64,6 +88,17 @@ public class FlowableWorkflowClientTests Assert.Empty(jobs); } + [Fact] + public async Task Acquire_throws_when_a_job_carries_no_registration_id() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, """[{"id":"job-1","variables":[]}]""")); + + var ex = await Assert.ThrowsAsync(() => client.AcquireOpenZaakJobsAsync(1)); + Assert.Contains("job-1", ex.Message); + Assert.Contains("registrationId", ex.Message); + } + [Fact] public async Task Complete_posts_the_zaak_url_variable_to_the_job_complete_endpoint() { @@ -78,6 +113,7 @@ public class FlowableWorkflowClientTests capture.Seen.RequestUri!.ToString()); Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); Assert.Contains("\"name\":\"zaakUrl\"", capture.Body); + Assert.Contains("\"type\":\"string\"", capture.Body); Assert.Contains("\"value\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); } @@ -90,4 +126,14 @@ public class FlowableWorkflowClientTests await Assert.ThrowsAsync( () => client.StartRegistrationProcessAsync(RegistrationId.New())); } + + [Fact] + public async Task Complete_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + + await Assert.ThrowsAsync( + () => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } } diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 8faaae6..1a63c57 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -33,4 +33,12 @@ public class InMemoryRegistrationStoreTests Assert.NotNull(loaded); Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", loaded.ZaakUrl!.ToString()); } + + [Fact] + public async Task Saving_a_null_registration_is_rejected() + { + var store = new InMemoryRegistrationStore(); + + await Assert.ThrowsAsync(() => store.SaveAsync(null!)); + } } diff --git a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs index 35a0c22..487e636 100644 --- a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs +++ b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs @@ -1,6 +1,7 @@ using Big.Application; using Big.Domain; using Big.Infrastructure; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace Big.Tests; @@ -54,11 +55,16 @@ public class OpenZaakJobProcessorTests var acl = new FakeAclClient(); // The job correlates to a registration that is not in the store, so the worker throws. var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", RegistrationId.New())); + var logger = new CapturingLogger(); + var processor = new OpenZaakJobProcessor(client, new OpenZaakWorker(store, acl), logger); - var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + var acquired = await processor.PumpOnceAsync(5); Assert.Equal(1, acquired); Assert.Empty(client.Completed); + // The failure is logged (and the job left for redelivery), not swallowed silently. + var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error); + Assert.Contains("job-1", error.Message); } [Fact] diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs index 2213d59..a57af38 100644 --- a/services/domain/Big.Tests/OpenZaakWorkerTests.cs +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -27,6 +27,15 @@ public class OpenZaakWorkerTests Assert.Equal("123456782", acl.OpenedForBsn); var saved = await store.GetAsync(registration.Id); Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Handling_a_null_job_is_rejected() + { + var worker = new OpenZaakWorker(new FakeRegistrationStore(), new FakeAclClient()); + + await Assert.ThrowsAsync(() => worker.HandleAsync(null!)); } [Fact] @@ -36,8 +45,9 @@ public class OpenZaakWorkerTests var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); - await Assert.ThrowsAsync( - () => worker.HandleAsync(new OpenZaakJob("job-1", RegistrationId.New()))); + var job = new OpenZaakJob("job-1", RegistrationId.New()); + var ex = await Assert.ThrowsAsync(() => worker.HandleAsync(job)); + Assert.Contains(job.RegistrationId.ToString(), ex.Message); Assert.Equal(0, acl.CallCount); } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index 85b2e8a..a8463a0 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -33,6 +33,17 @@ public class RegistrationTests Assert.Equal("proc-42", registration.ProcessInstanceId); } + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Recording_an_empty_process_instance_id_is_rejected(string? processInstanceId) + { + var registration = Registration.Submit("123456782"); + + Assert.ThrowsAny(() => registration.RecordProcessStarted(processInstanceId!)); + } + [Fact] public void Attaching_a_zaak_records_its_url_and_keeps_the_status_ingediend() { @@ -71,7 +82,9 @@ public class RegistrationTests var registration = Registration.Submit("123456782"); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); - Assert.Throws( + var ex = Assert.Throws( () => registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/other"))); + Assert.Contains("/zaken/abc", ex.Message); + Assert.Contains("/zaken/other", ex.Message); } } diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs index fd3c097..29974bf 100644 --- a/services/domain/Big.Tests/SubmitRegistrationTests.cs +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -21,6 +21,20 @@ public class SubmitRegistrationTests Assert.Equal("proc-42", saved.ProcessInstanceId); Assert.Equal(id, workflow.StartedFor); Assert.Null(saved.ZaakUrl); + // Persisted twice: once before starting the process, once after recording the instance id. + Assert.Equal(2, store.SaveCount); + } + + [Fact] + public async Task Rejects_a_null_command_without_touching_the_store_or_workflow() + { + var store = new FakeRegistrationStore(); + var workflow = new FakeWorkflowClient(); + var handler = new SubmitRegistration(store, workflow); + + await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); + Assert.Equal(0, store.SaveCount); + Assert.Null(workflow.StartedFor); } [Fact] diff --git a/services/domain/stryker-config.json b/services/domain/stryker-config.json new file mode 100644 index 0000000..935dfd7 --- /dev/null +++ b/services/domain/stryker-config.json @@ -0,0 +1,15 @@ +{ + "stryker-config": { + "solution": "Big.slnx", + "test-projects": ["Big.Tests/Big.Tests.csproj"], + "reporters": ["progress", "html"], + "mutate": [ + "!**/OpenZaakJobPump.cs" + ], + "thresholds": { + "high": 95, + "low": 90, + "break": 90 + } + } +} -- 2.49.1 From 5a3f28ac6d94de2a7a4bc14f16260983b2f6ac6b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:31:45 +0200 Subject: [PATCH 11/16] ci(domain): containerize, wire into compose, and verify end-to-end (refs #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile (multi-stage, .NET 10) + .dockerignore for the BIG Domain Service; a 'domain' service in infra/docker-compose.yml (health-checked, depends on acl healthy and flowable-init completed). run-domain-check.sh drives the full path against the up stack — seed a published zaaktype, recreate the acl pointed at it (host-consistent), POST /registrations, and assert the worker opens a zaak and records it. Wired as the verify-domain Makefile target + a verify-stack CI step; domain added to WAIT_SVCS and the log dump. seed_catalogus.py now emits a machine-readable ZAAKTYPE_URL line. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 4 +- Makefile | 10 ++++- infra/docker-compose.yml | 34 +++++++++++++++- infra/openzaak/seed_catalogus.py | 4 ++ infra/run-domain-check.sh | 68 ++++++++++++++++++++++++++++++++ services/domain/.dockerignore | 4 ++ services/domain/Dockerfile | 34 ++++++++++++++++ 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100755 infra/run-domain-check.sh create mode 100644 services/domain/.dockerignore create mode 100644 services/domain/Dockerfile diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9e3c95b..16901c6 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -94,10 +94,12 @@ jobs: run: make verify-nrc - name: OpenZaak → NRC → Event Subscriber → projection-api run: make verify-projection + - name: Domain → Flowable → ACL → OpenZaak + run: make verify-domain # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-init keycloak acl bff projection-db event-subscriber projection-api 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true - name: Tear down if: always() run: make down diff --git a/Makefile b/Makefile index 0f65935..b6fba46 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml # Long-running services with a healthcheck — the smoke polls these for readiness # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. -WAIT_SVCS := openzaak nrc-web acl bff event-subscriber projection-api +WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # into external named volumes via `docker cp` (infra/seed-config.sh) instead of # bind-mounted, because bind mounts don't reach sibling containers on the @@ -138,6 +138,11 @@ verify-nrc: verify-projection: bash infra/run-projection-check.sh +## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the +## already-running stack. Recreates the acl service to inject the seeded zaaktype URL. +verify-domain: + bash infra/run-domain-check.sh + ## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. @@ -148,7 +153,8 @@ verify: WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \ && bash infra/run-acl-integration.sh \ && bash infra/run-notification-check.sh \ - && bash infra/run-projection-check.sh || rc=$$?; \ + && bash infra/run-projection-check.sh \ + && bash infra/run-domain-check.sh || rc=$$?; \ docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \ docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ exit $$rc' diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index ecbe7bd..02a4e90 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -285,7 +285,9 @@ services: dockerfile: Dockerfile image: register-referentie/acl:dev environment: - Acl__OpenZaak__BaseUrl: http://openzaak:8000/ + # Overridable so verify-domain can point the ACL at the same OpenZaak host that + # owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009). + Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/} Acl__OpenZaak__ClientId: big-reference-seed Acl__OpenZaak__Secret: insecure-dev-secret-change-me Acl__Defaults__Bronorganisatie: "517439943" @@ -306,6 +308,36 @@ services: condition: service_healthy networks: [cg] + # ── BIG Domain Service (S-05) ────────────────────────────────────────────── + # Orchestrates a registration: POST /registrations creates the aggregate and + # starts the registratie Flowable process; a hosted worker acquires the + # OpenZaakAanmaken job, opens a zaak via the ACL and completes it (ADR-0009). + # Talks only to Flowable (Workflow Client, §8.2) and the ACL (§8.1). + 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] + # ── BFF ────────────────────────────────────────────────────────────────── bff: build: diff --git a/infra/openzaak/seed_catalogus.py b/infra/openzaak/seed_catalogus.py index a9aa4c6..5dc15a8 100644 --- a/infra/openzaak/seed_catalogus.py +++ b/infra/openzaak/seed_catalogus.py @@ -210,6 +210,10 @@ def main(): print(f"zaaktypen in BIG: {names}") assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed" state = "published" if PUBLISH else "concept" + # Machine-readable line so callers (e.g. infra/run-domain-check.sh) can capture the + # zaaktype URL to configure the ACL's default-fill (ADR-0003/0009). + zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE") + print(f"ZAAKTYPE_URL {zt_url}") print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)") diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh new file mode 100755 index 0000000..33d7d9e --- /dev/null +++ b/infra/run-domain-check.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Verify the BIG Domain Service end-to-end (S-05) against an ALREADY-RUNNING full stack: +# domain → Flowable (start the registratie process + external-task worker) → ACL → OpenZaak. +# Submits a registration to the domain and asserts the worker opens a zaak in OpenZaak and +# records its URL on the aggregate (ADR-0009). +# +# The seeded zaaktype URL is server-assigned, so it isn't knowable at initial bring-up. This +# script therefore seeds a published BIG zaaktype and recreates the `acl` service configured to +# default-fill it — pointing the ACL at the SAME OpenZaak host that owns the URL, so zaak creation +# is host-consistent (exactly the configuration the ACL integration test proves, ADR-0006). That +# one recreate aside, the caller owns stack bring-up + teardown. +# +# All in-network, reaching services by container IP (a single-label host isn't URL-valid; the +# runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Plain docker primitives. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/.." && pwd)" +compose="$root/infra/docker-compose.yml" + +ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; } + +oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)" +dom="$(docker ps -q --filter 'name=domain' | head -1)" +[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container — bring the stack up first" >&2; exit 1; } +[ -n "$dom" ] || { echo "ERROR: no running domain container — bring the stack up first" >&2; exit 1; } +net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)" +oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")" +oz_base="http://$oz_ip:8000" +echo ">> openzaak=$oz_ip domain=$dom_ip network=$net" + +echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL" +sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)" +docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null +zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)" +docker rm -f "$sid" >/dev/null +[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } +echo ">> zaaktype: $zt_url" + +echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)" +ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl +WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl + +echo ">> submitting a registration to the domain" +loc="$(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 "$loc" ] || { echo "ERROR: POST /registrations returned no Location" >&2; exit 1; } +echo ">> registration accepted at $loc" + +echo ">> polling the domain until the worker records the opened zaak" +for _ in $(seq 1 30); do + body="$(docker run --rm --network "$net" curlimages/curl:latest \ + -fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)" + if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then + echo "OK — the domain opened a zaak and recorded it on the registration:" + echo "$body" | cut -c1-300 + exit 0 + fi + sleep 2 +done +echo "FAIL — the registration never received a zaak URL" >&2 +echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 +acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" +[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } +exit 1 diff --git a/services/domain/.dockerignore b/services/domain/.dockerignore new file mode 100644 index 0000000..173dc20 --- /dev/null +++ b/services/domain/.dockerignore @@ -0,0 +1,4 @@ +**/bin +**/obj +**/*.user +StrykerOutput diff --git a/services/domain/Dockerfile b/services/domain/Dockerfile new file mode 100644 index 0000000..600799b --- /dev/null +++ b/services/domain/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build for the BIG Domain Service (.NET 10). +# Build context is services/domain (see infra/docker-compose.yml). +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +# Restore first (cached unless .csproj files change). +COPY Big.Api/Big.Api.csproj Big.Api/ +COPY Big.Application/Big.Application.csproj Big.Application/ +COPY Big.Infrastructure/Big.Infrastructure.csproj Big.Infrastructure/ +COPY Big.Domain/Big.Domain.csproj Big.Domain/ +RUN dotnet restore Big.Api/Big.Api.csproj + +COPY Big.Api/ Big.Api/ +COPY Big.Application/ Big.Application/ +COPY Big.Infrastructure/ Big.Infrastructure/ +COPY Big.Domain/ Big.Domain/ +RUN dotnet publish Big.Api/Big.Api.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \ + CMD curl -fsS http://localhost:8080/health || exit 1 + +ENTRYPOINT ["dotnet", "Big.Api.dll"] -- 2.49.1 From 67f0ffb88df0c66675ad1d97261ac703f0fb4550 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 30 Jun 2026 17:32:29 +0200 Subject: [PATCH 12/16] docs(domain): demo note for submitting a registration (S-05) (refs #6) Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/demo-script.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/demo-script.md b/docs/demo-script.md index a9e5396..e570024 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,38 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-05 — BIG Domain Service: submit a registration + +**Outcome:** submitting a registration starts a Flowable process; the external-task worker +opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton +that produces the zaak S-06 then projects. + +**The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken` +worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009). + +```bash +# 1. Bring the full stack up (seeds config, builds our services, waits for health). +make up + +# 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the +# ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up). +make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration" + +# 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back. +loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \ + -H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p') + +# 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll +# until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.) +curl -fsS "http://localhost:8130$loc" | jq +# → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/" } +``` + +> Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the +> projection (S-06), fed by the very zaak this flow opens. + +--- + ## S-06 — Event Subscriber + read projection **Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which -- 2.49.1 From ac874bf7469c4cbba8b290c54a0fe866b789fffd Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 1 Jul 2026 09:32:16 +0200 Subject: [PATCH 13/16] ci(mutation): make Stryker report upload best-effort (refs #62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gitea artifact backend returns 500 to actions/upload-artifact@v3 (server-side, distinct from the @v4 GHES guard). With if: always() that 500 failed the whole mutation job even though the ratchet passed — red on main and on every PR. Mark the three report uploads continue-on-error: true so the mutation *gate* stays the Stryker ratchet (make mutation's exit code), not the report upload. Documented in gitea-actions-gotchas.md §4. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 13 +++++++++---- docs/runbooks/gitea-actions-gotchas.md | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 16901c6..f857747 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -51,25 +51,30 @@ jobs: with: dotnet-version: '10.0.x' - run: make mutation - # Publish the Stryker HTML report. `if: always()` uploads it even when the + # Publish the Stryker HTML reports. `if: always()` uploads them even when the # ratchet fails — that is exactly when you want to inspect the survivors. - # Glob handles Stryker's non-deterministic StrykerOutput// dir. - # Pinned to @v3 deliberately: @v4 refuses to run on Gitea (GHES guard) — - # see docs/runbooks/gitea-actions-gotchas.md §4. + # `continue-on-error` keeps the upload best-effort: the mutation *gate* is the + # ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend + # 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's + # non-deterministic StrykerOutput// dir. Pinned @v3: @v4's bundled + # @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook. - uses: https://github.com/actions/upload-artifact@v3 if: always() + continue-on-error: true with: name: acl-mutation-report path: services/acl/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - uses: https://github.com/actions/upload-artifact@v3 if: always() + continue-on-error: true with: name: event-subscriber-mutation-report path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - uses: https://github.com/actions/upload-artifact@v3 if: always() + continue-on-error: true with: name: domain-mutation-report path: services/domain/StrykerOutput/**/reports/mutation-report.html diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 1632671..29f97d2 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -14,6 +14,7 @@ those containers share a filesystem — or a `localhost` — breaks. | `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` | | `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks | | `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) | +| `upload-artifact@v3` fails with "Artifact service responded with 500" | mark the upload `continue-on-error: true` (server-side; issue #62) | `.gitea/workflows/ci.yaml` (`mutation` job) | --- @@ -125,6 +126,22 @@ guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a dro swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible artifact support. +**Second failure mode — the server's artifact backend returns 500.** Even on the +correctly-pinned `@v3`, uploads can fail with: + +``` +Create Artifact Container - Attempt 5 of 5 failed with error: Artifact service responded with 500 +::error::Create Artifact Container failed: Artifact service responded with 500 +``` + +This is the **Gitea server's** artifact storage failing (not the action's GHES guard), +so it is outside the repo's control. Because the `mutation` job's upload steps run with +`if: always()`, that 500 would fail the job even though the ratchet passed. **Fix:** mark +the uploads `continue-on-error: true` (issue #62). The mutation *gate* is the Stryker +ratchet — `make mutation`'s exit code fails the job on a real regression — so the report +upload is best-effort: when the server's artifact storage is restored, reports publish +again with no workflow change. + --- ## 5. A runner process can't reach a service container's published port -- 2.49.1 From f885e0a3be698501210ce26a417efb1235ff3b49 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 1 Jul 2026 09:58:28 +0200 Subject: [PATCH 14/16] ci: retrigger after runner cleanup (refs #6) Co-Authored-By: Claude Opus 4.8 (1M context) -- 2.49.1 From 1edd34e2dba5537c18ab3db5d7d9f30a567becd8 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 1 Jul 2026 10:04:55 +0200 Subject: [PATCH 15/16] ci: retrigger CI (refs #6) Co-Authored-By: Claude Opus 4.8 (1M context) -- 2.49.1 From 72efab3ae0a77060e489213bb99e8ebd2910a4e9 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 1 Jul 2026 10:09:02 +0200 Subject: [PATCH 16/16] ci: retrigger CI after gitea restart (refs #6) Co-Authored-By: Claude Opus 4.8 (1M context) -- 2.49.1