fix(backend): resolve besluit endpoint's id via Referentie, not local PK

POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-05 15:20:36 +02:00
co-authored by Claude Opus 5
parent d2c2cffc1f
commit 6cfd70eeeb
12 changed files with 310 additions and 63 deletions
@@ -7,42 +7,51 @@ namespace BigRegister.Tests;
/// <summary>
/// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no
/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches
/// zaaktype labels, and always sends a Bearer token.
/// mocking library) — the guarantee that it follows ZGW pagination, maps a zaak's zaaktype
/// back to the internal aanvraag-type key, and always sends a Bearer token.
/// </summary>
public class OpenZaakZaakSourceTests
{
private const string ZrcBase = "https://oz.example/zaken/api/v1";
private const string ZtBase = "https://oz.example/catalogi/api/v1";
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
private static string Page1 => $$"""
{ "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [
{ "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1",
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01",
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-03-01",
"einddatum": null, "registratiedatum": "2026-03-01" } ] }
""";
private static string Page2 => $$"""
{ "count": 2, "next": null, "results": [
{ "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2",
"zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01",
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-01-01",
"einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] }
""";
private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }""";
[Fact]
public void Follows_pagination_caches_zaaktype_and_sends_bearer_token()
public void Follows_pagination_maps_the_internal_aanvraag_type_and_sends_bearer_token()
{
// Regression for a real bug found via a live behandelportal walkthrough: this used to
// return OpenZaak's human zaaktype label ("Herregistratie arts") as Type, which the FE's
// AANVRAAG_TYPES trust boundary always rejects (it only accepts the internal key, the
// same contract LocalZaakSource honors) — every werkvoorraad load failed to parse.
var handler = new ZgwStubHandler(url => url switch
{
_ when url == $"{ZrcBase}/zaken" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
_ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype,
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
});
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
ZaaktypeUrls = new() { ["herregistratie"] = ZaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var cases = source.ListCases(DateTimeOffset.UtcNow);
@@ -50,16 +59,31 @@ public class OpenZaakZaakSourceTests
// Both pages accumulated.
Assert.Equal(2, cases.Count);
Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id));
Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type));
Assert.All(cases, c => Assert.Equal("herregistratie", c.Type));
Assert.Equal("InBehandeling", cases[0].Status.Tag); // open
Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed
// Zaaktype resolved once despite two zaken sharing it (cache).
Assert.Single(handler.Requests, r => r.Contains("zaaktypen"));
// No Catalogi round-trip needed — the type maps back via the local Zgw:ZaaktypeUrls config.
Assert.DoesNotContain(handler.Requests, r => r.Contains("zaaktypen"));
// Every outbound request carried a Bearer token.
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
}
[Fact]
public void ListCases_throws_when_a_zaak_zaaktype_has_no_configured_aanvraag_type()
{
var handler = new ZgwStubHandler(url => url switch
{
_ when url == $"{ZrcBase}/zaken" => Page1,
_ when url == $"{ZrcBase}/zaken?page=2" => Page2,
_ => throw new InvalidOperationException($"unexpected ZGW GET {url}"),
});
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
Assert.Throws<InvalidOperationException>(() => source.ListCases(DateTimeOffset.UtcNow));
}
[Fact]
public void ListMyCases_filters_by_the_callers_bsn()
{
@@ -174,6 +198,11 @@ public class OpenZaakZaakSourceTests
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 },
{ "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] }
""",
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
""",
_ when url == $"{ZrcBase}/resultaten" => "{}",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
});
@@ -207,6 +236,59 @@ public class OpenZaakZaakSourceTests
Assert.Contains("onvolledig", statusBody);
}
[Fact]
public void RecordBesluit_creates_a_resultaat_before_posting_the_eindstatus()
{
// Regression for a real bug found against a live OpenZaak: posting straight to the eind
// statustype without a Resultaat first gets rejected with 400 "Zaak has no resultaat" —
// ZGW requires the Resultaat to exist before a zaak can reach its eindstatus.
const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie";
var handler = new ZgwStubHandler(url => url switch
{
_ when url.StartsWith($"{ZtBase}/statustypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
""",
_ when url.StartsWith($"{ZtBase}/resultaattypen") => """
{ "count": 1, "next": null,
"results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] }
""",
_ when url == $"{ZrcBase}/resultaten" => "{}",
_ when url == $"{ZrcBase}/statussen" => "{}",
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
});
var options = new ZgwOptions
{
ZrcBaseUrl = ZrcBase,
ZtcBaseUrl = ZtBase,
ClientId = "c",
Secret = "s",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
var resultaatBody = handler.BodyOf($"{ZrcBase}/resultaten");
Assert.Contains($"{ZrcBase}/zaken/uuid-existing", resultaatBody);
Assert.Contains("resultaattypen/rst-1", resultaatBody);
// The Resultaat must exist BEFORE the eindstatus is posted, not after.
Assert.True(
handler.Requests.IndexOf($"{ZrcBase}/resultaten") < handler.Requests.IndexOf($"{ZrcBase}/statussen"),
"expected /resultaten to be posted before /statussen");
}
[Fact]
public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak()
{