diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
index 10a3c3f..523f215 100644
--- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
+++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs
@@ -47,40 +47,51 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
+ var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
- using var message = new HttpRequestMessage(
- HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
+ // OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
+ // ("resultaat-does-not-exist"), so record the resultaat first, then the status.
+ await PostAsync("/zaken/api/v1/resultaten",
+ new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
+
+ await PostAsync("/zaken/api/v1/statussen",
+ // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
+ new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
+ datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
+ "Setting the zaak status", ct);
+ }
+
+ // 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)
+ {
+ using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{
- Content = JsonContent.Create(new StatusDto(
- zaakUrl.ToString(),
- eindstatus.ToString(),
- // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
- datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
+ Content = JsonContent.Create(dto),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
- // The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
- // body so uwsgi gets a Content-Length instead of a chunked body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
- response.EnsureSuccessStatusCode();
+ await EnsureSuccessAsync(response, action, ct);
+ }
+
+ // EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
+ // is essential for diagnosing a rejected request, so surface it in the exception.
+ private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
+ {
+ if (response.IsSuccessStatusCode)
+ return;
+
+ var body = await response.Content.ReadAsStringAsync(ct);
+ throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
/// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.
private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
{
- var query = new Uri(options.BaseUrl,
- "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
- using var message = new HttpRequestMessage(HttpMethod.Get, query);
- message.Headers.Authorization =
- new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
-
- using var response = await http.SendAsync(message, ct);
- response.EnsureSuccessStatusCode();
-
- var page = await response.Content.ReadFromJsonAsync(ct)
- ?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
+ var page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct);
var results = page.Results ?? [];
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
@@ -91,6 +102,31 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(eindstatus.Url);
}
+ /// Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).
+ private async Task ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
+ {
+ var page = await GetCatalogusAsync("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
+ var resultaattype = (page.Results ?? []).FirstOrDefault()
+ ?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
+ return new Uri(resultaattype.Url);
+ }
+
+ // GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
+ private async Task GetCatalogusAsync(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
+ {
+ var query = new Uri(options.BaseUrl,
+ $"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
+ using var message = new HttpRequestMessage(HttpMethod.Get, query);
+ 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");
+ }
+
private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
@@ -113,4 +149,14 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
+
+ private sealed record ResultaatDto(
+ [property: JsonPropertyName("zaak")] string Zaak,
+ [property: JsonPropertyName("resultaattype")] string Resultaattype);
+
+ private sealed record ResultaattypePage(
+ [property: JsonPropertyName("results")] IReadOnlyList? Results);
+
+ private sealed record ResultaattypeDto(
+ [property: JsonPropertyName("url")] string Url);
}
diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
index 8b9388e..7ca0271 100644
--- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
+++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs
@@ -155,28 +155,50 @@ public class OpenZaakGatewayTests
public List Requests { get; } = [];
public List Bodies { get; } = [];
public List ContentLengths { get; } = [];
+
+ public int IndexOf(string pathContains) =>
+ Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
+
+ // The (body, content-length, request) of the single request whose URL contains the segment.
+ public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
+ {
+ var i = IndexOf(pathContains);
+ return (Bodies[i], ContentLengths[i], Requests[i]);
+ }
}
- // 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" }),
- };
- });
+ // Per-route response config for the four calls the approval makes.
+ 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 HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
+ public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
+ public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
+ public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
+ }
+
+ // Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
+ // then POST /resultaten and POST /statussen (zaken).
+ private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
+ {
+ rec.Requests.Add(req);
+ // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
+ rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
+ rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
+
+ var url = req.RequestUri!.ToString();
+ if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
+ return Json(routes.StatustypenStatus, routes.StatustypenJson);
+ if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
+ return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
+ if (url.Contains("/resultaten"))
+ return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
+ return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
+ });
+
+ private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
+ new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
// 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.
@@ -190,32 +212,41 @@ public class OpenZaakGatewayTests
});
[Fact]
- public async Task Approving_queries_statustypen_then_posts_the_flagged_eindstatus_against_the_zaak()
+ public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
{
var rec = new Recorder();
- await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true)))
+ await Gateway(ApprovalStub(rec, new OzRoutes()))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
- Assert.Equal(2, rec.Requests.Count);
+ Assert.Equal(4, 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);
+ // Both catalogus queries filter by the zaaktype and carry the bearer.
+ var statustypenGet = rec.Sent("/statustypen").Request;
+ Assert.Equal(HttpMethod.Get, statustypenGet.Method);
+ Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
+ Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
+ Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
- 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);
+ // OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
+ Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
+
+ var resultaat = rec.Sent("/resultaten");
+ Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
+ Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
+ Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
+ Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
+ Assert.True(resultaat.Length > 0);
+
+ var status = rec.Sent("/statussen");
+ Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
+ Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
+ Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
// 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);
+ Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
+ Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
+ // Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
+ Assert.True(status.Length > 0);
}
[Fact]
@@ -223,11 +254,11 @@ public class OpenZaakGatewayTests
{
var rec = new Recorder();
- await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: false)))
+ await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = 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]);
+ Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
}
[Fact]
@@ -237,11 +268,11 @@ public class OpenZaakGatewayTests
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
var ex = await Assert.ThrowsAsync(() =>
- Gateway(ApprovalStub(rec, "{}"))
+ Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
.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.
+ // It never posts anything when it cannot resolve the eindstatus.
Assert.Single(rec.Requests);
}
@@ -251,7 +282,7 @@ public class OpenZaakGatewayTests
var rec = new Recorder();
var ex = await Assert.ThrowsAsync(() =>
- Gateway(ApprovalStub(rec, "null"))
+ Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("empty statustypen", ex.Message);
@@ -263,9 +294,53 @@ public class OpenZaakGatewayTests
{
var rec = new Recorder();
- await Assert.ThrowsAsync(() =>
- Gateway(ApprovalStub(rec, "{}", getStatus: HttpStatusCode.InternalServerError))
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("Querying statustypen", ex.Message);
+ }
+
+ [Fact]
+ public async Task Approving_throws_when_the_resultaattypen_query_fails()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
+ .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("Querying resultaattypen", ex.Message);
+ Assert.Equal(-1, rec.IndexOf("/resultaten"));
+ }
+
+ [Fact]
+ public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
+ .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("No resultaattypen found", ex.Message);
+ // Resolved the eindstatus + queried resultaattypen, but posted nothing.
+ Assert.Equal(-1, rec.IndexOf("/resultaten"));
+ Assert.Equal(-1, rec.IndexOf("/statussen"));
+ }
+
+ [Fact]
+ public async Task Approving_throws_when_posting_the_resultaat_fails()
+ {
+ var rec = new Recorder();
+
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
+ .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
+
+ Assert.Contains("Setting the zaak resultaat", ex.Message);
+ // The status is never posted if the resultaat could not be recorded.
+ Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
@@ -273,12 +348,13 @@ public class OpenZaakGatewayTests
{
var rec = new Recorder();
- await Assert.ThrowsAsync(() =>
- Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true), postStatus: HttpStatusCode.BadRequest))
+ var ex = await Assert.ThrowsAsync(() =>
+ Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = 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);
+ Assert.Contains("Setting the zaak status", ex.Message);
+ // It got as far as the resultaat + the status POST (4 calls) before failing.
+ Assert.Equal(4, rec.Requests.Count);
}
[Fact]