test(acl,domain): cover the approval-flow mutants to hold the ratchet (refs #75)
Some checks failed
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 1m6s
CI / unit (pull_request) Successful in 1m23s
CI / frontend (pull_request) Successful in 2m45s
CI / mutation (pull_request) Successful in 6m3s
CI / verify-stack (pull_request) Failing after 13m34s

The new gateway/use-case code left surviving mutants (empty NoCoverage on the ACL
gateway status-set, plus unasserted error messages, null-guards, and the persist
call). Add stub-handler tests for SetZaakToEindstatusAsync (eindstatus resolution,
POST framing, failure paths) and assert the approve use case's messages, null
handling and SaveAsync. Local Stryker: acl 100%, domain 98.4%, event-subscriber 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:30:02 +02:00
parent 8badef02af
commit 236e7ade9c
4 changed files with 178 additions and 4 deletions

View File

@@ -131,8 +131,9 @@ public class OpenZaakGatewayTests
Content = new StringContent("null", Encoding.UTF8, "application/json"),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => 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<HttpRequestMessage> Requests { get; } = [];
public List<string?> Bodies { get; } = [];
public List<long?> 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<HttpRequestException>(() =>
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<HttpRequestException>(() =>
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<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
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)
{

View File

@@ -64,4 +64,14 @@ public class AclHttpClientTests
await Assert.ThrowsAsync<HttpRequestException>(
() => 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<ArgumentNullException>(() => client.ApproveZaakAsync(null!));
Assert.Null(capture.Seen);
}
}

View File

@@ -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<ArgumentNullException>(() => 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<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => 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<InvalidOperationException>(
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}

View File

@@ -117,7 +117,8 @@ public class RegistrationTests
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
Assert.Throws<InvalidOperationException>(() => registration.Approve());
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
}