Compare commits
6 Commits
feat/75-ap
...
b00d8bd60e
| Author | SHA1 | Date | |
|---|---|---|---|
| b00d8bd60e | |||
| 7ec9d514ec | |||
| b8a88cb531 | |||
| 28fd4ca964 | |||
| 2c1734c251 | |||
| 1b89b45d21 |
@@ -1,64 +0,0 @@
|
|||||||
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-07-13
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
|
|
||||||
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
|
|
||||||
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
|
|
||||||
**temporary admin endpoint** on the Domain Service.
|
|
||||||
|
|
||||||
Two decisions are non-obvious (§14) and cross service boundaries:
|
|
||||||
|
|
||||||
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
|
|
||||||
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
|
|
||||||
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
|
|
||||||
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
|
|
||||||
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
|
|
||||||
not carry the statustype.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
|
|
||||||
notification's own fields on the read side.**
|
|
||||||
|
|
||||||
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
|
|
||||||
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
|
|
||||||
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
|
|
||||||
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
|
|
||||||
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
|
|
||||||
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
|
|
||||||
translation (§8.1, ADR-0003).
|
|
||||||
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
|
|
||||||
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
|
|
||||||
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
|
|
||||||
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
|
|
||||||
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
|
|
||||||
reproduces the right status.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
|
|
||||||
statustype knowledge lives only in the ACL.
|
|
||||||
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
|
|
||||||
resource, which is all a rebuild needs to reproject the status.
|
|
||||||
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
|
|
||||||
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
|
|
||||||
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
|
|
||||||
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
|
|
||||||
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
|
|
||||||
here so future-me revisits this rather than assuming it generalises.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
|
|
||||||
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
|
|
||||||
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
|
|
||||||
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
|
|
||||||
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
|
|
||||||
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
|
|
||||||
should retain notification *facts*, not projection semantics; the mapping stays in the projector.
|
|
||||||
@@ -195,35 +195,5 @@ curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view
|
|||||||
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
|
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
|
||||||
```
|
```
|
||||||
|
|
||||||
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below.
|
> The register shows `INGEDIEND` entries today; the approval transition to a terminal status
|
||||||
|
> (e.g. `INGESCHREVEN`) lands with the approval flow (S-09b, #75).
|
||||||
---
|
|
||||||
|
|
||||||
## S-09b — Approval flow (public visibility flips to INGESCHREVEN)
|
|
||||||
|
|
||||||
**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no
|
|
||||||
behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows
|
|
||||||
back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`.
|
|
||||||
|
|
||||||
**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW
|
|
||||||
`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN:
|
|
||||||
make up
|
|
||||||
make verify-e2e
|
|
||||||
|
|
||||||
# 2. Or by hand: submit (as in S-09), note the reference, then approve it.
|
|
||||||
# The zaak is opened off the request path, so approve once GET shows a zaakUrl.
|
|
||||||
ref="<registration-reference-from-the-confirmation>"
|
|
||||||
curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl
|
|
||||||
curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content
|
|
||||||
|
|
||||||
# 3. The public register now shows the entry as approved.
|
|
||||||
curl -fsS http://localhost:8140/openbaar/register | jq
|
|
||||||
# → [ { "id": "<zaak-uuid>", "status": "INGESCHREVEN" } ]
|
|
||||||
```
|
|
||||||
|
|
||||||
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
|
|
||||||
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
|
|
||||||
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
|
|
||||||
|
|||||||
@@ -24,18 +24,8 @@ app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationT
|
|||||||
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
|
|
||||||
// URL; the ACL owns the ZGW statustype resolution (§8.1).
|
|
||||||
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn);
|
public sealed record OpenZaakRequest(string Bsn);
|
||||||
|
|
||||||
public sealed record SetStatusRequest(string ZaakUrl);
|
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -17,15 +17,4 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
return gateway.OpenZaakAsync(request, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
|
||||||
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
|
||||||
/// </summary>
|
|
||||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
|
||||||
|
|
||||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,4 @@ namespace Acl.Application;
|
|||||||
public interface IZaakGateway
|
public interface IZaakGateway
|
||||||
{
|
{
|
||||||
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Set the given zaak to the <em>eindstatus</em> (final statustype) of the supplied zaaktype —
|
|
||||||
/// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
|
|
||||||
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
|
|
||||||
/// </summary>
|
|
||||||
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,92 +41,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(created.Url);
|
return new Uri(created.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
|
||||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
|
||||||
|
|
||||||
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
|
||||||
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
|
|
||||||
|
|
||||||
// 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(dto),
|
|
||||||
};
|
|
||||||
message.Headers.Authorization =
|
|
||||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
|
||||||
await message.Content.LoadIntoBufferAsync(ct);
|
|
||||||
|
|
||||||
using var response = await http.SendAsync(message, ct);
|
|
||||||
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 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
|
|
||||||
// highest volgnummer if the flag is absent.
|
|
||||||
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
|
|
||||||
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
|
|
||||||
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(
|
private sealed record ZaakDto(
|
||||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||||
@@ -136,27 +50,4 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
|
|
||||||
private sealed record ZaakCreatedDto(
|
private sealed record ZaakCreatedDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
private sealed record StatusDto(
|
|
||||||
[property: JsonPropertyName("zaak")] string Zaak,
|
|
||||||
[property: JsonPropertyName("statustype")] string Statustype,
|
|
||||||
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
|
|
||||||
|
|
||||||
private sealed record StatustypePage(
|
|
||||||
[property: JsonPropertyName("results")] IReadOnlyList<StatustypeDto>? Results);
|
|
||||||
|
|
||||||
private sealed record StatustypeDto(
|
|
||||||
[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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,43 +65,6 @@ public sealed class OpenZaakFixture : IDisposable
|
|||||||
return JsonDocument.Parse(json).RootElement.Clone();
|
return JsonDocument.Parse(json).RootElement.Clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.</summary>
|
|
||||||
public async Task<JsonElement> GetJsonAsync(Uri url, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var message = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
|
|
||||||
|
|
||||||
using var response = await Http.SendAsync(message, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
|
||||||
return JsonDocument.Parse(json).RootElement.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.</summary>
|
|
||||||
public async Task<Uri> FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var query = new Uri(BaseUrl,
|
|
||||||
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
|
||||||
var page = await GetJsonAsync(query, ct);
|
|
||||||
var results = page.GetProperty("results");
|
|
||||||
|
|
||||||
Uri? fallback = null;
|
|
||||||
var highest = int.MinValue;
|
|
||||||
foreach (var st in results.EnumerateArray())
|
|
||||||
{
|
|
||||||
if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
|
|
||||||
return new Uri(st.GetProperty("url").GetString()!);
|
|
||||||
var volgnummer = st.GetProperty("volgnummer").GetInt32();
|
|
||||||
if (volgnummer > highest)
|
|
||||||
{
|
|
||||||
highest = volgnummer;
|
|
||||||
fallback = new Uri(st.GetProperty("url").GetString()!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
|
||||||
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
// rather than reusing Acl.Infrastructure's internal minter to keep that internal.
|
||||||
private string MintToken()
|
private string MintToken()
|
||||||
|
|||||||
@@ -42,32 +42,4 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
||||||
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
|
|
||||||
{
|
|
||||||
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
|
||||||
Assert.True(zaaktype is not null,
|
|
||||||
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
|
|
||||||
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
|
||||||
|
|
||||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
|
||||||
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
|
|
||||||
Bronorganisatie: "517439943",
|
|
||||||
VerantwoordelijkeOrganisatie: "517439943",
|
|
||||||
Vertrouwelijkheidaanduiding: "openbaar",
|
|
||||||
Zaaktype: zaaktype!,
|
|
||||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)));
|
|
||||||
|
|
||||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
|
||||||
|
|
||||||
// The zaak now carries a current status, and it is the zaaktype's eindstatus.
|
|
||||||
var zaak = await stack.GetZaakAsync(zaakUrl);
|
|
||||||
var statusUrl = zaak.GetProperty("status").GetString();
|
|
||||||
Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
|
|
||||||
|
|
||||||
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
|
|
||||||
var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
|
|
||||||
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,28 +9,12 @@ public class AclServiceTests
|
|||||||
public ZaakRequest? Captured;
|
public ZaakRequest? Captured;
|
||||||
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved;
|
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Captured = request;
|
Captured = request;
|
||||||
return Task.FromResult(Result);
|
return Task.FromResult(Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
|
||||||
{
|
|
||||||
Bronorganisatie = "517439943",
|
|
||||||
VerantwoordelijkeOrganisatie = "517439943",
|
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
|
||||||
};
|
|
||||||
|
|
||||||
private sealed class FixedClock(DateOnly today) : IClock
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
@@ -77,30 +61,4 @@ public class AclServiceTests
|
|||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||||
Assert.Null(gateway.Captured);
|
Assert.Null(gateway.Captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var defaults = Defaults();
|
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
|
||||||
|
|
||||||
await service.ApproveZaakAsync(zaak);
|
|
||||||
|
|
||||||
Assert.NotNull(gateway.Approved);
|
|
||||||
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
|
||||||
Assert.Null(gateway.Approved);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,9 +131,8 @@ public class OpenZaakGatewayTests
|
|||||||
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
||||||
Assert.Contains("empty zaak response", ex.Message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -145,229 +144,6 @@ public class OpenZaakGatewayTests
|
|||||||
() => Gateway(handler).OpenZaakAsync(null!));
|
() => 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; } = [];
|
|
||||||
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
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_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
|
|
||||||
{
|
|
||||||
var rec = new Recorder();
|
|
||||||
|
|
||||||
await Gateway(ApprovalStub(rec, new OzRoutes()))
|
|
||||||
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
|
||||||
|
|
||||||
Assert.Equal(4, rec.Requests.Count);
|
|
||||||
|
|
||||||
// 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());
|
|
||||||
|
|
||||||
// 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("\"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]
|
|
||||||
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
|
|
||||||
{
|
|
||||||
var rec = new Recorder();
|
|
||||||
|
|
||||||
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.Sent("/statussen").Body);
|
|
||||||
}
|
|
||||||
|
|
||||||
[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, new OzRoutes { StatustypenJson = "{}" }))
|
|
||||||
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
|
||||||
|
|
||||||
Assert.Contains("No statustypen found", ex.Message);
|
|
||||||
// It never posts anything 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, new OzRoutes { StatustypenJson = "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();
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
|
||||||
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<HttpRequestException>(() =>
|
|
||||||
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<InvalidOperationException>(() =>
|
|
||||||
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<HttpRequestException>(() =>
|
|
||||||
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]
|
|
||||||
public async Task Approving_throws_when_posting_the_status_fails()
|
|
||||||
{
|
|
||||||
var rec = new Recorder();
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
|
||||||
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
|
|
||||||
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
|
||||||
|
|
||||||
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]
|
|
||||||
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.
|
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
||||||
private static string DecodeSegment(string segment)
|
private static string DecodeSegment(string segment)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService
|
|||||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||||
|
|
||||||
builder.Services.AddScoped<SubmitRegistration>();
|
builder.Services.AddScoped<SubmitRegistration>();
|
||||||
builder.Services.AddScoped<ApproveRegistration>();
|
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
|
||||||
@@ -43,18 +42,6 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis
|
|||||||
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
|
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the
|
|
||||||
// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over
|
|
||||||
// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent.
|
|
||||||
app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (!Guid.TryParse(id, out var guid))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Application;
|
|
||||||
|
|
||||||
/// <summary>A behandelaar's decision to approve a registration, in domain language.</summary>
|
|
||||||
public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1),
|
|
||||||
/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an
|
|
||||||
/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The
|
|
||||||
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
|
|
||||||
/// keeps the domain's own view consistent.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
|
|
||||||
{
|
|
||||||
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
|
||||||
|
|
||||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
|
||||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve.");
|
|
||||||
|
|
||||||
// A repeated approval is a no-op: don't set the zaak status a second time.
|
|
||||||
if (registration.Status == RegistrationStatus.Ingeschreven)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (registration.ZaakUrl is null)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
|
||||||
|
|
||||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
|
||||||
registration.Approve();
|
|
||||||
await store.SaveAsync(registration, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,12 +25,6 @@ public interface IWorkflowClient
|
|||||||
public interface IAclClient
|
public interface IAclClient
|
||||||
{
|
{
|
||||||
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
|
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
|
|
||||||
/// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen.
|
|
||||||
/// </summary>
|
|
||||||
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -64,23 +64,4 @@ public sealed class Registration
|
|||||||
|
|
||||||
ZaakUrl = zaakUrl;
|
ZaakUrl = zaakUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
|
||||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
|
||||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
|
|
||||||
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
|
|
||||||
/// </summary>
|
|
||||||
public void Approve()
|
|
||||||
{
|
|
||||||
if (ZaakUrl is null)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
|
||||||
|
|
||||||
if (Status != RegistrationStatus.Ingediend)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
|
|
||||||
|
|
||||||
Status = RegistrationStatus.Ingeschreven;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
namespace Big.Domain;
|
namespace Big.Domain;
|
||||||
|
|
||||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||||
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
/// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie
|
||||||
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
/// states arrive in their own slices (Iteration 2+).</summary>
|
||||||
public enum RegistrationStatus
|
public enum RegistrationStatus
|
||||||
{
|
{
|
||||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||||
Ingediend,
|
Ingediend,
|
||||||
|
|
||||||
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
|
|
||||||
Ingeschreven,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,7 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
|||||||
return new Uri(opened.ZaakUrl);
|
return new Uri(opened.ZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
|
||||||
|
|
||||||
using var response = await http.PostAsJsonAsync(
|
|
||||||
new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
|
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
|
||||||
|
|
||||||
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||||
|
|
||||||
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,37 +41,4 @@ public class AclHttpClientTests
|
|||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approves_a_zaak_by_posting_its_url_to_statussen()
|
|
||||||
{
|
|
||||||
var capture = new RequestCapture();
|
|
||||||
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
|
||||||
|
|
||||||
await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
|
|
||||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
|
||||||
Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString());
|
|
||||||
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approve_throws_when_the_acl_rejects_the_request()
|
|
||||||
{
|
|
||||||
var capture = new RequestCapture();
|
|
||||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
using Big.Application;
|
|
||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Tests;
|
|
||||||
|
|
||||||
public class ApproveRegistrationTests
|
|
||||||
{
|
|
||||||
private static Registration WithZaak(string bsn = "123456782")
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit(bsn);
|
|
||||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
|
||||||
return registration;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approving_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new ApproveRegistration(store, acl);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
|
||||||
|
|
||||||
var saved = await store.GetAsync(registration.Id);
|
|
||||||
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]
|
|
||||||
public async Task Approving_an_unknown_registration_throws_and_does_not_call_the_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var handler = new ApproveRegistration(store, acl);
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
||||||
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
|
|
||||||
Assert.Contains("No registration", ex.Message);
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Approving_before_the_zaak_is_opened_throws_without_calling_the_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new ApproveRegistration(store, acl);
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
||||||
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
|
|
||||||
Assert.Contains("no zaak", ex.Message);
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Re_approving_an_already_ingeschreven_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new ApproveRegistration(store, acl);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
|
||||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
|
||||||
|
|
||||||
// The second approval is a no-op: the ACL is not asked to set the status again.
|
|
||||||
Assert.Equal(1, acl.ApproveCallCount);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,20 +52,10 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
|||||||
public string? OpenedForBsn { get; private set; }
|
public string? OpenedForBsn { get; private set; }
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Uri? ApprovedZaakUrl { get; private set; }
|
|
||||||
public int ApproveCallCount { get; private set; }
|
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
OpenedForBsn = bsn;
|
OpenedForBsn = bsn;
|
||||||
return Task.FromResult(_zaakUrl);
|
return Task.FromResult(_zaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ApproveCallCount++;
|
|
||||||
ApprovedZaakUrl = zaakUrl;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,38 +87,4 @@ public class RegistrationTests
|
|||||||
Assert.Contains("/zaken/abc", ex.Message);
|
Assert.Contains("/zaken/abc", ex.Message);
|
||||||
Assert.Contains("/zaken/other", ex.Message);
|
Assert.Contains("/zaken/other", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Approving_a_registration_with_a_zaak_sets_it_ingeschreven()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
|
|
||||||
registration.Approve();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Approving_before_the_zaak_is_opened_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
|
||||||
|
|
||||||
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Approving_an_already_ingeschreven_registration_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
registration.Approve();
|
|
||||||
|
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
|
||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ await app.RunAsync();
|
|||||||
|
|
||||||
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
|
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
|
||||||
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
|
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
|
||||||
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null)
|
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl)
|
||||||
{
|
{
|
||||||
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject);
|
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Program
|
public partial class Program
|
||||||
|
|||||||
@@ -11,23 +11,14 @@ public sealed record Notification(
|
|||||||
string Kanaal,
|
string Kanaal,
|
||||||
string Resource,
|
string Resource,
|
||||||
string Actie,
|
string Actie,
|
||||||
Uri ResourceUrl,
|
Uri ResourceUrl)
|
||||||
Uri? HoofdObject = null)
|
|
||||||
{
|
{
|
||||||
/// <summary>A zaak being created — projected as INGEDIEND.</summary>
|
/// <summary>The only event the walking-skeleton projection reacts to: a zaak being created.</summary>
|
||||||
public bool IsZaakCreated =>
|
public bool IsZaakCreated =>
|
||||||
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
|
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
|
||||||
|
|
||||||
/// <summary>A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the
|
/// <summary>The zaak UUID — the trailing path segment of the resource URL — used as the projection key.</summary>
|
||||||
/// walking skeleton the only status ever set after creation is the approval, and the subscriber may
|
public string ZaakId => ResourceUrl.Segments[^1].Trim('/');
|
||||||
/// not read OpenZaak (§8.1), so any status-create is taken as the approval.</summary>
|
|
||||||
public bool IsZaakStatusSet =>
|
|
||||||
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
|
||||||
|
|
||||||
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
|
|
||||||
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
|
|
||||||
/// resource URL) — see the NRC payload note.</summary>
|
|
||||||
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
||||||
|
|||||||
@@ -7,15 +7,13 @@ namespace EventSubscriber.Application;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
|
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
|
||||||
{
|
{
|
||||||
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
|
/// <summary>Handle one inbound notification. Ignores anything that is not a zaak-created event.</summary>
|
||||||
/// status being set (INGESCHREVEN); ignores everything else.</summary>
|
|
||||||
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
|
if (!notification.IsZaakCreated)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var recorded = new RecordedNotification(
|
var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId);
|
||||||
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource);
|
|
||||||
|
|
||||||
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
|
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
|
||||||
// before it touches the projection, so the projection stays a faithful derived artefact.
|
// before it touches the projection, so the projection stays a faithful derived artefact.
|
||||||
@@ -33,10 +31,8 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
|
|||||||
await store.UpsertAsync(ToEntry(recorded), ct);
|
await store.UpsertAsync(ToEntry(recorded), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
|
/// <summary>The minimal projection row for an accepted notification. A zaak-create maps to
|
||||||
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
|
/// INGEDIEND; bsn/naam are deferred (ADR-0008).</summary>
|
||||||
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
||||||
=> new(recorded.ZaakId, recorded.Resource == "status"
|
=> new(recorded.ZaakId, RegistrationStatus.Ingediend);
|
||||||
? RegistrationStatus.Ingeschreven
|
|
||||||
: RegistrationStatus.Ingediend);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ public interface INotificationLog
|
|||||||
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
|
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its projection row.</summary>
|
||||||
/// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND)
|
public sealed record RecordedNotification(string Key, string Actie, string ZaakId);
|
||||||
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
|
|
||||||
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource);
|
|
||||||
|
|
||||||
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
||||||
/// subscriber writes to it and the projection-api reads it.</summary>
|
/// subscriber writes to it and the projection-api reads it.</summary>
|
||||||
|
|||||||
@@ -17,7 +17,4 @@ public static class RegistrationStatus
|
|||||||
{
|
{
|
||||||
/// <summary>A zaak has been created; the registration is submitted.</summary>
|
/// <summary>A zaak has been created; the registration is submitted.</summary>
|
||||||
public const string Ingediend = "INGEDIEND";
|
public const string Ingediend = "INGEDIEND";
|
||||||
|
|
||||||
/// <summary>The registration has been approved and entered in the register (S-09b).</summary>
|
|
||||||
public const string Ingeschreven = "INGESCHREVEN";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ namespace EventSubscriber.Tests;
|
|||||||
public sealed class NotificationProjectorTests
|
public sealed class NotificationProjectorTests
|
||||||
{
|
{
|
||||||
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
|
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
|
||||||
private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222";
|
|
||||||
|
|
||||||
private readonly InMemoryNotificationLog _log = new();
|
private readonly InMemoryNotificationLog _log = new();
|
||||||
private readonly InMemoryProjectionStore _store = new();
|
private readonly InMemoryProjectionStore _store = new();
|
||||||
@@ -18,10 +17,6 @@ public sealed class NotificationProjectorTests
|
|||||||
private static Notification ZaakCreated(string url = ZaakUrl)
|
private static Notification ZaakCreated(string url = ZaakUrl)
|
||||||
=> new("zaken", "zaak", "create", new Uri(url));
|
=> new("zaken", "zaak", "create", new Uri(url));
|
||||||
|
|
||||||
// A status-set notification: resourceUrl is the status resource, hoofdObject is the zaak it belongs to.
|
|
||||||
private static Notification StatusSet(string zaakUrl = ZaakUrl, string statusUrl = StatusUrl)
|
|
||||||
=> new("zaken", "status", "create", new Uri(statusUrl), new Uri(zaakUrl));
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
|
public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
|
||||||
{
|
{
|
||||||
@@ -67,53 +62,16 @@ public sealed class NotificationProjectorTests
|
|||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
|
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
|
||||||
[InlineData("documenten", "zaak", "create")] // wrong kanaal only
|
[InlineData("documenten", "zaak", "create")] // wrong kanaal only
|
||||||
|
[InlineData("zaken", "status", "create")] // wrong resource only
|
||||||
[InlineData("zaken", "zaak", "update")] // wrong actie
|
[InlineData("zaken", "zaak", "update")] // wrong actie
|
||||||
[InlineData("zaken", "zaak", "destroy")] // wrong actie
|
[InlineData("zaken", "zaak", "destroy")] // wrong actie
|
||||||
[InlineData("zaken", "status", "update")] // a status change we ignore
|
public async Task only_a_zaken_zaak_create_notification_is_projected(string kanaal, string resource, string actie)
|
||||||
[InlineData("zaken", "resultaat", "create")] // not a status we project
|
|
||||||
public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie)
|
|
||||||
{
|
{
|
||||||
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
|
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
|
||||||
|
|
||||||
Assert.Empty(await _store.AllAsync());
|
Assert.Empty(await _store.AllAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task setting_a_status_projects_ingeschreven_keyed_on_the_zaak_not_the_status()
|
|
||||||
{
|
|
||||||
await Projector().HandleAsync(StatusSet());
|
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
|
||||||
// Keyed on the zaak (hoofdObject), not the status resource URL.
|
|
||||||
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task approving_updates_the_existing_zaak_row_from_ingediend_to_ingeschreven()
|
|
||||||
{
|
|
||||||
var projector = Projector();
|
|
||||||
await projector.HandleAsync(ZaakCreated());
|
|
||||||
await projector.HandleAsync(StatusSet());
|
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
|
||||||
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task rebuild_reproduces_the_approved_status()
|
|
||||||
{
|
|
||||||
var projector = Projector();
|
|
||||||
await projector.HandleAsync(ZaakCreated());
|
|
||||||
await projector.HandleAsync(StatusSet());
|
|
||||||
|
|
||||||
await projector.RebuildAsync();
|
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
|
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
Key = notification.Key,
|
Key = notification.Key,
|
||||||
Actie = notification.Actie,
|
Actie = notification.Actie,
|
||||||
ZaakId = notification.ZaakId,
|
ZaakId = notification.ZaakId,
|
||||||
Resource = notification.Resource,
|
|
||||||
ReceivedAt = DateTimeOffset.UtcNow,
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +34,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
||||||
=> await db.ProcessedNotifications
|
=> await db.ProcessedNotifications
|
||||||
.OrderBy(r => r.ReceivedAt)
|
.OrderBy(r => r.ReceivedAt)
|
||||||
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource))
|
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
using Projection.ReadModel;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Projection.ReadModel.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(ProjectionDbContext))]
|
|
||||||
[Migration("20260713145944_AddNotificationResource")]
|
|
||||||
partial class AddNotificationResource
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("key");
|
|
||||||
|
|
||||||
b.Property<string>("Actie")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("actie");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("ReceivedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("received_at");
|
|
||||||
|
|
||||||
b.Property<string>("Resource")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("resource");
|
|
||||||
|
|
||||||
b.Property<string>("ZaakId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("zaak_id");
|
|
||||||
|
|
||||||
b.HasKey("Key");
|
|
||||||
|
|
||||||
b.ToTable("processed_notifications", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("Bsn")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("bsn");
|
|
||||||
|
|
||||||
b.Property<string>("NaamPlaceholder")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("naam_placeholder");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("status");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("register_projection", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Projection.ReadModel.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddNotificationResource : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// Pre-existing rows are all zaak-creates (the only notification the projector recorded
|
|
||||||
// before S-09b), so default the backfill to "zaak" rather than "".
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "resource",
|
|
||||||
table: "processed_notifications",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "zaak");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "resource",
|
|
||||||
table: "processed_notifications");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,11 +37,6 @@ namespace Projection.ReadModel.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("received_at");
|
.HasColumnName("received_at");
|
||||||
|
|
||||||
b.Property<string>("Resource")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("resource");
|
|
||||||
|
|
||||||
b.Property<string>("ZaakId")
|
b.Property<string>("ZaakId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
|
|||||||
e.Property(r => r.Key).HasColumnName("key");
|
e.Property(r => r.Key).HasColumnName("key");
|
||||||
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
|
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
|
||||||
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
||||||
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
|
|
||||||
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -56,10 +55,5 @@ public sealed class ProcessedNotificationRow
|
|||||||
public required string Key { get; set; }
|
public required string Key { get; set; }
|
||||||
public required string Actie { get; set; }
|
public required string Actie { get; set; }
|
||||||
public required string ZaakId { get; set; }
|
public required string ZaakId { get; set; }
|
||||||
|
|
||||||
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
|
|
||||||
/// the right status without reading OpenZaak (S-09b).</summary>
|
|
||||||
public required string Resource { get; set; }
|
|
||||||
|
|
||||||
public DateTimeOffset ReceivedAt { get; set; }
|
public DateTimeOffset ReceivedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,46 +19,12 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant).
|
|
||||||
private const long MigrationAdvisoryLockKey = 727501;
|
|
||||||
|
|
||||||
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
|
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
|
||||||
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
|
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).</summary>
|
||||||
///
|
|
||||||
/// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's
|
|
||||||
/// migrations-history lock is released between individual migrations, so with more than one pending
|
|
||||||
/// migration two migrators can interleave and one re-applies a migration the other just did
|
|
||||||
/// ("column already exists"). Hold a Postgres session <c>pg_advisory_lock</c> across the whole
|
|
||||||
/// sequence so it runs exactly once; the second migrator then finds nothing pending.</summary>
|
|
||||||
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
|
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var scope = services.CreateAsyncScope();
|
await using var scope = services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
|
||||||
var connection = db.Database.GetDbConnection();
|
|
||||||
|
|
||||||
await connection.OpenAsync(ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await db.Database.MigrateAsync(ct);
|
await db.Database.MigrateAsync(ct);
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await connection.CloseAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct)
|
|
||||||
{
|
|
||||||
await using var command = connection.CreateCommand();
|
|
||||||
command.CommandText = sql;
|
|
||||||
await command.ExecuteNonQueryAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ builder.Services.AddProjectionReadModel(connectionString);
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too;
|
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the
|
||||||
// MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole
|
// migrations-history lock, so it is safe that the Event Subscriber migrates too.
|
||||||
// migration sequence runs exactly once (see ServiceCollectionExtensions).
|
|
||||||
await app.Services.MigrateProjectionAsync();
|
await app.Services.MigrateProjectionAsync();
|
||||||
|
|
||||||
app.MapGet("/health", () => "Healthy");
|
app.MapGet("/health", () => "Healthy");
|
||||||
|
|||||||
@@ -26,19 +26,12 @@ public sealed class InMemoryAclClient : IAclClient
|
|||||||
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
|
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
|
||||||
|
|
||||||
public string? OpenedForBsn { get; private set; }
|
public string? OpenedForBsn { get; private set; }
|
||||||
public Uri? ApprovedZaakUrl { get; private set; }
|
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
OpenedForBsn = bsn;
|
OpenedForBsn = bsn;
|
||||||
return Task.FromResult(OpenedZaakUrl);
|
return Task.FromResult(OpenedZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ApprovedZaakUrl = zaakUrl;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
|
|||||||
@@ -11,17 +11,10 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
|
public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
|
||||||
|
|
||||||
public ZaakRequest? Captured { get; private set; }
|
public ZaakRequest? Captured { get; private set; }
|
||||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Captured = request;
|
Captured = request;
|
||||||
return Task.FromResult(CreatedZaakUrl);
|
return Task.FromResult(CreatedZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
|
// Walking-skeleton happy path (S-08d + S-09): a zorgprofessional logs in via mock DigiD and submits a
|
||||||
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
|
// registration through the self-service portal → BFF → domain; the projection is updated via NRC → the
|
||||||
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
|
// event-subscriber, and the entry becomes publicly visible in the openbaar register portal.
|
||||||
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
|
test('DigiD login → submit → confirmation → public visibility', async ({ page }) => {
|
||||||
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
|
|
||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@@ -18,11 +17,8 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
|||||||
|
|
||||||
await page.getByRole('button', { name: /indienen/i }).click();
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|
||||||
// The BFF accepted it and the page shows the confirmation with the reference.
|
// The BFF accepted it and the page shows the confirmation.
|
||||||
const confirmation = page.getByText(/ontvangen/i);
|
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||||
await expect(confirmation).toBeVisible();
|
|
||||||
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
|
|
||||||
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
|
||||||
|
|
||||||
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
||||||
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
||||||
@@ -35,25 +31,4 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
|||||||
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
|
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
|
||||||
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
|
||||||
// path by the worker, so wait for it before approving.
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const res = await request.get(`http://domain:8080/registrations/${reference}`);
|
|
||||||
return res.ok() ? (await res.json()).zaakUrl : null;
|
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
|
||||||
.toBeTruthy();
|
|
||||||
|
|
||||||
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
|
||||||
expect(approve.status()).toBe(204);
|
|
||||||
|
|
||||||
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN.
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
await page.reload();
|
|
||||||
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user