From 551f6e0a0be8077f940dfc0466717d6b969ce097 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:45:36 +0200 Subject: [PATCH 01/11] =?UTF-8?q?test(domain):=20approving=20a=20registrat?= =?UTF-8?q?ion=20advances=20INGEDIEND=20=E2=86=92=20INGESCHREVEN=20(refs?= =?UTF-8?q?=20#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards: an opened zaak is required and only a submitted registration can be approved. Fails to compile — Registration.Approve() and RegistrationStatus. Ingeschreven don't exist yet (red). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/Big.Tests/RegistrationTests.cs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index a8463a0..c924f0b 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -87,4 +87,37 @@ public class RegistrationTests Assert.Contains("/zaken/abc", ex.Message); Assert.Contains("/zaken/other", ex.Message); } + + [Fact] + public void Approving_a_registration_with_a_zaak_sets_it_ingeschreven() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + registration.Approve(); + + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } + + [Fact] + public void Approving_before_the_zaak_is_opened_is_rejected() + { + var registration = Registration.Submit("123456782"); + + var ex = Assert.Throws(() => registration.Approve()); + + Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(RegistrationStatus.Ingediend, registration.Status); + } + + [Fact] + public void Approving_an_already_ingeschreven_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.Approve(); + + Assert.Throws(() => registration.Approve()); + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } } -- 2.49.1 From ca9964f79bd843a1e63137b1d15d08e2680a164d Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:45:36 +0200 Subject: [PATCH 02/11] feat(domain): add the Ingeschreven state and Registration.Approve() (refs #75) Approve() advances a submitted registration with an opened zaak to INGESCHREVEN; re-approval and approval-before-zaak are rejected as invalid transitions. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Domain/Registration.cs | 19 +++++++++++++++++++ .../domain/Big.Domain/RegistrationStatus.cs | 7 +++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index d6f2d85..be1f1ea 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -64,4 +64,23 @@ public sealed class Registration ZaakUrl = zaakUrl; } + + /// + /// Approve the registration — the behandelaar's decision to enter it in the register. Advances + /// . + /// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a + /// submitted registration can be approved: re-approving is rejected as an invalid transition. + /// + public void Approve() + { + if (ZaakUrl is null) + throw new InvalidOperationException( + $"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened."); + + if (Status != RegistrationStatus.Ingediend) + throw new InvalidOperationException( + $"Registration {Id} is {Status}; only an INGEDIEND registration can be approved."); + + Status = RegistrationStatus.Ingeschreven; + } } diff --git a/services/domain/Big.Domain/RegistrationStatus.cs b/services/domain/Big.Domain/RegistrationStatus.cs index 3ddad57..60bb171 100644 --- a/services/domain/Big.Domain/RegistrationStatus.cs +++ b/services/domain/Big.Domain/RegistrationStatus.cs @@ -1,10 +1,13 @@ 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+). +/// skeleton knows and the terminal ; 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, + + /// Approved: entered in the register. Terminal in the walking skeleton (S-09b). + Ingeschreven, } -- 2.49.1 From 66fe1e5f6f02ca6993b3ad489819dd3832799736 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:50:45 +0200 Subject: [PATCH 03/11] test(domain): approve use case sets the zaak status via the ACL, idempotently (refs #75) Red: ApproveRegistration and IAclClient.ApproveZaakAsync don't exist yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Big.Tests/ApproveRegistrationTests.cs | 74 +++++++++++++++++++ services/domain/Big.Tests/Fakes.cs | 10 +++ 2 files changed, 84 insertions(+) create mode 100644 services/domain/Big.Tests/ApproveRegistrationTests.cs diff --git a/services/domain/Big.Tests/ApproveRegistrationTests.cs b/services/domain/Big.Tests/ApproveRegistrationTests.cs new file mode 100644 index 0000000..2112f96 --- /dev/null +++ b/services/domain/Big.Tests/ApproveRegistrationTests.cs @@ -0,0 +1,74 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +public class ApproveRegistrationTests +{ + private static Registration WithZaak(string bsn = "123456782") + { + var registration = Registration.Submit(bsn); + registration.AttachZaak(FakeAclClient.DefaultZaakUrl); + return registration; + } + + [Fact] + public async Task Approving_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new ApproveRegistration(store, acl); + + await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); + + var saved = await store.GetAsync(registration.Id); + Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status); + Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl); + Assert.Equal(1, acl.ApproveCallCount); + } + + [Fact] + public async Task Approving_an_unknown_registration_throws_and_does_not_call_the_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var handler = new ApproveRegistration(store, acl); + + await Assert.ThrowsAsync( + () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); + Assert.Equal(0, acl.ApproveCallCount); + } + + [Fact] + public async Task Approving_before_the_zaak_is_opened_throws_without_calling_the_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = Registration.Submit("123456782"); // no zaak yet + store.Seed(registration); + var handler = new ApproveRegistration(store, acl); + + await Assert.ThrowsAsync( + () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); + Assert.Equal(0, acl.ApproveCallCount); + } + + [Fact] + public async Task Re_approving_an_already_ingeschreven_registration_is_idempotent() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new ApproveRegistration(store, acl); + + await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); + await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); + + // The second approval is a no-op: the ACL is not asked to set the status again. + Assert.Equal(1, acl.ApproveCallCount); + Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status); + } +} diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index 38d1bae..2377b7b 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -52,10 +52,20 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient public string? OpenedForBsn { get; private set; } public int CallCount { get; private set; } + public Uri? ApprovedZaakUrl { get; private set; } + public int ApproveCallCount { get; private set; } + public Task OpenZaakAsync(string bsn, CancellationToken ct = default) { CallCount++; OpenedForBsn = bsn; return Task.FromResult(_zaakUrl); } + + public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ApproveCallCount++; + ApprovedZaakUrl = zaakUrl; + return Task.CompletedTask; + } } -- 2.49.1 From 3e0d2f9b11abcadbccd9de814dc3568aa292b947 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:50:45 +0200 Subject: [PATCH 04/11] feat(domain): ApproveRegistration use case + temp /registrations/{id}/approve endpoint (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads the registration, asks the ACL to set its zaak's final status (new IAclClient.ApproveZaakAsync → POST /statussen), then advances the aggregate to INGESCHREVEN. Idempotent: a repeated approval is a no-op. Adds HTTP-adapter tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Api/Program.cs | 13 +++++++ .../Big.Application/ApproveRegistration.cs | 36 +++++++++++++++++++ services/domain/Big.Application/Ports.cs | 6 ++++ .../Big.Infrastructure/AclHttpClient.cs | 11 ++++++ .../domain/Big.Tests/AclHttpClientTests.cs | 23 ++++++++++++ 5 files changed, 89 insertions(+) create mode 100644 services/domain/Big.Application/ApproveRegistration.cs diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 6f2599d..c180da8 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddTransient(sp => sp.GetRequiredService builder.Services.AddHttpClient(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -42,6 +43,18 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); }); +// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the +// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over +// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent. +app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) => +{ + if (!Guid.TryParse(id, out var guid)) + return Results.NotFound(); + + await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), ct); + return Results.NoContent(); +}); + // 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) => { diff --git a/services/domain/Big.Application/ApproveRegistration.cs b/services/domain/Big.Application/ApproveRegistration.cs new file mode 100644 index 0000000..ac6e32e --- /dev/null +++ b/services/domain/Big.Application/ApproveRegistration.cs @@ -0,0 +1,36 @@ +using Big.Domain; + +namespace Big.Application; + +/// A behandelaar's decision to approve a registration, in domain language. +public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId); + +/// +/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1), +/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an +/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The +/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition +/// keeps the domain's own view consistent. +/// +public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl) +{ + public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + + var registration = await store.GetAsync(command.RegistrationId, ct) + ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve."); + + // A repeated approval is a no-op: don't set the zaak status a second time. + if (registration.Status == RegistrationStatus.Ingeschreven) + return; + + if (registration.ZaakUrl is null) + throw new InvalidOperationException( + $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); + + await acl.ApproveZaakAsync(registration.ZaakUrl, ct); + registration.Approve(); + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 800b036..044777d 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -25,6 +25,12 @@ public interface IWorkflowClient public interface IAclClient { Task OpenZaakAsync(string bsn, CancellationToken ct = default); + + /// + /// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting + /// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen. + /// + Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs index 293be9c..c4b3129 100644 --- a/services/domain/Big.Infrastructure/AclHttpClient.cs +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -22,7 +22,18 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli return new Uri(opened.ZaakUrl); } + public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + using var response = await http.PostAsJsonAsync( + new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct); + response.EnsureSuccessStatusCode(); + } + private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); + + private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); } diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index 2818b7d..0fdfb48 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -41,4 +41,27 @@ public class AclHttpClientTests var ex = await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public async Task Approves_a_zaak_by_posting_its_url_to_statussen() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Approve_throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync( + () => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); + } } -- 2.49.1 From 3e42999c23e219896522dad0c0a1c29c4b793de6 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:54:39 +0200 Subject: [PATCH 05/11] test(acl): approving a zaak sets it to the zaaktype's eindstatus, dated today (refs #75) Red: AclService.ApproveZaakAsync and IZaakGateway.SetZaakToEindstatusAsync don't exist yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/acl/Acl.Tests/AclServiceTests.cs | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index d1bc38b..a51a132 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -9,13 +9,29 @@ public class AclServiceTests public ZaakRequest? Captured; public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved; + public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { Captured = request; return Task.FromResult(Result); } + + public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } } + private static AclDefaults Defaults() => new() + { + Bronorganisatie = "517439943", + VerantwoordelijkeOrganisatie = "517439943", + Vertrouwelijkheidaanduiding = "openbaar", + ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), + }; + private sealed class FixedClock(DateOnly today) : IClock { public DateOnly Today { get; } = today; @@ -61,4 +77,30 @@ public class AclServiceTests await Assert.ThrowsAsync(() => service.OpenZaakAsync(null!)); Assert.Null(gateway.Captured); } + + [Fact] + public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today() + { + var gateway = new FakeGateway(); + var defaults = Defaults(); + var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); + + await service.ApproveZaakAsync(zaak); + + Assert.NotNull(gateway.Approved); + Assert.Equal(zaak, gateway.Approved!.Value.Zaak); + Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype); + Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum); + } + + [Fact] + public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway() + { + var gateway = new FakeGateway(); + var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + + await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); + Assert.Null(gateway.Approved); + } } -- 2.49.1 From b697b0d865544814e1f08f68a85e086bbdd2692a Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:54:39 +0200 Subject: [PATCH 06/11] feat(acl): set a zaak to its eindstatus via ZGW + POST /statussen endpoint (refs #75) The gateway resolves the zaaktype's eindstatus from the catalogus (isEindstatus, falling back to the highest volgnummer) and POSTs a status against the zaak. Exposed as POST /statussen for the domain's approve use case. Adds an integration test that sets the eindstatus against a real OpenZaak and verifies the zaak's current status. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/acl/Acl.Api/Program.cs | 10 +++ services/acl/Acl.Application/AclService.cs | 11 ++++ services/acl/Acl.Application/IZaakGateway.cs | 7 +++ .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 63 +++++++++++++++++++ .../Acl.IntegrationTests/OpenZaakFixture.cs | 37 +++++++++++ .../OpenZaakGatewayIntegrationTests.cs | 28 +++++++++ 6 files changed, 156 insertions(+) diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 31385d9..d601366 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -24,8 +24,18 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); }); +// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak +// URL; the ACL owns the ZGW statustype resolution (§8.1). +app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) => +{ + await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct); + return Results.NoContent(); +}); + app.Run(); public sealed record OpenZaakRequest(string Bsn); +public sealed record SetStatusRequest(string ZaakUrl); + public partial class Program; diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index e285ee7..f9b866a 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -17,4 +17,15 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc return gateway.OpenZaakAsync(request, ct); } + + /// + /// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The + /// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// + public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + + return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + } } diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 1962488..c608c7a 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -5,4 +5,11 @@ namespace Acl.Application; public interface IZaakGateway { Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default); + + /// + /// Set the given zaak to the eindstatus (final statustype) of the supplied zaaktype — + /// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from + /// the catalogus and POSTs a status against the zaak, dated . + /// + Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); } diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index f9cce31..10a3c3f 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -41,6 +41,56 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return new Uri(created.Url); } + public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(zaakUrl); + ArgumentNullException.ThrowIfNull(zaaktypeUrl); + + var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); + + using var message = new HttpRequestMessage( + HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen")) + { + Content = JsonContent.Create(new StatusDto( + zaakUrl.ToString(), + eindstatus.ToString(), + // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC). + datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))), + }; + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + // The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the + // body so uwsgi gets a Content-Length instead of a chunked body. + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + } + + /// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus. + private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + + using var response = await http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + + var page = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response"); + var results = page.Results ?? []; + + // OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the + // highest volgnummer if the flag is absent. + var eindstatus = results.FirstOrDefault(s => s.IsEindstatus) + ?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault() + ?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}"); + return new Uri(eindstatus.Url); + } + private sealed record ZaakDto( [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("zaaktype")] string Zaaktype, @@ -50,4 +100,17 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record ZaakCreatedDto( [property: JsonPropertyName("url")] string Url); + + private sealed record StatusDto( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("statustype")] string Statustype, + [property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet); + + private sealed record StatustypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record StatustypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("volgnummer")] int Volgnummer, + [property: JsonPropertyName("isEindstatus")] bool IsEindstatus); } diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs index bcdfdcc..a743913 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs @@ -65,6 +65,43 @@ public sealed class OpenZaakFixture : IDisposable return JsonDocument.Parse(json).RootElement.Clone(); } + /// GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers. + public async Task GetJsonAsync(Uri url, CancellationToken ct = default) + { + using var message = new HttpRequestMessage(HttpMethod.Get, url); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken()); + + using var response = await Http.SendAsync(message, ct); + response.EnsureSuccessStatusCode(); + var json = await response.Content.ReadAsStringAsync(ct); + return JsonDocument.Parse(json).RootElement.Clone(); + } + + /// The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets. + public async Task FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default) + { + var query = new Uri(BaseUrl, + "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + var page = await GetJsonAsync(query, ct); + var results = page.GetProperty("results"); + + Uri? fallback = null; + var highest = int.MinValue; + foreach (var st in results.EnumerateArray()) + { + if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean()) + return new Uri(st.GetProperty("url").GetString()!); + var volgnummer = st.GetProperty("volgnummer").GetInt32(); + if (volgnummer > highest) + { + highest = volgnummer; + fallback = new Uri(st.GetProperty("url").GetString()!); + } + } + + return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}"); + } + // A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here // rather than reusing Acl.Infrastructure's internal minter to keep that internal. private string MintToken() diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index 16f2b7e..dfe88b2 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -42,4 +42,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); } + + [Fact] + public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype() + { + var zaaktype = await stack.FindPublishedBigZaaktypeAsync(); + Assert.True(zaaktype is not null, + "No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " + + "seed it with OZ_PUBLISH=1 (`make integration` does this)."); + + var gateway = new OpenZaakGateway(stack.Http, stack.Options); + var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest( + Bronorganisatie: "517439943", + VerantwoordelijkeOrganisatie: "517439943", + Vertrouwelijkheidaanduiding: "openbaar", + Zaaktype: zaaktype!, + Startdatum: DateOnly.FromDateTime(DateTime.UtcNow))); + + await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow)); + + // The zaak now carries a current status, and it is the zaaktype's eindstatus. + var zaak = await stack.GetZaakAsync(zaakUrl); + var statusUrl = zaak.GetProperty("status").GetString(); + Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status"); + + var status = await stack.GetJsonAsync(new Uri(statusUrl!)); + var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!); + Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString()); + } } -- 2.49.1 From b672132ed4f9a5b64d4d97abfa93cf4a521b6926 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 17:02:39 +0200 Subject: [PATCH 07/11] test(event-subscriber): project INGESCHREVEN from a status-set notification (refs #75) A zaken/status/create notification (hoofdObject = the zaak) updates the zaak's row to INGESCHREVEN, and a rebuild reproduces it. Red: Notification has no hoofdObject/ IsZaakStatusSet and RegistrationStatus.Ingeschreven doesn't exist. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NotificationProjectorTests.cs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs index 65d1092..c472c9a 100644 --- a/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs +++ b/services/event-subscriber/EventSubscriber.Tests/NotificationProjectorTests.cs @@ -8,6 +8,7 @@ namespace EventSubscriber.Tests; public sealed class NotificationProjectorTests { private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111"; + private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222"; private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryProjectionStore _store = new(); @@ -17,6 +18,10 @@ public sealed class NotificationProjectorTests private static Notification ZaakCreated(string url = ZaakUrl) => new("zaken", "zaak", "create", new Uri(url)); + // A status-set notification: resourceUrl is the status resource, hoofdObject is the zaak it belongs to. + private static Notification StatusSet(string zaakUrl = ZaakUrl, string statusUrl = StatusUrl) + => new("zaken", "status", "create", new Uri(statusUrl), new Uri(zaakUrl)); + [Fact] public async Task creating_a_zaak_writes_one_row_with_status_ingediend() { @@ -62,16 +67,53 @@ public sealed class NotificationProjectorTests [Theory] [InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource [InlineData("documenten", "zaak", "create")] // wrong kanaal only - [InlineData("zaken", "status", "create")] // wrong resource only [InlineData("zaken", "zaak", "update")] // wrong actie [InlineData("zaken", "zaak", "destroy")] // wrong actie - public async Task only_a_zaken_zaak_create_notification_is_projected(string kanaal, string resource, string actie) + [InlineData("zaken", "status", "update")] // a status change we ignore + [InlineData("zaken", "resultaat", "create")] // not a status we project + public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie) { await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl))); Assert.Empty(await _store.AllAsync()); } + [Fact] + public async Task setting_a_status_projects_ingeschreven_keyed_on_the_zaak_not_the_status() + { + await Projector().HandleAsync(StatusSet()); + + var entry = Assert.Single(await _store.AllAsync()); + // Keyed on the zaak (hoofdObject), not the status resource URL. + Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); + Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status); + } + + [Fact] + public async Task approving_updates_the_existing_zaak_row_from_ingediend_to_ingeschreven() + { + var projector = Projector(); + await projector.HandleAsync(ZaakCreated()); + await projector.HandleAsync(StatusSet()); + + var entry = Assert.Single(await _store.AllAsync()); + Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); + Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status); + } + + [Fact] + public async Task rebuild_reproduces_the_approved_status() + { + var projector = Projector(); + await projector.HandleAsync(ZaakCreated()); + await projector.HandleAsync(StatusSet()); + + await projector.RebuildAsync(); + + var entry = Assert.Single(await _store.AllAsync()); + Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status); + } + [Fact] public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log() { -- 2.49.1 From 2cacedc46961c94a8e3a5f8f44e1e1e726ca03c8 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 17:02:39 +0200 Subject: [PATCH 08/11] feat(event-subscriber): project a status-set as INGESCHREVEN via hoofdObject (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind the NRC hoofdObject, recognise a zaken/status/create notification, and key the projection on the zaak (hoofdObject) so the status updates the existing INGEDIEND row to INGESCHREVEN — without reading OpenZaak (§8.1). Retain the ZGW resource in the log (new column + migration) so a rebuild reproduces the approved status. Updates the acceptance fakes for the new IAclClient/IZaakGateway members. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EventSubscriber.Api/Program.cs | 4 +- .../Notification.cs | 17 +++- .../NotificationProjector.cs | 16 ++-- .../EventSubscriber.Application/Ports.cs | 6 +- .../RegisterEntry.cs | 3 + .../Projection.ReadModel/EfNotificationLog.cs | 3 +- ...145944_AddNotificationResource.Designer.cs | 84 +++++++++++++++++++ .../20260713145944_AddNotificationResource.cs | 31 +++++++ .../ProjectionDbContextModelSnapshot.cs | 5 ++ .../ProjectionDbContext.cs | 6 ++ .../acceptance/Support/InMemoryDomainPorts.cs | 7 ++ .../acceptance/Support/InMemoryZaakGateway.cs | 7 ++ 12 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs create mode 100644 services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs diff --git a/services/event-subscriber/EventSubscriber.Api/Program.cs b/services/event-subscriber/EventSubscriber.Api/Program.cs index 1b8b981..ee27d70 100644 --- a/services/event-subscriber/EventSubscriber.Api/Program.cs +++ b/services/event-subscriber/EventSubscriber.Api/Program.cs @@ -54,9 +54,9 @@ await app.RunAsync(); /// The NRC notification body, as Open Notificaties POSTs it. Only the fields the /// projection needs are bound; aanmaakdatum/kenmerken are ignored for the minimal slice. -public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl) +public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null) { - public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); + public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject); } public partial class Program diff --git a/services/event-subscriber/EventSubscriber.Application/Notification.cs b/services/event-subscriber/EventSubscriber.Application/Notification.cs index 9e99b8f..c57884d 100644 --- a/services/event-subscriber/EventSubscriber.Application/Notification.cs +++ b/services/event-subscriber/EventSubscriber.Application/Notification.cs @@ -11,14 +11,23 @@ public sealed record Notification( string Kanaal, string Resource, string Actie, - Uri ResourceUrl) + Uri ResourceUrl, + Uri? HoofdObject = null) { - /// The only event the walking-skeleton projection reacts to: a zaak being created. + /// A zaak being created — projected as INGEDIEND. public bool IsZaakCreated => Kanaal == "zaken" && Resource == "zaak" && Actie == "create"; - /// The zaak UUID — the trailing path segment of the resource URL — used as the projection key. - public string ZaakId => ResourceUrl.Segments[^1].Trim('/'); + /// A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the + /// walking skeleton the only status ever set after creation is the approval, and the subscriber may + /// not read OpenZaak (§8.1), so any status-create is taken as the approval. + public bool IsZaakStatusSet => + Kanaal == "zaken" && Resource == "status" && Actie == "create"; + + /// The zaak UUID used as the projection key. For a status notification the resource URL is + /// the status, so the zaak comes from hoofdObject (which, for a zaak-create, equals the + /// resource URL) — see the NRC payload note. + public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/'); /// /// A deterministic dedup key. Open Notificaties carries no notification id and may diff --git a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs index 1610787..2779e5c 100644 --- a/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs +++ b/services/event-subscriber/EventSubscriber.Application/NotificationProjector.cs @@ -7,13 +7,15 @@ namespace EventSubscriber.Application; /// public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) { - /// Handle one inbound notification. Ignores anything that is not a zaak-created event. + /// Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a + /// status being set (INGESCHREVEN); ignores everything else. public async Task HandleAsync(Notification notification, CancellationToken ct = default) { - if (!notification.IsZaakCreated) + if (!notification.IsZaakCreated && !notification.IsZaakStatusSet) return; - var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId); + var recorded = new RecordedNotification( + notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource); // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // before it touches the projection, so the projection stays a faithful derived artefact. @@ -31,8 +33,10 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore await store.UpsertAsync(ToEntry(recorded), ct); } - /// The minimal projection row for an accepted notification. A zaak-create maps to - /// INGEDIEND; bsn/naam are deferred (ADR-0008). + /// The projection row for an accepted notification: a status-set maps to INGESCHREVEN, + /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008). private static RegisterEntry ToEntry(RecordedNotification recorded) - => new(recorded.ZaakId, RegistrationStatus.Ingediend); + => new(recorded.ZaakId, recorded.Resource == "status" + ? RegistrationStatus.Ingeschreven + : RegistrationStatus.Ingediend); } diff --git a/services/event-subscriber/EventSubscriber.Application/Ports.cs b/services/event-subscriber/EventSubscriber.Application/Ports.cs index 911ee68..a2a3cf6 100644 --- a/services/event-subscriber/EventSubscriber.Application/Ports.cs +++ b/services/event-subscriber/EventSubscriber.Application/Ports.cs @@ -19,8 +19,10 @@ public interface INotificationLog Task> AllAsync(CancellationToken ct = default); } -/// A notification that has been accepted, retaining what a rebuild needs to recompute its projection row. -public sealed record RecordedNotification(string Key, string Actie, string ZaakId); +/// A notification that has been accepted, retaining what a rebuild needs to recompute its +/// projection row — including the ZGW resource, which distinguishes a zaak-create (INGEDIEND) +/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status. +public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource); /// The read projection store. Owned by the projection bounded context (ADR-0008); the /// subscriber writes to it and the projection-api reads it. diff --git a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs index 3f342a3..de1eeb8 100644 --- a/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs +++ b/services/event-subscriber/EventSubscriber.Application/RegisterEntry.cs @@ -17,4 +17,7 @@ public static class RegistrationStatus { /// A zaak has been created; the registration is submitted. public const string Ingediend = "INGEDIEND"; + + /// The registration has been approved and entered in the register (S-09b). + public const string Ingeschreven = "INGESCHREVEN"; } diff --git a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs index 5a25bc1..d3d2a00 100644 --- a/services/projection-api/Projection.ReadModel/EfNotificationLog.cs +++ b/services/projection-api/Projection.ReadModel/EfNotificationLog.cs @@ -15,6 +15,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog Key = notification.Key, Actie = notification.Actie, ZaakId = notification.ZaakId, + Resource = notification.Resource, ReceivedAt = DateTimeOffset.UtcNow, }); @@ -34,6 +35,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog public async Task> AllAsync(CancellationToken ct = default) => await db.ProcessedNotifications .OrderBy(r => r.ReceivedAt) - .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId)) + .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource)) .ToListAsync(ct); } diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs new file mode 100644 index 0000000..858c7cf --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.Designer.cs @@ -0,0 +1,84 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Projection.ReadModel; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + [DbContext(typeof(ProjectionDbContext))] + [Migration("20260713145944_AddNotificationResource")] + partial class AddNotificationResource + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Actie") + .IsRequired() + .HasColumnType("text") + .HasColumnName("actie"); + + b.Property("ReceivedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("received_at"); + + b.Property("Resource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource"); + + b.Property("ZaakId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("zaak_id"); + + b.HasKey("Key"); + + b.ToTable("processed_notifications", (string)null); + }); + + modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Bsn") + .HasColumnType("text") + .HasColumnName("bsn"); + + b.Property("NaamPlaceholder") + .HasColumnType("text") + .HasColumnName("naam_placeholder"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id"); + + b.ToTable("register_projection", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs new file mode 100644 index 0000000..9f4456f --- /dev/null +++ b/services/projection-api/Projection.ReadModel/Migrations/20260713145944_AddNotificationResource.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Projection.ReadModel.Migrations +{ + /// + public partial class AddNotificationResource : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Pre-existing rows are all zaak-creates (the only notification the projector recorded + // before S-09b), so default the backfill to "zaak" rather than "". + migrationBuilder.AddColumn( + name: "resource", + table: "processed_notifications", + type: "text", + nullable: false, + defaultValue: "zaak"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "resource", + table: "processed_notifications"); + } + } +} diff --git a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs index 4bcd24c..39692a2 100644 --- a/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs +++ b/services/projection-api/Projection.ReadModel/Migrations/ProjectionDbContextModelSnapshot.cs @@ -37,6 +37,11 @@ namespace Projection.ReadModel.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("received_at"); + b.Property("Resource") + .IsRequired() + .HasColumnType("text") + .HasColumnName("resource"); + b.Property("ZaakId") .IsRequired() .HasColumnType("text") diff --git a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs index b260576..b826ccf 100644 --- a/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs +++ b/services/projection-api/Projection.ReadModel/ProjectionDbContext.cs @@ -35,6 +35,7 @@ public sealed class ProjectionDbContext(DbContextOptions op e.Property(r => r.Key).HasColumnName("key"); e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); + e.Property(r => r.Resource).HasColumnName("resource").IsRequired(); e.Property(r => r.ReceivedAt).HasColumnName("received_at"); }); } @@ -55,5 +56,10 @@ public sealed class ProcessedNotificationRow public required string Key { get; set; } public required string Actie { get; set; } public required string ZaakId { get; set; } + + /// The ZGW resource (e.g. zaak or status) — retained so a rebuild reprojects + /// the right status without reading OpenZaak (S-09b). + public required string Resource { get; set; } + public DateTimeOffset ReceivedAt { get; set; } } diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 3059aa7..7cc171e 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -26,12 +26,19 @@ 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 Uri? ApprovedZaakUrl { get; private set; } public Task OpenZaakAsync(string bsn, CancellationToken ct = default) { OpenedForBsn = bsn; return Task.FromResult(OpenedZaakUrl); } + + public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + { + ApprovedZaakUrl = zaakUrl; + return Task.CompletedTask; + } } /// An in-memory registration store for the domain acceptance scenario. diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index a1dde7b..683e0d7 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -11,10 +11,17 @@ public sealed class InMemoryZaakGateway : IZaakGateway public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123"); public ZaakRequest? Captured { get; private set; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; } public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { Captured = request; return Task.FromResult(CreatedZaakUrl); } + + public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } } -- 2.49.1 From d433bdee0e0ec3037b74440882428c54bb06e92c Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 17:03:50 +0200 Subject: [PATCH 09/11] test(e2e): approve a submitted registration and assert public INGESCHREVEN (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the walking-skeleton happy path: after the entry is publicly visible as INGEDIEND, approve it via the temporary admin endpoint (waiting for the zaak to be opened first), then poll the openbaar register until it shows INGESCHREVEN — covering submit → BFF → domain → ACL → NRC → projection → public visibility. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/e2e/registration.spec.ts | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 216bb0d..c2d604b 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -1,9 +1,10 @@ import { expect, test } from '@playwright/test'; -// Walking-skeleton happy path (S-08d + S-09): a zorgprofessional logs in via mock DigiD and submits a -// registration through the self-service portal → BFF → domain; the projection is updated via NRC → the -// event-subscriber, and the entry becomes publicly visible in the openbaar register portal. -test('DigiD login → submit → confirmation → public visibility', async ({ page }) => { +// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and +// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register +// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the +// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN. +test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => { // Visiting the guarded page redirects to the Keycloak (mock DigiD) login. await page.goto('/'); @@ -17,8 +18,11 @@ test('DigiD login → submit → confirmation → public visibility', async ({ p await page.getByRole('button', { name: /indienen/i }).click(); - // The BFF accepted it and the page shows the confirmation. - await expect(page.getByText(/ontvangen/i)).toBeVisible(); + // The BFF accepted it and the page shows the confirmation with the reference. + const confirmation = page.getByText(/ontvangen/i); + await expect(confirmation).toBeVisible(); + const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1]; + expect(reference, 'the confirmation shows a registration reference').toBeTruthy(); // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads @@ -31,4 +35,25 @@ test('DigiD login → submit → confirmation → public visibility', async ({ p return page.getByRole('cell', { name: 'INGEDIEND' }).count(); }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); + + // Approve via the temporary admin endpoint (reached directly on the compose network, as a + // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request + // path by the worker, so wait for it before approving. + await expect + .poll(async () => { + const res = await request.get(`http://domain:8080/registrations/${reference}`); + return res.ok() ? (await res.json()).zaakUrl : null; + }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) + .toBeTruthy(); + + const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`); + expect(approve.status()).toBe(204); + + // The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN. + await expect + .poll(async () => { + await page.reload(); + return page.getByRole('cell', { name: 'INGESCHREVEN' }).count(); + }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) + .toBeGreaterThan(0); }); -- 2.49.1 From 8badef02af515af0831e662614bf08ba2c0b431b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 17:05:12 +0200 Subject: [PATCH 10/11] docs(architecture): ADR-0011 approval status flow + demo note (refs #75) Records the two non-obvious decisions: the ACL resolves the zaaktype eindstatus (domain stays ZGW-ignorant), and the event-subscriber projects INGESCHREVEN from the notification alone (hoofdObject + any status-create), never reading OpenZaak. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adr-0011-approval-status-flow.md | 64 +++++++++++++++++++ docs/demo-script.md | 34 +++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr-0011-approval-status-flow.md diff --git a/docs/architecture/adr-0011-approval-status-flow.md b/docs/architecture/adr-0011-approval-status-flow.md new file mode 100644 index 0000000..b6e0441 --- /dev/null +++ b/docs/architecture/adr-0011-approval-status-flow.md @@ -0,0 +1,64 @@ +# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone + +- **Status:** Accepted +- **Date:** 2026-07-13 +- **Deciders:** Respellion engineering +- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker) + +## Context + +The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register, +but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly +visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a +**temporary admin endpoint** on the Domain Service. + +Two decisions are non-obvious (§14) and cross service boundaries: + +1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the + domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs. +2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over + NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an + NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does + not carry the statustype. + +## Decision + +**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the +notification's own fields on the read side.** + +- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a + repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then + advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it. +- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's + **eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and + POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW + translation (§8.1, ADR-0003). +- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on + it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created, + flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton + the only status ever set after creation is the approval — so it never has to read OpenZaak to learn + the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild + reproduces the right status. + +## Consequences + +- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW + statustype knowledge lives only in the ACL. +- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW + resource, which is all a rebuild needs to reproject the status. +- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval. +- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the + sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+), + the subscriber must distinguish statustypen. The honest options then are to carry the statustype + omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded + here so future-me revisits this rather than assuming it generalises. + +## Alternatives considered + +- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected: + couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus + discovery keeps the ACL self-contained for one extra ZGW GET per approval. +- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected: + violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up. +- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log + should retain notification *facts*, not projection semantics; the mapping stays in the projector. diff --git a/docs/demo-script.md b/docs/demo-script.md index 7874781..f178a00 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -195,5 +195,35 @@ curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view # → [ { "id": "", "status": "INGEDIEND" } ] ``` -> The register shows `INGEDIEND` entries today; the approval transition to a terminal status -> (e.g. `INGESCHREVEN`) lands with the approval flow (S-09b, #75). +> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below. + +--- + +## S-09b — Approval flow (public visibility flips to INGESCHREVEN) + +**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no +behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows +back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`. + +**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW +`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register. + +```bash +# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN: +make up +make verify-e2e + +# 2. Or by hand: submit (as in S-09), note the reference, then approve it. +# The zaak is opened off the request path, so approve once GET shows a zaakUrl. +ref="" +curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl +curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content + +# 3. The public register now shows the entry as approved. +curl -fsS http://localhost:8140/openbaar/register | jq +# → [ { "id": "", "status": "INGESCHREVEN" } ] +``` + +> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from +> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the +> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+). -- 2.49.1 From 236e7ade9c2ab3c793608c7aa59ffab7194337ce Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 09:30:02 +0200 Subject: [PATCH 11/11] test(acl,domain): cover the approval-flow mutants to hold the ratchet (refs #75) The new gateway/use-case code left surviving mutants (empty NoCoverage on the ACL gateway status-set, plus unasserted error messages, null-guards, and the persist call). Add stub-handler tests for SetZaakToEindstatusAsync (eindstatus resolution, POST framing, failure paths) and assert the approve use case's messages, null handling and SaveAsync. Local Stryker: acl 100%, domain 98.4%, event-subscriber 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 150 +++++++++++++++++- .../domain/Big.Tests/AclHttpClientTests.cs | 10 ++ .../Big.Tests/ApproveRegistrationTests.cs | 19 ++- .../domain/Big.Tests/RegistrationTests.cs | 3 +- 4 files changed, 178 insertions(+), 4 deletions(-) diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index b9f9099..8b9388e 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -131,8 +131,9 @@ public class OpenZaakGatewayTests Content = new StringContent("null", Encoding.UTF8, "application/json"), })); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => Gateway(handler).OpenZaakAsync(SampleRequest())); + Assert.Contains("empty zaak response", ex.Message); } [Fact] @@ -144,6 +145,153 @@ public class OpenZaakGatewayTests () => Gateway(handler).OpenZaakAsync(null!)); } + // --- SetZaakToEindstatusAsync (approval / S-09b) --- + + private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz"; + private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big"); + + private sealed class Recorder + { + public List Requests { get; } = []; + public List Bodies { get; } = []; + public List ContentLengths { get; } = []; + } + + // Routes the two calls the approval makes: GET statustypen (returns the given page) then POST statussen. + private static StubHandler ApprovalStub(Recorder rec, string statustypenJson, + HttpStatusCode getStatus = HttpStatusCode.OK, HttpStatusCode postStatus = HttpStatusCode.Created) + => new(async req => + { + rec.Requests.Add(req); + // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect, + // which would mask whether the gateway buffered it itself — mirrors the zaak-create tests). + rec.ContentLengths.Add(req.Content?.Headers.ContentLength); + rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync()); + if (req.Method == HttpMethod.Get) + return new HttpResponseMessage(getStatus) + { + Content = new StringContent(statustypenJson, Encoding.UTF8, "application/json"), + }; + return new HttpResponseMessage(postStatus) + { + Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/statussen/new" }), + }; + }); + + // Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the + // isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it. + private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new + { + results = new object[] + { + new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag }, + new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false }, + }, + }); + + [Fact] + public async Task Approving_queries_statustypen_then_posts_the_flagged_eindstatus_against_the_zaak() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true))) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + Assert.Equal(2, rec.Requests.Count); + + var get = rec.Requests[0]; + Assert.Equal(HttpMethod.Get, get.Method); + Assert.StartsWith("http://openzaak/catalogi/api/v1/statustypen", get.RequestUri!.ToString()); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), get.RequestUri.ToString()); + Assert.Equal("Bearer", get.Headers.Authorization!.Scheme); + + var post = rec.Requests[1]; + Assert.Equal(HttpMethod.Post, post.Method); + Assert.Equal("http://openzaak/zaken/api/v1/statussen", post.RequestUri!.ToString()); + Assert.Equal("Bearer", post.Headers.Authorization!.Scheme); + // The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer. + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", rec.Bodies[1]); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", rec.Bodies[1]); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", rec.Bodies[1]); + // The POST body is buffered (Content-Length set), so uwsgi doesn't get a chunked body. + Assert.NotNull(rec.ContentLengths[1]); + Assert.True(rec.ContentLengths[1] > 0); + } + + [Fact] + public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: false))) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + // No isEindstatus flag → the highest volgnummer (/2) is chosen. + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Bodies[1]); + } + + [Fact] + public async Task Approving_throws_when_the_zaaktype_has_no_statustypen() + { + var rec = new Recorder(); + + // A page with no `results` property (Results is null) — the eindstatus cannot be resolved. + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, "{}")) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("No statustypen found", ex.Message); + // It never posts a status when it cannot resolve the eindstatus. + Assert.Single(rec.Requests); + } + + [Fact] + public async Task Approving_throws_when_the_statustypen_response_is_empty() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, "null")) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("empty statustypen", ex.Message); + Assert.Single(rec.Requests); + } + + [Fact] + public async Task Approving_throws_when_the_statustypen_query_fails() + { + var rec = new Recorder(); + + await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, "{}", getStatus: HttpStatusCode.InternalServerError)) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + } + + [Fact] + public async Task Approving_throws_when_posting_the_status_fails() + { + var rec = new Recorder(); + + await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true), postStatus: HttpStatusCode.BadRequest)) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + // It resolved the eindstatus and attempted the POST before failing. + Assert.Equal(2, rec.Requests.Count); + } + + [Fact] + public async Task Approving_rejects_a_null_zaak_or_zaaktype() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + + await Assert.ThrowsAsync(() => + Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4))); + await Assert.ThrowsAsync(() => + Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4))); + } + // ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode. private static string DecodeSegment(string segment) { diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index 0fdfb48..71311ea 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -64,4 +64,14 @@ public class AclHttpClientTests await Assert.ThrowsAsync( () => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); } + + [Fact] + public async Task Approve_rejects_a_null_zaak_url_without_sending_a_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await Assert.ThrowsAsync(() => client.ApproveZaakAsync(null!)); + Assert.Null(capture.Seen); + } } diff --git a/services/domain/Big.Tests/ApproveRegistrationTests.cs b/services/domain/Big.Tests/ApproveRegistrationTests.cs index 2112f96..3c7ae28 100644 --- a/services/domain/Big.Tests/ApproveRegistrationTests.cs +++ b/services/domain/Big.Tests/ApproveRegistrationTests.cs @@ -27,6 +27,19 @@ public class ApproveRegistrationTests Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status); Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl); Assert.Equal(1, acl.ApproveCallCount); + // The approved aggregate is persisted (not just mutated in memory). + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Rejects_a_null_command_without_touching_the_store_or_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var handler = new ApproveRegistration(store, acl); + + await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); + Assert.Equal(0, acl.ApproveCallCount); } [Fact] @@ -36,8 +49,9 @@ public class ApproveRegistrationTests var acl = new FakeAclClient(); var handler = new ApproveRegistration(store, acl); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); + Assert.Contains("No registration", ex.Message); Assert.Equal(0, acl.ApproveCallCount); } @@ -50,8 +64,9 @@ public class ApproveRegistrationTests store.Seed(registration); var handler = new ApproveRegistration(store, acl); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); + Assert.Contains("no zaak", ex.Message); Assert.Equal(0, acl.ApproveCallCount); } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index c924f0b..b069ecc 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -117,7 +117,8 @@ public class RegistrationTests registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.Approve(); - Assert.Throws(() => registration.Approve()); + var ex = Assert.Throws(() => registration.Approve()); + Assert.Contains("only an INGEDIEND", ex.Message); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } } -- 2.49.1