From 66f832258005dc4d10c3445d55ebfbe910052e44 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:16:23 +0200 Subject: [PATCH] test(acl): approval writes the RegisterRecord to Objecten (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports and failing tests for the Objecten hop, ahead of the implementation: - IRegisterRecordGateway + RegisterRecord — the Application-side port; the record mirrors the objecttype schema registered in S-18c (ADR-0027). - AclService takes the port but does not yet call it, so the approval test fails on an empty upsert list. - ObjectenGateway is a shell throwing NotImplementedException; its tests pin the contract: resolve the objecttype by name, search by data attribute, POST when absent / PATCH when present, static Token auth per API, the CRS headers the geo API requires, and a surfaced error body. Also splits S-19 (#20) into #149/#150 in BACKLOG.md — the approval-side write and the projection re-sourcing are independently deployable (CLAUDE.md §13). --- BACKLOG.md | 7 +- services/acl/Acl.Application/AclService.cs | 7 +- .../Acl.Application/IRegisterRecordGateway.cs | 30 +++ .../acl/Acl.Infrastructure/ObjectenGateway.cs | 10 + .../acl/Acl.Infrastructure/ObjectenOptions.cs | 20 ++ services/acl/Acl.Tests/AclServiceTests.cs | 50 ++++- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 184 ++++++++++++++++++ tests/acceptance/Steps/EenZaakOpenenSteps.cs | 2 +- .../acceptance/Support/InMemoryZaakGateway.cs | 13 ++ 9 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 services/acl/Acl.Application/IRegisterRecordGateway.cs create mode 100644 services/acl/Acl.Infrastructure/ObjectenGateway.cs create mode 100644 services/acl/Acl.Infrastructure/ObjectenOptions.cs create mode 100644 services/acl/Acl.Tests/ObjectenGatewayTests.cs diff --git a/BACKLOG.md b/BACKLOG.md index 476d485..8a5ddbe 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -287,12 +287,17 @@ Split into independently deployable sub-slices (CLAUDE.md §13): - **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a. - **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b. -### S-19 · ACL extension: write register-record to Objecten on approval +### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)* **Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events. **ADR required:** "Why Objecten holds the register, OpenZaak holds the process." +Split into independently deployable sub-slices (CLAUDE.md §13): + +- **S-19a** (#149) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR. +- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. + --- ## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)* diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 28e83f9..37f2950 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,7 +2,12 @@ namespace Acl.Application; /// The ACL's single operation: open a zaak from a domain payload, /// default-filling the ZGW-mandatory fields (ADR-0003). -public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock) +public sealed class AclService( + IZaakGateway gateway, + IRegisterRecordGateway register, + IDefaultFillStore fill, + IZaaktypeCatalog catalog, + IClock clock) { public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { diff --git a/services/acl/Acl.Application/IRegisterRecordGateway.cs b/services/acl/Acl.Application/IRegisterRecordGateway.cs new file mode 100644 index 0000000..4540861 --- /dev/null +++ b/services/acl/Acl.Application/IRegisterRecordGateway.cs @@ -0,0 +1,30 @@ +namespace Acl.Application; + +/// +/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028). +/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the +/// upstream Common Ground module (§8.1). +/// +public interface IRegisterRecordGateway +{ + /// + /// Write the register record for a registration, creating it if absent and updating it if it + /// already exists. Idempotent on : a replayed approval updates + /// the existing object instead of creating a second one (§8.6). + /// + Task UpsertAsync(RegisterRecord record, CancellationToken ct = default); +} + +/// +/// The public-safe register record, matching the RegisterRecord objecttype schema registered +/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable. +/// +public sealed record RegisterRecord(string Id, string Status, string? Reference); + +/// The register statuses the RegisterRecord objecttype's schema allows (ADR-0027). +public static class RegisterRecordStatus +{ + public const string Ingediend = "INGEDIEND"; + + public const string Ingeschreven = "INGESCHREVEN"; +} diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs new file mode 100644 index 0000000..50fc445 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -0,0 +1,10 @@ +using Acl.Application; + +namespace Acl.Infrastructure; + +/// The only code that talks to the Objecten API (ADR-0028). +public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway +{ + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) => + throw new NotImplementedException(); +} diff --git a/services/acl/Acl.Infrastructure/ObjectenOptions.cs b/services/acl/Acl.Infrastructure/ObjectenOptions.cs new file mode 100644 index 0000000..9e46d13 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenOptions.cs @@ -0,0 +1,20 @@ +namespace Acl.Infrastructure; + +/// +/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a +/// static Authorization: Token … (they are not ZGW JWT APIs), so there is no client-id/secret +/// pair as with OpenZaak. +/// +public sealed class ObjectenOptions +{ + public required Uri BaseUrl { get; init; } + public required string Token { get; init; } + + /// Objecttypen API root — the ACL resolves the objecttype URL + version from it by name + /// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021). + public required Uri ObjecttypenBaseUrl { get; init; } + public required string ObjecttypenToken { get; init; } + + /// The objecttype the register record is written as (S-18c registers "RegisterRecord"). + public required string ObjecttypeName { get; init; } +} diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 6b726f8..158be35 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -75,6 +75,17 @@ public class AclServiceTests Task.FromResult(Zaaktypen); } + private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway + { + public readonly List Upserted = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } + } + private static AclDefaults Defaults() => new() { Bronorganisatie = "517439943", @@ -88,7 +99,10 @@ public class AclServiceTests new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) => - new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); + ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today); + + private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) => + new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); private sealed class FixedClock(DateOnly today) : IClock { @@ -161,10 +175,42 @@ public class AclServiceTests public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway() { var gateway = new FakeGateway(); - var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); + Assert.Empty(register.Upserted); + } + + [Fact] + public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + var record = Assert.Single(register.Upserted); + // The record is keyed on the zaak id — the same key the read projection rows carry (S-19b). + Assert.Equal("abc", record.Id); + Assert.Equal("INGESCHREVEN", record.Status); + // The public-safe reference comes from the zaak's identificatie, never from the domain payload. + Assert.Equal("REG-FROM-ZAAK", record.Reference); + } + + [Fact] + public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + // Only an approval enters the register; a cancelled zaak never becomes a register record. + Assert.Empty(register.Upserted); } [Fact] diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs new file mode 100644 index 0000000..bbedc84 --- /dev/null +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -0,0 +1,184 @@ +using System.Net; +using System.Net.Http.Json; +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.Tests; + +public class ObjectenGatewayTests +{ + private sealed class StubHandler(Func> onSend) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); + } + + private sealed class FixedClock(DateOnly today) : IClock + { + public DateOnly Today { get; } = today; + } + + private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs); + + private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; + + private static ObjectenGateway Gateway(List sent, Func respond) => + new( + new HttpClient(new StubHandler(async req => + { + sent.Add(new Sent( + req.Method, + req.RequestUri!, + req.Content is null ? null : await req.Content.ReadAsStringAsync(), + req.Headers.Authorization?.ToString(), + req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, + req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + return respond(req); + })), + new ObjectenOptions + { + BaseUrl = new("http://objecten:8000"), + Token = "objecten-token", + ObjecttypenBaseUrl = new("http://objecttypen:8000"), + ObjecttypenToken = "objecttypen-token", + ObjecttypeName = "RegisterRecord", + }, + new FixedClock(new DateOnly(2026, 6, 4))); + + // The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every + // write. `respondToWrite` decides what the POST/PATCH returns. + private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => + req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new + { + results = new[] + { + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } }, + new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } }, + }, + }), + } + : req.Method == HttpMethod.Get + ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) } + : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + + private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); + + [Fact] + public async Task Creates_the_object_when_none_exists_for_the_registration() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body); + Assert.Contains("\"typeVersion\":2", write.Body); + Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body); + Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body); + } + + [Fact] + public async Task Updates_the_existing_object_instead_of_creating_a_second_one() + { + var sent = new List(); + object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }]; + + await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record()); + + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + var write = sent.Single(s => s.Method == HttpMethod.Patch); + Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString()); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + } + + [Fact] + public async Task Searches_objecten_for_the_registration_id_within_the_objecttype() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query); + Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query); + } + + [Fact] + public async Task Authenticates_with_the_static_token_of_each_api() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Equal("Token objecttypen-token", s.Auth)); + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)), + s => Assert.Equal("Token objecten-token", s.Auth)); + } + + [Fact] + public async Task Sends_the_geo_crs_headers_the_objecten_api_requires() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList(); + Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs)); + Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs)); + } + + [Fact] + public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes() + { + var sent = new List(); + var gateway = Gateway(sent, req => Route(req, [])); + + await gateway.UpsertAsync(Record()); + await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); + + Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_is_not_registered() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { results = Array.Empty() }), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("schema mismatch", error.Message); + } + + [Fact] + public async Task Rejects_a_null_record_without_calling_objecten() + { + var sent = new List(); + + await Assert.ThrowsAsync(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!)); + Assert.Empty(sent); + } +} diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index b65a04f..f8c95c9 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -46,7 +46,7 @@ public sealed class EenZaakOpenenSteps { var fill = new InMemoryDefaultFillStore(new DefaultFillSettings( _defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding)); - var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); + var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); _returnedUrl = await service.OpenZaakAsync(_registration!); } diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 99b6ce0..71142fc 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -53,3 +53,16 @@ public sealed class InMemoryZaakGateway : IZaakGateway => Task.FromResult>( [new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]); } + +/// An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL +/// writes on approval, so a scenario can assert on them without a running Objecten. +public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway +{ + public List Upserted { get; } = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } +}