From 0ba410ad78b5b93db3b153ae399a93625a660320 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Wed, 22 Jul 2026 15:39:01 +0200 Subject: [PATCH] feat(acl): resolve zaaktype + informatieobjecttype by business key, not a pinned URL (refs #113) The ACL now discovers its BIG zaaktype (by identificatie) and diploma informatieobjecttype (by omschrijving) from OpenZaak's Catalogi API, instead of being handed server-assigned URLs in config. A CachedZaaktypeCatalog resolves lazily on first use and caches (success only, so a pre-publish miss is retried); AclDefaults now carries ZaaktypeIdentificatie/InformatieobjecttypeOmschrijving. Clear errors replace the opaque placeholder-URL 400. Unit tests cover the resolver (resolve/cache/retry-on-failure) and the gateway lookups (match/miss). Co-Authored-By: Claude Opus 4.8 (1M context) --- services/acl/Acl.Api/Program.cs | 2 + services/acl/Acl.Application/AclDefaults.cs | 11 ++- services/acl/Acl.Application/AclService.cs | 30 +++--- .../Acl.Application/CachedZaaktypeCatalog.cs | 46 ++++++++++ services/acl/Acl.Application/IZaakGateway.cs | 8 ++ .../acl/Acl.Application/IZaaktypeCatalog.cs | 12 +++ .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 56 +++++++++++ services/acl/Acl.Tests/AclServiceTests.cs | 82 +++++++++-------- .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 88 ++++++++++++++++++ .../acl/Acl.Tests/ZaaktypeCatalogTests.cs | 92 +++++++++++++++++++ 10 files changed, 370 insertions(+), 57 deletions(-) create mode 100644 services/acl/Acl.Application/CachedZaaktypeCatalog.cs create mode 100644 services/acl/Acl.Application/IZaaktypeCatalog.cs create mode 100644 services/acl/Acl.Tests/ZaaktypeCatalogTests.cs diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 1b1bf7f..22cceee 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -11,6 +11,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() .GetSection("Acl:OpenZaak").Get() ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); builder.Services.AddHttpClient(); +// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). +builder.Services.AddSingleton(); builder.Services.AddScoped(); var app = builder.Build(); diff --git a/services/acl/Acl.Application/AclDefaults.cs b/services/acl/Acl.Application/AclDefaults.cs index d378c8c..7874d03 100644 --- a/services/acl/Acl.Application/AclDefaults.cs +++ b/services/acl/Acl.Application/AclDefaults.cs @@ -6,9 +6,12 @@ public sealed class AclDefaults public required string Bronorganisatie { get; init; } public required string VerantwoordelijkeOrganisatie { get; init; } public required string Vertrouwelijkheidaanduiding { get; init; } - public required Uri ZaaktypeUrl { get; init; } - /// The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the - /// catalogus and injected like . - public required Uri InformatieobjecttypeUrl { get; init; } + /// The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype + /// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021). + public required string ZaaktypeIdentificatie { get; init; } + + /// The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b); + /// resolved to a URL by the Catalogi API, like . + public required string InformatieobjecttypeOmschrijving { get; init; } } diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 6f00fef..4b23964 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,9 +2,9 @@ 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, AclDefaults defaults, IClock clock) +public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock) { - public Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) + public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(registration); @@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc defaults.Bronorganisatie, defaults.VerantwoordelijkeOrganisatie, defaults.Vertrouwelijkheidaanduiding, - defaults.ZaaktypeUrl, + await catalog.GetZaaktypeUrlAsync(ct), clock.Today, registration.Reference); - return gateway.OpenZaakAsync(request, ct); + return await 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). + /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). + /// 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) + public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); - return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); } /// - /// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's - /// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which + /// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation + /// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which /// statustype/resultaat means "cancelled" (§8.1). /// - public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); - return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); } /// The zaak's reference (its ZGW identificatie), for the read projection (#78). @@ -56,7 +56,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc /// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak. /// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1). /// - public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) + public async Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); ArgumentNullException.ThrowIfNull(content); @@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc var request = new DocumentRequest( defaults.Bronorganisatie, - defaults.InformatieobjecttypeUrl, + await catalog.GetInformatieobjecttypeUrlAsync(ct), defaults.Vertrouwelijkheidaanduiding, zaakUrl, clock.Today, @@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc Formaat: contentType, Inhoud: content); - return gateway.StoreDocumentAsync(request, ct); + return await gateway.StoreDocumentAsync(request, ct); } } diff --git a/services/acl/Acl.Application/CachedZaaktypeCatalog.cs b/services/acl/Acl.Application/CachedZaaktypeCatalog.cs new file mode 100644 index 0000000..bc9b293 --- /dev/null +++ b/services/acl/Acl.Application/CachedZaaktypeCatalog.cs @@ -0,0 +1,46 @@ +namespace Acl.Application; + +/// Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first +/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL +/// never crash-loops when it boots before the catalogus is seeded/published; a failed +/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published). +/// A process restart re-resolves. +public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog +{ + private readonly SemaphoreSlim gate = new(1, 1); + private Uri? zaaktype; + private Uri? informatieobjecttype; + + public Task GetZaaktypeUrlAsync(CancellationToken ct = default) => + ResolveOnceAsync( + () => zaaktype, value => zaaktype = value, + () => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct); + + public Task GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) => + ResolveOnceAsync( + () => informatieobjecttype, value => informatieobjecttype = value, + () => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct); + + // Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the + // gate and cache only on success (a throw leaves the cache empty so the next call retries). + private async Task ResolveOnceAsync(Func read, Action store, Func> resolve, CancellationToken ct) + { + if (read() is { } cached) + return cached; + + await gate.WaitAsync(ct); + try + { + if (read() is { } existing) + return existing; + + var resolved = await resolve(); + store(resolved); + return resolved; + } + finally + { + gate.Release(); + } + } +} diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 1e71337..3848d8b 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -32,4 +32,12 @@ public interface IZaakGateway /// the created informatieobject. /// Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default); + + /// Resolve the URL of the published zaaktype with the given + /// from the Catalogi API (S-27). Throws if no published zaaktype matches. + Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default); + + /// Resolve the URL of the published informatieobjecttype with the given + /// from the Catalogi API (S-27). Throws if none matches. + Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default); } diff --git a/services/acl/Acl.Application/IZaaktypeCatalog.cs b/services/acl/Acl.Application/IZaaktypeCatalog.cs new file mode 100644 index 0000000..2394c2b --- /dev/null +++ b/services/acl/Acl.Application/IZaaktypeCatalog.cs @@ -0,0 +1,12 @@ +namespace Acl.Application; + +/// Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's +/// Catalogi API by their stable business keys ( / +/// ) rather than pinned in config (S-27, +/// ADR-0021). Implementations resolve lazily on first use and cache the result. +public interface IZaaktypeCatalog +{ + Task GetZaaktypeUrlAsync(CancellationToken ct = default); + + Task GetInformatieobjecttypeUrlAsync(CancellationToken ct = default); +} diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 5f33ba8..156b810 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -142,6 +142,48 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return created; } + public async Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(identificatie); + + // The published zaaktype with this identificatie; status=definitief excludes concepts. + var page = await GetAsync( + "/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie), + "zaaktypen", ct); + var match = (page.Results ?? []).FirstOrDefault() + ?? throw new InvalidOperationException( + $"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?"); + return new Uri(match.Url); + } + + public async Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving); + + // The informatieobjecttypen collection has no omschrijving filter, so match client-side over the + // published ones. + var page = await GetAsync( + "/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct); + var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving) + ?? throw new InvalidOperationException( + $"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?"); + return new Uri(match.Url); + } + + // GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API). + private async Task GetAsync(string pathAndQuery, string label, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery)); + 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"); + } + // 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) @@ -298,4 +340,18 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record ZaakInformatieobjectDto( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("informatieobject")] string Informatieobject); + + private sealed record ZaaktypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ZaaktypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("identificatie")] string? Identificatie); + + private sealed record InformatieobjecttypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record InformatieobjecttypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("omschrijving")] string? Omschrijving); } diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 01cbcef..930d380 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -6,6 +6,12 @@ public class AclServiceTests { private sealed class FakeGateway : IZaakGateway { + // The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27). + public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big"); + public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"); + public string? ResolvedByIdentificatie; + public string? ResolvedByOmschrijving; + public ZaakRequest? Captured; public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); @@ -47,6 +53,18 @@ public class AclServiceTests StoredDocument = request; return Task.FromResult(DocumentResult); } + + public Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + { + ResolvedByIdentificatie = identificatie; + return Task.FromResult(ResolvedZaaktype); + } + + public Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + { + ResolvedByOmschrijving = omschrijving; + return Task.FromResult(ResolvedInformatieobjecttype); + } } private static AclDefaults Defaults() => new() @@ -54,28 +72,23 @@ public class AclServiceTests Bronorganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943", Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), + ZaaktypeIdentificatie = "BIG-REGISTRATIE", + InformatieobjecttypeOmschrijving = "Diploma", }; + private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) => + new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); + private sealed class FixedClock(DateOnly today) : IClock { public DateOnly Today { get; } = today; } [Fact] - public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url() + public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype() { var gateway = new FakeGateway(); - var defaults = new AclDefaults - { - Bronorganisatie = "517439943", - VerantwoordelijkeOrganisatie = "517439943", - Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), - }; - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); @@ -84,7 +97,9 @@ public class AclServiceTests Assert.Equal("517439943", req.Bronorganisatie); Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); - Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); + // The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27). + Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie); + Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); // The registration reference becomes the zaak identificatie (#78). Assert.Equal("reg-77", req.Identificatie); @@ -94,33 +109,24 @@ public class AclServiceTests public async Task Rejects_a_null_registration_without_calling_the_gateway() { var gateway = new FakeGateway(); - var defaults = new AclDefaults - { - Bronorganisatie = "517439943", - VerantwoordelijkeOrganisatie = "517439943", - Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), - }; - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); 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() + public async Task Approving_a_zaak_sets_it_to_its_resolved_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 service = ServiceWith(gateway, Defaults(), 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(gateway.ResolvedZaaktype, gateway.Approved.Value.Zaaktype); Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum); } @@ -128,7 +134,7 @@ public class AclServiceTests 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))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); @@ -138,15 +144,14 @@ public class AclServiceTests public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today() { var gateway = new FakeGateway(); - var defaults = Defaults(); - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); await service.CancelZaakAsync(zaak); Assert.NotNull(gateway.Cancelled); Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak); - Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype); + Assert.Equal(gateway.ResolvedZaaktype, gateway.Cancelled.Value.Zaaktype); Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum); // Cancellation must not touch the approval path. Assert.Null(gateway.Approved); @@ -156,18 +161,17 @@ public class AclServiceTests public async Task Cancelling_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))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.CancelZaakAsync(null!)); Assert.Null(gateway.Cancelled); } [Fact] - public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url() + public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype() { var gateway = new FakeGateway(); - var defaults = Defaults(); - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf"); @@ -175,7 +179,9 @@ public class AclServiceTests Assert.Equal(gateway.DocumentResult, url); var req = gateway.StoredDocument!; Assert.Equal(zaak, req.Zaak); - Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype); + // The informatieobjecttype is resolved from the configured omschrijving (S-27). + Assert.Equal("Diploma", gateway.ResolvedByOmschrijving); + Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype); Assert.Equal("517439943", req.Bronorganisatie); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum); @@ -188,7 +194,7 @@ public class AclServiceTests [Fact] public async Task Storing_a_diploma_rejects_null_or_blank_arguments() { - var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(new FakeGateway(), Defaults(), new DateOnly(2026, 6, 4)); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); await Assert.ThrowsAsync(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf")); @@ -201,7 +207,7 @@ public class AclServiceTests public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie() { var gateway = new FakeGateway(); - var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); var reference = await service.GetZaakReferenceAsync(zaak); @@ -214,7 +220,7 @@ public class AclServiceTests public async Task Reading_a_null_zaak_reference_is_rejected() { var gateway = new FakeGateway(); - var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.GetZaakReferenceAsync(null!)); Assert.Null(gateway.ReadReferenceFor); diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index f167f75..fb750aa 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -675,4 +675,92 @@ public class OpenZaakGatewayTests await Assert.ThrowsAsync(() => Gateway(handler).StoreDocumentAsync(null!)); } + + // ── Catalogi resolution by business key (S-27) ──────────────────────────────────────────────── + + [Fact] + public async Task Resolves_the_published_zaaktype_url_by_identificatie() + { + HttpRequestMessage? seen = null; + var handler = new StubHandler(req => + { + seen = req; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new + { + results = new[] { new { url = "http://openzaak/catalogi/api/v1/zaaktypen/big", identificatie = "BIG-REGISTRATIE" } }, + }), + }); + }); + + var url = await Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"); + + Assert.Equal("http://openzaak/catalogi/api/v1/zaaktypen/big", url.ToString()); + Assert.Equal(HttpMethod.Get, seen!.Method); + // Filters to the published zaaktype with that identificatie, and authenticates. + Assert.Contains("/catalogi/api/v1/zaaktypen", seen.RequestUri!.ToString()); + Assert.Contains("status=definitief", seen.RequestUri!.Query); + Assert.Contains("identificatie=BIG-REGISTRATIE", seen.RequestUri!.Query); + Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme); + } + + [Fact] + public async Task Resolving_a_zaaktype_throws_a_clear_error_when_none_is_published() + { + var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { results = Array.Empty() }), + })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE")); + Assert.Contains("BIG-REGISTRATIE", ex.Message); + } + + [Fact] + public async Task Resolves_the_informatieobjecttype_url_by_omschrijving() + { + var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new + { + results = new[] + { + new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" }, + new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", omschrijving = "Diploma" }, + }, + }), + })); + + var url = await Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"); + + // Matches on omschrijving, not position. + Assert.Equal("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", url.ToString()); + } + + [Fact] + public async Task Resolving_an_informatieobjecttype_throws_when_no_omschrijving_matches() + { + var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new + { + results = new[] { new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" } }, + }), + })); + + var ex = await Assert.ThrowsAsync( + () => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma")); + Assert.Contains("Diploma", ex.Message); + } + + [Fact] + public async Task Resolving_rejects_a_blank_business_key_without_calling_openzaak() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent")); + + await Assert.ThrowsAnyAsync(() => Gateway(handler).ResolveZaaktypeUrlAsync(" ")); + await Assert.ThrowsAnyAsync(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" ")); + } } diff --git a/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs b/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs new file mode 100644 index 0000000..8f3f7f6 --- /dev/null +++ b/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs @@ -0,0 +1,92 @@ +using Acl.Application; + +namespace Acl.Tests; + +public class ZaaktypeCatalogTests +{ + // A gateway that only supports resolution; the other members are unused here. + private sealed class ResolvingGateway : IZaakGateway + { + public int ZaaktypeCalls; + public int InformatieobjecttypeCalls; + public string? LastIdentificatie; + public string? LastOmschrijving; + public int ThrowZaaktypeTimes; + public Uri ZaaktypeUrl { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big"); + public Uri InformatieobjecttypeUrl { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"); + + public Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + { + ZaaktypeCalls++; + LastIdentificatie = identificatie; + if (ZaaktypeCalls <= ThrowZaaktypeTimes) + throw new InvalidOperationException("no published zaaktype yet"); + return Task.FromResult(ZaaktypeUrl); + } + + public Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + { + InformatieobjecttypeCalls++; + LastOmschrijving = omschrijving; + return Task.FromResult(InformatieobjecttypeUrl); + } + + public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) => throw new NotSupportedException(); + public Task SetZaakToEindstatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException(); + public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException(); + public Task GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException(); + public Task StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException(); + } + + private static AclDefaults Defaults() => new() + { + Bronorganisatie = "517439943", + VerantwoordelijkeOrganisatie = "517439943", + Vertrouwelijkheidaanduiding = "openbaar", + ZaaktypeIdentificatie = "BIG-REGISTRATIE", + InformatieobjecttypeOmschrijving = "Diploma", + }; + + [Fact] + public async Task Resolves_the_zaaktype_and_informatieobjecttype_by_their_configured_business_keys() + { + var gateway = new ResolvingGateway(); + var catalog = new CachedZaaktypeCatalog(gateway, Defaults()); + + Assert.Equal(gateway.ZaaktypeUrl, await catalog.GetZaaktypeUrlAsync()); + Assert.Equal(gateway.InformatieobjecttypeUrl, await catalog.GetInformatieobjecttypeUrlAsync()); + Assert.Equal("BIG-REGISTRATIE", gateway.LastIdentificatie); + Assert.Equal("Diploma", gateway.LastOmschrijving); + } + + [Fact] + public async Task Caches_the_resolved_urls_so_the_gateway_is_hit_once() + { + var gateway = new ResolvingGateway(); + var catalog = new CachedZaaktypeCatalog(gateway, Defaults()); + + for (var i = 0; i < 3; i++) + { + await catalog.GetZaaktypeUrlAsync(); + await catalog.GetInformatieobjecttypeUrlAsync(); + } + + Assert.Equal(1, gateway.ZaaktypeCalls); + Assert.Equal(1, gateway.InformatieobjecttypeCalls); + } + + [Fact] + public async Task Does_not_cache_a_failed_resolution_so_it_is_retried() + { + // The zaaktype is not published yet on the first call; the catalog must retry (not cache the + // failure) so a later call succeeds once it is published. + var gateway = new ResolvingGateway { ThrowZaaktypeTimes = 1 }; + var catalog = new CachedZaaktypeCatalog(gateway, Defaults()); + + await Assert.ThrowsAsync(() => catalog.GetZaaktypeUrlAsync()); + var url = await catalog.GetZaaktypeUrlAsync(); + + Assert.Equal(gateway.ZaaktypeUrl, url); + Assert.Equal(2, gateway.ZaaktypeCalls); + } +}