diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index b9f9099..8b9388e 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,153 @@ 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; } = []; + } + + // Routes the two calls the approval makes: GET statustypen (returns the given page) then POST statussen. + private static StubHandler ApprovalStub(Recorder rec, string statustypenJson, + HttpStatusCode getStatus = HttpStatusCode.OK, HttpStatusCode postStatus = HttpStatusCode.Created) + => new(async req => + { + rec.Requests.Add(req); + // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect, + // which would mask whether the gateway buffered it itself — mirrors the zaak-create tests). + rec.ContentLengths.Add(req.Content?.Headers.ContentLength); + rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync()); + if (req.Method == HttpMethod.Get) + return new HttpResponseMessage(getStatus) + { + Content = new StringContent(statustypenJson, Encoding.UTF8, "application/json"), + }; + return new HttpResponseMessage(postStatus) + { + Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/statussen/new" }), + }; + }); + + // 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_queries_statustypen_then_posts_the_flagged_eindstatus_against_the_zaak() + { + var rec = new Recorder(); + + await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true))) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); + + Assert.Equal(2, rec.Requests.Count); + + var get = rec.Requests[0]; + Assert.Equal(HttpMethod.Get, get.Method); + Assert.StartsWith("http://openzaak/catalogi/api/v1/statustypen", get.RequestUri!.ToString()); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), get.RequestUri.ToString()); + Assert.Equal("Bearer", get.Headers.Authorization!.Scheme); + + var post = rec.Requests[1]; + Assert.Equal(HttpMethod.Post, post.Method); + Assert.Equal("http://openzaak/zaken/api/v1/statussen", post.RequestUri!.ToString()); + Assert.Equal("Bearer", post.Headers.Authorization!.Scheme); + // The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer. + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", rec.Bodies[1]); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", rec.Bodies[1]); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", rec.Bodies[1]); + // The POST body is buffered (Content-Length set), so uwsgi doesn't get a chunked body. + Assert.NotNull(rec.ContentLengths[1]); + Assert.True(rec.ContentLengths[1] > 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, 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.Bodies[1]); + } + + [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, "{}")) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("No statustypen found", ex.Message); + // It never posts a status 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, "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(); + + await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, "{}", getStatus: HttpStatusCode.InternalServerError)) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + } + + [Fact] + public async Task Approving_throws_when_posting_the_status_fails() + { + var rec = new Recorder(); + + await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true), postStatus: HttpStatusCode.BadRequest)) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + // It resolved the eindstatus and attempted the POST before failing. + Assert.Equal(2, 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.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs index 0fdfb48..71311ea 100644 --- a/services/domain/Big.Tests/AclHttpClientTests.cs +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -64,4 +64,14 @@ public class AclHttpClientTests 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 index 2112f96..3c7ae28 100644 --- a/services/domain/Big.Tests/ApproveRegistrationTests.cs +++ b/services/domain/Big.Tests/ApproveRegistrationTests.cs @@ -27,6 +27,19 @@ public class ApproveRegistrationTests 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] @@ -36,8 +49,9 @@ public class ApproveRegistrationTests var acl = new FakeAclClient(); var handler = new ApproveRegistration(store, acl); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); + Assert.Contains("No registration", ex.Message); Assert.Equal(0, acl.ApproveCallCount); } @@ -50,8 +64,9 @@ public class ApproveRegistrationTests store.Seed(registration); var handler = new ApproveRegistration(store, acl); - await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); + Assert.Contains("no zaak", ex.Message); Assert.Equal(0, acl.ApproveCallCount); } diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index c924f0b..b069ecc 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -117,7 +117,8 @@ public class RegistrationTests registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.Approve(); - Assert.Throws(() => registration.Approve()); + var ex = Assert.Throws(() => registration.Approve()); + Assert.Contains("only an INGEDIEND", ex.Message); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } }