From 1c185e6686a6c6b20f4d6d445c4d85814cd519d1 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 09:04:57 +0000 Subject: [PATCH] =?UTF-8?q?S-09b:=20Approval=20flow=20=E2=80=94=20temp=20a?= =?UTF-8?q?dmin=20endpoint=20+=20status=20transition=20to=20projection=20(?= =?UTF-8?q?#77)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar. ## Changes (bottom-up, each red→green TDD) - **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`. - **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test. - **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status. - **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN. - **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note. ## Key decisions (see ADR-0011) - **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant. - **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+). ## Verification - All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean. - No new compose config (the eindstatus-discovery approach avoided it). - The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally). closes #75 Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/77 --- .../adr-0011-approval-status-flow.md | 64 +++++ docs/demo-script.md | 34 ++- 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 | 109 +++++++++ .../Acl.IntegrationTests/OpenZaakFixture.cs | 37 +++ .../OpenZaakGatewayIntegrationTests.cs | 28 +++ services/acl/Acl.Tests/AclServiceTests.cs | 42 ++++ .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 226 +++++++++++++++++- services/domain/Big.Api/Program.cs | 13 + .../Big.Application/ApproveRegistration.cs | 36 +++ services/domain/Big.Application/Ports.cs | 6 + services/domain/Big.Domain/Registration.cs | 19 ++ .../domain/Big.Domain/RegistrationStatus.cs | 7 +- .../Big.Infrastructure/AclHttpClient.cs | 11 + .../domain/Big.Tests/AclHttpClientTests.cs | 33 +++ .../Big.Tests/ApproveRegistrationTests.cs | 89 +++++++ services/domain/Big.Tests/Fakes.cs | 10 + .../domain/Big.Tests/RegistrationTests.cs | 34 +++ .../EventSubscriber.Api/Program.cs | 4 +- .../Notification.cs | 17 +- .../NotificationProjector.cs | 16 +- .../EventSubscriber.Application/Ports.cs | 6 +- .../RegisterEntry.cs | 3 + .../NotificationProjectorTests.cs | 46 +++- .../Projection.ReadModel/EfNotificationLog.cs | 3 +- ...145944_AddNotificationResource.Designer.cs | 84 +++++++ .../20260713145944_AddNotificationResource.cs | 31 +++ .../ProjectionDbContextModelSnapshot.cs | 5 + .../ProjectionDbContext.cs | 6 + .../ServiceCollectionExtensions.cs | 38 ++- .../ProjectionApi.Api/Program.cs | 5 +- .../acceptance/Support/InMemoryDomainPorts.cs | 7 + .../acceptance/Support/InMemoryZaakGateway.cs | 7 + tests/e2e/registration.spec.ts | 37 ++- 36 files changed, 1109 insertions(+), 32 deletions(-) create mode 100644 docs/architecture/adr-0011-approval-status-flow.md create mode 100644 services/domain/Big.Application/ApproveRegistration.cs create mode 100644 services/domain/Big.Tests/ApproveRegistrationTests.cs 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/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+). 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..523f215 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -41,6 +41,92 @@ 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); + var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct); + + // 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(dto), + }; + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + 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 page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct); + 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); + } + + /// 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, @@ -50,4 +136,27 @@ 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); + + 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.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()); + } } 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); + } } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index b9f9099..7ca0271 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,229 @@ 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; } = []; + + 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]); + } + } + + // 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. + 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_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, new OzRoutes())) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + Assert.Equal(4, rec.Requests.Count); + + // 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()); + + // 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("\"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] + public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged() + { + var rec = new Recorder(); + + 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.Sent("/statussen").Body); + } + + [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, new OzRoutes { StatustypenJson = "{}" })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("No statustypen found", ex.Message); + // It never posts anything 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, new OzRoutes { StatustypenJson = "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(); + + 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] + public async Task Approving_throws_when_posting_the_status_fails() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + 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] + 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.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.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, } 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..71311ea 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -41,4 +41,37 @@ 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"))); + } + + [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 new file mode 100644 index 0000000..3c7ae28 --- /dev/null +++ b/services/domain/Big.Tests/ApproveRegistrationTests.cs @@ -0,0 +1,89 @@ +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); + // 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] + 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); + + var ex = await Assert.ThrowsAsync( + () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); + Assert.Contains("No registration", ex.Message); + 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); + + var ex = await Assert.ThrowsAsync( + () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); + Assert.Contains("no zaak", ex.Message); + 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; + } } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index a8463a0..b069ecc 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -87,4 +87,38 @@ 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(); + + var ex = Assert.Throws(() => registration.Approve()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } } 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/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() { 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/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"); 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; + } } 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); });