fix(acl): set a resultaat before the eindstatus so OpenZaak accepts the status (refs #75)
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m38s
CI / frontend (pull_request) Successful in 3m2s
CI / mutation (pull_request) Successful in 6m36s
CI / verify-stack (pull_request) Successful in 7m59s
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m38s
CI / frontend (pull_request) Successful in 3m2s
CI / mutation (pull_request) Successful in 6m36s
CI / verify-stack (pull_request) Successful in 7m59s
The status-set integration test hit 400 "resultaat-does-not-exist: Zaak has no resultaat" — OpenZaak refuses a zaak's eindstatus until the zaak has a resultaat. Resolve the zaaktype's resultaattype and POST /resultaten before POST /statussen. Also surface the ZGW response body in the failure exception (EnsureSuccessStatusCode hid the 400 detail that pinpointed this). Reworked the gateway with shared GetCatalogusAsync/PostAsync helpers and rewrote the stub-handler tests for the new 4-call flow incl. failure paths. Verified against real OpenZaak (integration test green) and locally: acl mutation 100%. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
|
||||
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
||||
private async Task<Uri> 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<StatustypePage>(ct)
|
||||
?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
|
||||
var page = await GetCatalogusAsync<StatustypePage>("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);
|
||||
}
|
||||
|
||||
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
|
||||
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||
{
|
||||
var page = await GetCatalogusAsync<ResultaattypePage>("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<T> GetCatalogusAsync<T>(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<T>(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<ResultaattypeDto>? Results);
|
||||
|
||||
private sealed record ResultaattypeDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user