From 06c04448592620f73436210ca5f4d984f2cc7ff5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 28 Aug 2026 12:28:01 +0200 Subject: [PATCH] test(acl): submit writes an INGEDIEND record, and records are readable back (refs #153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports and failing tests for the ACL half of S-19b-2, ahead of the implementation. Once the projection is sourced from Objecten (ADR-0028's stated direction), a submitted registration has to exist in the register the moment the zaak is opened — otherwise re-sourcing silently drops every INGEDIEND row, since today only approval writes a record. So `OpenZaakAsync` gains a second write, and approval upserts that same record to INGESCHREVEN. The subscriber gets only an object URL on an `objecten` notification (the payload carries no record data) and may not read Objecten itself (§8.1), so `IRegisterRecordGateway` gains a read and `AclService` exposes it. Red: - AclService does not yet write on open → the record assertion fails on an empty list. - ObjectenGateway.GetAsync is a shell throwing NotImplementedException; its tests pin the contract: fetch the object URL directly (no objecttype resolution, no search), the CRS header a geo API requires, static Token auth, and a 404 read as "nothing to project" rather than an error (§8.6). --- services/acl/Acl.Application/AclService.cs | 12 ++++ .../Acl.Application/IRegisterRecordGateway.cs | 8 +++ .../acl/Acl.Infrastructure/ObjectenGateway.cs | 3 + services/acl/Acl.Tests/AclServiceTests.cs | 56 +++++++++++++++++++ .../acl/Acl.Tests/ObjectenGatewayTests.cs | 37 ++++++++++++ .../acceptance/Support/InMemoryZaakGateway.cs | 4 ++ 6 files changed, 120 insertions(+) diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index cab5577..f3b7973 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -52,6 +52,18 @@ public sealed class AclService( ct); } + /// + /// The register record held by an object in Objecten, for the Event Subscriber (S-19b-2). The + /// subscriber gets only an object URL on the notification and may not read Objecten itself + /// (§8.1, ADR-0028), so the ACL reads it back. + /// + public Task GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(objectUrl); + + return register.GetAsync(objectUrl, ct); + } + /// The zaak's UUID — the key the register record and the read projection rows share. private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/'); diff --git a/services/acl/Acl.Application/IRegisterRecordGateway.cs b/services/acl/Acl.Application/IRegisterRecordGateway.cs index 4540861..6e8d4bd 100644 --- a/services/acl/Acl.Application/IRegisterRecordGateway.cs +++ b/services/acl/Acl.Application/IRegisterRecordGateway.cs @@ -13,6 +13,14 @@ public interface IRegisterRecordGateway /// the existing object instead of creating a second one (§8.6). /// Task UpsertAsync(RegisterRecord record, CancellationToken ct = default); + + /// + /// The register record held by the object at , or null if that + /// object holds none. The Event Subscriber projects a register write from the notification NRC + /// delivers, which carries only the object URL — so it reads the record back through the ACL + /// rather than talking to Objecten itself (§8.1, S-19b-2). + /// + Task GetAsync(Uri objectUrl, CancellationToken ct = default); } /// diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index aa43e68..80aa715 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -38,6 +38,9 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC "Updating the register record", ct); } + public Task GetAsync(Uri objectUrl, CancellationToken ct = default) + => throw new NotImplementedException(); + private RecordDto NewRecord(int typeVersion, RecordDataDto data) => new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd")); diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 158be35..52e1d61 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -79,11 +79,21 @@ public class AclServiceTests { public readonly List Upserted = []; + public RegisterRecord? Stored; + + public Uri? ReadFrom; + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) { Upserted.Add(record); return Task.CompletedTask; } + + public Task GetAsync(Uri objectUrl, CancellationToken ct = default) + { + ReadFrom = objectUrl; + return Task.FromResult(Stored); + } } private static AclDefaults Defaults() => new() @@ -130,6 +140,52 @@ public class AclServiceTests Assert.Equal("reg-77", req.Identificatie); } + [Fact] + public async Task Opening_a_zaak_also_writes_an_ingediend_register_record(/* S-19b-2 */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); + + // The register — not ZGW — is what the read projection is sourced from (ADR-0028), so a + // submitted registration has to exist there the moment the zaak is opened, not only on + // approval. Approval upserts this same record to INGESCHREVEN. + var record = Assert.Single(register.Upserted); + Assert.Equal("abc", record.Id); + Assert.Equal("INGEDIEND", record.Status); + // The reference comes from the registration itself — no ZGW read-back needed on this path. + Assert.Equal("reg-77", record.Reference); + } + + [Fact] + public async Task Reading_a_register_record_goes_through_the_objecten_gateway(/* S-19b-2 */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway { Stored = new RegisterRecord("abc", "INGESCHREVEN", "reg-77") }; + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + var objectUrl = new Uri("http://objecten.local:8000/api/v2/objects/9de4a2ca"); + + var record = await service.GetRegisterRecordAsync(objectUrl); + + Assert.Equal(objectUrl, register.ReadFrom); + Assert.Equal("abc", record!.Id); + Assert.Equal("INGESCHREVEN", record.Status); + Assert.Equal("reg-77", record.Reference); + } + + [Fact] + public async Task Reading_a_register_record_from_a_null_url_is_rejected(/* S-19b-2 */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await Assert.ThrowsAsync(() => service.GetRegisterRecordAsync(null!)); + Assert.Null(register.ReadFrom); + } + [Fact] public async Task Opening_a_zaak_reflects_a_default_fill_update(/* S-15b */) { diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index 60c3a6f..d095764 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -83,6 +83,43 @@ public class ObjectenGatewayTests private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); + [Fact] + public async Task Reads_a_register_record_back_from_its_object_url(/* S-19b-2 */) + { + var sent = new List(); + var objectUrl = new Uri("http://objecten:8000/api/v2/objects/obj-9"); + var gateway = Gateway(sent, _ => Json(new + { + url = objectUrl.ToString(), + record = new { data = new { id = "zaak-uuid-1", status = "INGESCHREVEN", reference = "REG-2026-0001" } }, + })); + + var record = await gateway.GetAsync(objectUrl); + + // The object is fetched directly by the URL the notification carried — no objecttype + // resolution and no search, unlike a write. + var read = Assert.Single(sent); + Assert.Equal(HttpMethod.Get, read.Method); + Assert.Equal(objectUrl, read.Uri); + // Objecten is a geo API: the CRS header is required on reads too. + Assert.Equal("EPSG:4326", read.AcceptCrs); + Assert.Equal("Token objecten-token", read.Auth); + Assert.Equal("zaak-uuid-1", record!.Id); + Assert.Equal("INGESCHREVEN", record.Status); + Assert.Equal("REG-2026-0001", record.Reference); + } + + [Fact] + public async Task Reading_an_object_that_is_gone_yields_no_record(/* S-19b-2 */) + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.NotFound)); + + // A record deleted between the notification and the read is not an error — there is simply + // nothing to project (§8.6: the subscriber tolerates whatever order deliveries arrive in). + Assert.Null(await gateway.GetAsync(new Uri("http://objecten:8000/api/v2/objects/gone"))); + } + [Fact] public async Task Creates_the_object_when_none_exists_for_the_registration() { diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index 71142fc..8c3b6c1 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -65,4 +65,8 @@ public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway Upserted.Add(record); return Task.CompletedTask; } + + /// The most recently written record — scenarios never read one back by object URL. + public Task GetAsync(Uri objectUrl, CancellationToken ct = default) + => Task.FromResult(Upserted.Count == 0 ? null : Upserted[^1]); }