From 551f6e0a0be8077f940dfc0466717d6b969ce097 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 13 Jul 2026 16:45:36 +0200 Subject: [PATCH 01/13] =?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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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 From 80d0308de811af869f7792628970e522cec64bb2 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 10:03:14 +0200 Subject: [PATCH 12/13] fix(projection): serialise concurrent migrators with a pg advisory lock (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-up failed: the event-subscriber and projection-api both migrate the shared projection DB on start, and EF releases its migrations-history lock between individual migrations — harmless with one migration, but the new AddNotificationResource made a second, so a migrator re-applied it in the window between the other's two migrations ("column resource already exists"). Hold a session pg_advisory_lock across the whole MigrateAsync so the sequence runs exactly once; the second migrator then finds nothing pending. Verified by starting both images simultaneously against a fresh Postgres: both reach health, the resource column is created once, and __EFMigrationsHistory has both rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ServiceCollectionExtensions.cs | 38 ++++++++++++++++++- .../ProjectionApi.Api/Program.cs | 5 ++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs index 1779f21..d191dd8 100644 --- a/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs +++ b/services/projection-api/Projection.ReadModel/ServiceCollectionExtensions.cs @@ -19,12 +19,46 @@ public static class ServiceCollectionExtensions return services; } + // A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant). + private const long MigrationAdvisoryLockKey = 727501; + /// Apply any pending EF migrations. Called once on service start so a fresh stack - /// reaches a usable schema without a manual migration step (DoD: compose up reaches green). + /// reaches a usable schema without a manual migration step (DoD: compose up reaches green). + /// + /// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's + /// migrations-history lock is released between individual migrations, so with more than one pending + /// migration two migrators can interleave and one re-applies a migration the other just did + /// ("column already exists"). Hold a Postgres session pg_advisory_lock across the whole + /// sequence so it runs exactly once; the second migrator then finds nothing pending. public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default) { await using var scope = services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - await db.Database.MigrateAsync(ct); + var connection = db.Database.GetDbConnection(); + + await connection.OpenAsync(ct); + try + { + await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct); + try + { + await db.Database.MigrateAsync(ct); + } + finally + { + await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct); + } + } + finally + { + await connection.CloseAsync(); + } + } + + private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + await command.ExecuteNonQueryAsync(ct); } } diff --git a/services/projection-api/ProjectionApi.Api/Program.cs b/services/projection-api/ProjectionApi.Api/Program.cs index 74a24be..bc13277 100644 --- a/services/projection-api/ProjectionApi.Api/Program.cs +++ b/services/projection-api/ProjectionApi.Api/Program.cs @@ -10,8 +10,9 @@ builder.Services.AddProjectionReadModel(connectionString); var app = builder.Build(); -// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the -// migrations-history lock, so it is safe that the Event Subscriber migrates too. +// Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too; +// MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole +// migration sequence runs exactly once (see ServiceCollectionExtensions). await app.Services.MigrateProjectionAsync(); app.MapGet("/health", () => "Healthy"); -- 2.49.1 From 9089bd5e884d56cdf8f1d8c2a2e57217671f8ebe Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 10:39:19 +0200 Subject: [PATCH 13/13] fix(acl): set a resultaat before the eindstatus so OpenZaak accepts the status (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-set integration test hit 400 "resultaat-does-not-exist: Zaak has no resultaat" — OpenZaak refuses a zaak's eindstatus until the zaak has a resultaat. Resolve the zaaktype's resultaattype and POST /resultaten before POST /statussen. Also surface the ZGW response body in the failure exception (EnsureSuccessStatusCode hid the 400 detail that pinpointed this). Reworked the gateway with shared GetCatalogusAsync/PostAsync helpers and rewrote the stub-handler tests for the new 4-call flow incl. failure paths. Verified against real OpenZaak (integration test green) and locally: acl mutation 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 88 ++++++--- .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 174 +++++++++++++----- 2 files changed, 192 insertions(+), 70 deletions(-) diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 10a3c3f..523f215 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -47,40 +47,51 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : ArgumentNullException.ThrowIfNull(zaaktypeUrl); var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); + var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct); - using var message = new HttpRequestMessage( - HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen")) + // OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat + // ("resultaat-does-not-exist"), so record the resultaat first, then the status. + await PostAsync("/zaken/api/v1/resultaten", + new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct); + + await PostAsync("/zaken/api/v1/statussen", + // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC). + new StatusDto(zaakUrl.ToString(), eindstatus.ToString(), + datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")), + "Setting the zaak status", ct); + } + + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets + // a Content-Length instead of a chunked body (as with zaak-create). + private async Task PostAsync(string path, object dto, string action, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) { - 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"))), + Content = JsonContent.Create(dto), }; 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(); + await EnsureSuccessAsync(response, action, ct); + } + + // EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that + // is essential for diagnosing a rejected request, so surface it in the exception. + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}"); } /// 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 page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct); var results = page.Results ?? []; // OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the @@ -91,6 +102,31 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return new Uri(eindstatus.Url); } + /// Resolve the zaaktype's resultaattype from the catalogus (the seed defines one). + private async Task ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct) + { + var page = await GetCatalogusAsync("resultaattypen", zaaktypeUrl, "resultaattypen", ct); + var resultaattype = (page.Results ?? []).FirstOrDefault() + ?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}"); + return new Uri(resultaattype.Url); + } + + // GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published). + private async Task GetCatalogusAsync(string resource, Uri zaaktypeUrl, string label, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + $"/catalogi/api/v1/{resource}?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); + await EnsureSuccessAsync(response, $"Querying {label}", ct); + + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response"); + } + private sealed record ZaakDto( [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("zaaktype")] string Zaaktype, @@ -113,4 +149,14 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("volgnummer")] int Volgnummer, [property: JsonPropertyName("isEindstatus")] bool IsEindstatus); + + private sealed record ResultaatDto( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("resultaattype")] string Resultaattype); + + private sealed record ResultaattypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ResultaattypeDto( + [property: JsonPropertyName("url")] string Url); } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index 8b9388e..7ca0271 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -155,28 +155,50 @@ public class OpenZaakGatewayTests public List Requests { get; } = []; public List Bodies { get; } = []; public List ContentLengths { get; } = []; + + public int IndexOf(string pathContains) => + Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains)); + + // The (body, content-length, request) of the single request whose URL contains the segment. + public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains) + { + var i = IndexOf(pathContains); + return (Bodies[i], ContentLengths[i], Requests[i]); + } } - // 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" }), - }; - }); + // Per-route response config for the four calls the approval makes. + private sealed class OzRoutes + { + public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true); + public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}"""; + public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK; + public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK; + public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created; + public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created; + } + + // Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus), + // then POST /resultaten and POST /statussen (zaken). + private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req => + { + rec.Requests.Add(req); + // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect). + rec.ContentLengths.Add(req.Content?.Headers.ContentLength); + rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync()); + + var url = req.RequestUri!.ToString(); + if (req.Method == HttpMethod.Get && url.Contains("/statustypen")) + return Json(routes.StatustypenStatus, routes.StatustypenJson); + if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen")) + return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson); + if (url.Contains("/resultaten")) + return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}"""); + return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}"""); + }); + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; // 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. @@ -190,32 +212,41 @@ public class OpenZaakGatewayTests }); [Fact] - public async Task Approving_queries_statustypen_then_posts_the_flagged_eindstatus_against_the_zaak() + public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak() { var rec = new Recorder(); - await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true))) + await Gateway(ApprovalStub(rec, new OzRoutes())) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); - Assert.Equal(2, rec.Requests.Count); + Assert.Equal(4, 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); + // Both catalogus queries filter by the zaaktype and carry the bearer. + var statustypenGet = rec.Sent("/statustypen").Request; + Assert.Equal(HttpMethod.Get, statustypenGet.Method); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString()); + Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString()); - 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); + // OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen. + Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen")); + + var resultaat = rec.Sent("/resultaten"); + Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString()); + Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body); + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body); + Assert.True(resultaat.Length > 0); + + var status = rec.Sent("/statussen"); + Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString()); + Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body); // 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); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body); + // Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body. + Assert.True(status.Length > 0); } [Fact] @@ -223,11 +254,11 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: false))) + await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = 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]); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body); } [Fact] @@ -237,11 +268,11 @@ public class OpenZaakGatewayTests // A page with no `results` property (Results is null) — the eindstatus cannot be resolved. var ex = await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "{}")) + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" })) .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. + // It never posts anything when it cannot resolve the eindstatus. Assert.Single(rec.Requests); } @@ -251,7 +282,7 @@ public class OpenZaakGatewayTests var rec = new Recorder(); var ex = await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "null")) + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); Assert.Contains("empty statustypen", ex.Message); @@ -263,9 +294,53 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "{}", getStatus: HttpStatusCode.InternalServerError)) + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Querying statustypen", ex.Message); + } + + [Fact] + public async Task Approving_throws_when_the_resultaattypen_query_fails() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Querying resultaattypen", ex.Message); + Assert.Equal(-1, rec.IndexOf("/resultaten")); + } + + [Fact] + public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("No resultaattypen found", ex.Message); + // Resolved the eindstatus + queried resultaattypen, but posted nothing. + Assert.Equal(-1, rec.IndexOf("/resultaten")); + Assert.Equal(-1, rec.IndexOf("/statussen")); + } + + [Fact] + public async Task Approving_throws_when_posting_the_resultaat_fails() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Setting the zaak resultaat", ex.Message); + // The status is never posted if the resultaat could not be recorded. + Assert.Equal(-1, rec.IndexOf("/statussen")); } [Fact] @@ -273,12 +348,13 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true), postStatus: HttpStatusCode.BadRequest)) + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = 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); + Assert.Contains("Setting the zaak status", ex.Message); + // It got as far as the resultaat + the status POST (4 calls) before failing. + Assert.Equal(4, rec.Requests.Count); } [Fact] -- 2.49.1