diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 73903f0..1e71337 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -13,6 +13,15 @@ public interface IZaakGateway /// Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + /// + /// Set the given zaak to the cancellation statustype ("Geannuleerd") and record the + /// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term + /// lapsed" (S-10c). Distinct from (approval): the gateway + /// resolves both the cancellation statustype and resultaattype from the catalogus by their + /// omschrijving, POSTs the resultaat then the status, dated . + /// + Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default); + /// Read the zaak's identificatie — the public-safe reference the register shows. /// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78). Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default); diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 18907ab..2dcffd4 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -62,6 +62,9 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : "Setting the zaak status", ct); } + public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + => throw new NotImplementedException(); + public async Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index fe2a81a..6d0e79c 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -23,6 +23,14 @@ public class AclServiceTests return Task.CompletedTask; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled; + + public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } + public Uri? ReadReferenceFor; public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index e58c050..c570fc0 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -173,7 +173,8 @@ public class OpenZaakGatewayTests 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 string ResultaattypenJson { get; init; } = + """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}"""; public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK; public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK; public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created; @@ -251,6 +252,90 @@ public class OpenZaakGatewayTests Assert.True(status.Length > 0); } + [Fact] + public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist() + { + // Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the + // Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving. + var rec = new Recorder(); + var twoResultaattypen = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}, + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"} + ]} + """; + + await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"", + rec.Sent("/resultaten").Body); + } + + // --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) --- + + // A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the + // Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and + // "Vervallen" by omschrijving, never the approval pair. + private const string CancellationStatustypenJson = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false}, + {"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false}, + {"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true} + ]} + """; + + private const string CancellationResultaattypenJson = """ + {"results":[ + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}, + {"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"} + ]} + """; + + [Fact] + public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, new OzRoutes + { + StatustypenJson = CancellationStatustypenJson, + ResultaattypenJson = CancellationResultaattypenJson, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + // Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status). + Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen")); + + var resultaat = rec.Sent("/resultaten"); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body); + // The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd). + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body); + Assert.True(resultaat.Length > 0); + + var status = rec.Sent("/statussen"); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body); + // The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval). + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body); + Assert.True(status.Length > 0); + } + + [Fact] + public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes + { + // Only the approval statustypen — no "Geannuleerd". + StatustypenJson = StatustypenPage(withEindstatusFlag: true), + ResultaattypenJson = CancellationResultaattypenJson, + })).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Geannuleerd", ex.Message); + } + [Fact] public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged() { diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index e9e205d..d16d353 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -12,6 +12,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway public ZaakRequest? Captured { get; private set; } public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; } + public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; } public Task OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { @@ -25,6 +26,12 @@ public sealed class InMemoryZaakGateway : IZaakGateway return Task.CompletedTask; } + public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default) + { + Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet); + return Task.CompletedTask; + } + public Task GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default) => Task.FromResult("ACC-REF-1");