Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88af769d45 | ||
|
|
bf234e1322 | ||
|
|
c8fdfbb699 | ||
|
|
0904df8db0 |
+9
-1
@@ -215,7 +215,9 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
|||||||
|
|
||||||
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
#### S-10c · Close the ZGW zaak on document-timeout expiry — #106
|
||||||
|
|
||||||
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a cancellation status (not just the domain aggregate → `Verlopen`). Adds a cancellation statustype/resultaattype to the seed + an ACL method + expiry-worker wiring. Carved from S-10b (ADR-0017/0018). Depends on #103.
|
**Outcome:** when the 30-day term lapses (S-10a `RegistratieVerlopen`), the ZGW zaak is set to a distinct non-terminal `Geannuleerd` status + `Vervallen` resultaat (not just the domain aggregate → `Verlopen`), resolved by name in the ACL. Adds the cancellation statustype/resultaattype to the seed + an ACL `CancelZaakAsync`/`POST /annuleringen` + expiry-worker wiring. Carved from S-10b (ADR-0017/0018/0019). Depends on #103.
|
||||||
|
|
||||||
|
**Acceptance:** ACL↔OpenZaak integration test (cancellation records `Geannuleerd` + a resultaat, live); the domain verify script fires the P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD asserts the zaak is cancelled on timeout but untouched when documents arrive in time.
|
||||||
|
|
||||||
### S-11 · Withdrawal (Flow 3)
|
### S-11 · Withdrawal (Flow 3)
|
||||||
|
|
||||||
@@ -237,6 +239,12 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
|||||||
|
|
||||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||||
|
|
||||||
|
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||||
|
|
||||||
|
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||||
|
|
||||||
|
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# ADR-0019: A timed-out zaak is cancelled with a distinct status + resultaat, resolved by name
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-21
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-10c (#106). Completes the S-10a/S-10b boundary noted in ADR-0017 (§Consequences) and
|
||||||
|
reuses the ACL close-zaak machinery from S-09b (approval) and the Documenten work in ADR-0018.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0017 (S-10a) cancels the *process* and marks the domain aggregate `Verlopen` when the 30-day
|
||||||
|
document term lapses, but explicitly deferred setting the ZGW **zaak** to a cancellation status. Left
|
||||||
|
open, a timed-out zaak stays open in OpenZaak while the register shows the registration as lapsed — the
|
||||||
|
two diverge. S-10c closes that gap: on expiry the domain must also cancel the zaak through the ACL
|
||||||
|
(§8.1, the only code that talks to ZGW).
|
||||||
|
|
||||||
|
The non-obvious part is *how to represent "cancelled" in ZGW* alongside the existing "approved" close.
|
||||||
|
The approval path (S-09b) sets the zaak's **eindstatus** (the terminal statustype) plus a resultaat. In
|
||||||
|
ZGW a zaaktype has exactly one eindstatus — the highest-`volgnummer` statustype — and setting it is what
|
||||||
|
closes the zaak (`einddatum`). A second *terminal* status would collide with that single-eindstatus rule.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Model cancellation as a distinct, non-terminal `Geannuleerd` statustype plus a distinct `Vervallen`
|
||||||
|
resultaat, and resolve both the approval and cancellation statustype/resultaat by their omschrijving
|
||||||
|
(name) rather than by position or the eindstatus flag alone.**
|
||||||
|
|
||||||
|
- **Seed.** `Geannuleerd` is seeded at `volgnummer` 2 — between `Ontvangen` (1) and the `Afgehandeld`
|
||||||
|
eindstatus (3) — so it is a *non-terminal* status and never displaces the eindstatus the approval path
|
||||||
|
resolves. A second resultaattype `Vervallen` (archiefnominatie `vernietigen`) is seeded beside the
|
||||||
|
approval `Geregistreerd` (`blijvend_bewaren`); both draw their `selectielijstklasse` from the
|
||||||
|
zaaktype's single `selectielijstProcestype` so they validate on publish.
|
||||||
|
- **The ACL owns the mapping.** `OpenZaakGateway.SetZaakToCancellationStatusAsync` resolves `Geannuleerd`
|
||||||
|
+ `Vervallen` by omschrijving and POSTs the resultaat then the status (OpenZaak requires a resultaat
|
||||||
|
before a closing/terminal status), mirroring `SetZaakToEindstatusAsync`. Exposed as
|
||||||
|
`AclService.CancelZaakAsync` behind the ACL endpoint `POST /annuleringen`. The omschrijvingen live as
|
||||||
|
constants in the gateway — the ACL, not the domain, knows which ZGW status means what (§8.1).
|
||||||
|
- **Approval now resolves its resultaat by name too.** With two resultaattypen present, taking the first
|
||||||
|
is ambiguous (the Zaken API does not guarantee order), so the approval path resolves `Geregistreerd`
|
||||||
|
by omschrijving. Its statustype resolution is unchanged (still the eindstatus).
|
||||||
|
- **Domain wiring.** The `ExpireRegistrationWorker` calls `IAclClient.CancelZaakAsync(zaakUrl)` **before**
|
||||||
|
advancing the aggregate to `Verlopen` (ACL-first, mirroring approval): if the ACL call fails the job is
|
||||||
|
redelivered (§8.6) rather than leaving the aggregate `Verlopen` with an open zaak. The existing
|
||||||
|
open-state guard stops a redelivered job from cancelling twice (a second resultaat would be a 400); a
|
||||||
|
registration that lapsed before its zaak was opened has nothing to cancel.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The domain aggregate and the ZGW zaak no longer diverge on timeout — both reflect the cancellation.
|
||||||
|
- Reuses the approval close machinery (resultaat-then-status, ACL endpoint shape, ACL-first ordering), so
|
||||||
|
the change is additive and §8 stays clean (only the ACL talks to ZGW).
|
||||||
|
- Verified at two levels: an ACL↔OpenZaak integration test asserts the live zaak reaches `Geannuleerd`
|
||||||
|
with a resultaat, and the domain verify script fires the real P30D timer and confirms the zaak is
|
||||||
|
cancelled end-to-end.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- `Geannuleerd` is non-terminal, so the cancelled zaak's `einddatum` is not set — it carries a
|
||||||
|
cancellation status + resultaat but is not formally "closed" in ZGW. Accepted: the register reads the
|
||||||
|
domain aggregate's status, and a single eindstatus per zaaktype is a ZGW constraint we chose not to
|
||||||
|
fight. Formally closing a cancelled zaak (a second eindstatus, or reusing `Afgehandeld` with a
|
||||||
|
`Vervallen` resultaat) is a possible follow-up.
|
||||||
|
- The ACL couples to the seeded omschrijvingen (`Geregistreerd`/`Geannuleerd`/`Vervallen`) by string
|
||||||
|
constants. This mirrors the existing implicit coupling to the catalogus (zaaktype URL, eindstatus) and
|
||||||
|
is documented in the gateway.
|
||||||
|
- Renumbering `Afgehandeld` from `volgnummer` 2 to 3 means a *stale* local catalogus must have its
|
||||||
|
OpenZaak volumes reset for the change to take effect; CI reseeds a fresh catalogus each run.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Shared eindstatus, distinct resultaat only** (reuse `Afgehandeld`, distinguish approval vs
|
||||||
|
cancellation purely by the resultaat). ZGW-idiomatic and would set `einddatum` on cancellation too, but
|
||||||
|
the register would show no visibly distinct cancellation *status*. Rejected in favour of the issue's
|
||||||
|
explicit "distinct statustype + resultaattype" outcome, which makes the cancellation legible in ZGW.
|
||||||
|
- **A second terminal (eindstatus) `Geannuleerd`.** Rejected: ZGW allows only one eindstatus per
|
||||||
|
zaaktype (highest volgnummer); a second terminal status would either not close the zaak or collide with
|
||||||
|
the approval eindstatus resolution.
|
||||||
|
- **Passing the target omschrijvingen from the domain.** Rejected: which ZGW status means "cancelled" is
|
||||||
|
ZGW vocabulary the ACL owns (§8.1); the domain says only "cancel this zaak".
|
||||||
+21
-1
@@ -461,4 +461,24 @@ make verify-acl # → "Storing a diploma creates a real informatieobject
|
|||||||
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
|
`ProvideDocuments` → ACL `POST /documenten` → ZGW `enkelvoudiginformatieobjecten` +
|
||||||
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
|
`zaakinformatieobjecten`; the wait is then completed and the case advances to Beoordelen (§8.1, ADR-0018).
|
||||||
|
|
||||||
> Setting the ZGW zaak to a cancellation status on 30-day expiry is a follow-up (S-10c, #106).
|
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
|
||||||
|
|
||||||
|
When the 30-day document term lapses (S-10a), the domain no longer only marks the aggregate `Verlopen` —
|
||||||
|
it now also cancels the **ZGW zaak** through the ACL, so OpenZaak and the register agree. The zaak is set
|
||||||
|
to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (as opposed to the
|
||||||
|
approval `Afgehandeld` + `Geregistreerd`), resolved by name in the ACL (§8.1, ADR-0019).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. The ACL integration test proves cancellation records the Geannuleerd status + a resultaat
|
||||||
|
# against a live OpenZaak:
|
||||||
|
make verify-acl # → "Cancelling a zaak records the geannuleerd status and a resultaat"
|
||||||
|
#
|
||||||
|
# 2. End-to-end: the domain check submits a registration, fires its 30-day timer early, and asserts
|
||||||
|
# the timeout worker both expires the registration (VERLOPEN) and cancels its zaak (Geannuleerd):
|
||||||
|
make verify-domain # → "the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL
|
||||||
|
`POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd); the aggregate then moves to
|
||||||
|
`Verlopen`. The ACL cancels the zaak **before** the aggregate is expired, so a failed ZGW call leaves the
|
||||||
|
job for redelivery rather than diverging the two (ADR-0019).
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Creates (if absent):
|
|||||||
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
|
Auth uses the JWT client provisioned by setup_configuration (see ADR-0002).
|
||||||
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
|
Stdlib only — no pip deps. Re-running is safe (matches existing by identifier).
|
||||||
"""
|
"""
|
||||||
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.request
|
import base64, hashlib, hmac, json, os, sys, time, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
|
BASE = os.environ.get("OZ_BASE", "http://localhost:8000")
|
||||||
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
CLIENT_ID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
@@ -77,8 +77,12 @@ def publish_zaaktype(zt):
|
|||||||
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
||||||
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
||||||
"""
|
"""
|
||||||
|
# Ontvangen (begin) → Afgehandeld (eind, highest volgnummer). "Geannuleerd" (S-10c) sits between
|
||||||
|
# them: a non-terminal status the document-timeout branch sets, so it never displaces the Afgehandeld
|
||||||
|
# eindstatus the approval path resolves. Keyed by volgnummer on a fresh catalogus (CI reseeds); a
|
||||||
|
# stale local stack must reset its OpenZaak volumes for the renumbering to take effect.
|
||||||
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
||||||
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
|
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Geannuleerd"), (3, "Afgehandeld")]:
|
||||||
if volgnummer not in have_st:
|
if volgnummer not in have_st:
|
||||||
st, body = api("POST", "/statustypen", {
|
st, body = api("POST", "/statustypen", {
|
||||||
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
||||||
@@ -95,25 +99,42 @@ def publish_zaaktype(zt):
|
|||||||
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
print("create roltype Aanvrager")
|
print("create roltype Aanvrager")
|
||||||
|
|
||||||
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
|
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
|
||||||
print("skip resultaattype Geregistreerd")
|
# "Geregistreerd" — the approval outcome (S-09b)
|
||||||
|
# "Vervallen" — the document-timeout cancellation outcome (S-10c)
|
||||||
|
# Both selectielijstklassen must share the zaaktype's selectielijstProcestype, so pick two
|
||||||
|
# Selectielijst resultaten from a single procestype and set that procestype on the zaaktype.
|
||||||
|
have_rt = {r.get("omschrijving") for r in find(f"/resultaattypen?zaaktype={zt['url']}&status=alles")}
|
||||||
|
wanted = [("Geregistreerd", "blijvend_bewaren"), ("Vervallen", "vernietigen")]
|
||||||
|
if all(naam in have_rt for naam, _ in wanted):
|
||||||
|
print("skip resultaattypen Geregistreerd + Vervallen")
|
||||||
else:
|
else:
|
||||||
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
|
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
|
||||||
|
# both klassen validate against the zaaktype's selectielijstProcestype.
|
||||||
|
procestype = selectielijst("/resultaten?pageSize=1")["results"][0]["procesType"]
|
||||||
|
resultaten = selectielijst(f"/resultaten?procesType={urllib.parse.quote(procestype, safe='')}")["results"]
|
||||||
|
if len(resultaten) < len(wanted):
|
||||||
|
sys.exit(f"selectielijst procestype has too few resultaten ({len(resultaten)}) for {len(wanted)} resultaattypen")
|
||||||
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
||||||
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
|
oms_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
|
||||||
# The selectielijstklasse and the zaaktype must share a procestype.
|
|
||||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
|
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
|
||||||
if st != 200:
|
if st != 200:
|
||||||
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
st, body = api("POST", "/resultaattypen", {
|
|
||||||
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
for i, (naam, archiefnominatie) in enumerate(wanted):
|
||||||
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
if naam in have_rt:
|
||||||
"archiefnominatie": "blijvend_bewaren",
|
print(f"skip resultaattype {naam}")
|
||||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
continue
|
||||||
})
|
st, body = api("POST", "/resultaattypen", {
|
||||||
if st != 201:
|
"zaaktype": zt["url"], "omschrijving": naam,
|
||||||
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
|
||||||
print("create resultaattype Geregistreerd")
|
"archiefnominatie": archiefnominatie,
|
||||||
|
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||||
|
})
|
||||||
|
if st != 201:
|
||||||
|
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
|
||||||
|
print(f"create resultaattype {naam}")
|
||||||
|
|
||||||
if zt.get("concept", True):
|
if zt.get("concept", True):
|
||||||
st, body = api("POST", f"{zt['url']}/publish")
|
st, body = api("POST", f"{zt['url']}/publish")
|
||||||
|
|||||||
@@ -309,10 +309,11 @@ done
|
|||||||
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
[ -n "$escalated" ] || { echo "FAIL — Beoordelen task not reassigned to teamlead (candidate groups: '$groups')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
echo "OK — the 14-day timer escalated the still-open Beoordelen task to the teamlead"
|
||||||
|
|
||||||
# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above —
|
# ── S-10a/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
|
||||||
# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
|
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
|
||||||
# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
|
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
|
||||||
# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
|
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
|
||||||
|
# registration to VERLOPEN (ADR-0017). ─────────────────────────────────────────────────────────────
|
||||||
echo ">> submitting a registration to let its document term lapse"
|
echo ">> submitting a registration to let its document term lapse"
|
||||||
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
|
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||||
@@ -354,4 +355,47 @@ for _ in $(seq 1 30); do
|
|||||||
done
|
done
|
||||||
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
[ -n "$verlopen" ] || { echo "FAIL — registration $reg_idv not VERLOPEN after the document timer fired (body: $body)" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
||||||
|
|
||||||
|
# S-10c: the worker cancels the ZGW zaak (ACL-first, before it expires the aggregate), so a VERLOPEN
|
||||||
|
# registration must carry a zaak whose current status is "Geannuleerd". Read it back from OpenZaak with
|
||||||
|
# a ZGW token minted like the seed's client (the same client OpenZaak trusts for this stack).
|
||||||
|
zaak_url_v="$(printf '%s' "$body" | grep -oiE 'http://[^"]*/zaken/api/v1/zaken/[a-f0-9-]+' | head -1)"
|
||||||
|
[ -n "$zaak_url_v" ] || { echo "FAIL — VERLOPEN registration $reg_idv exposes no zaak URL (body: $body)" >&2; exit 1; }
|
||||||
|
echo ">> confirming the zaak $zaak_url_v reached the Geannuleerd status in OpenZaak"
|
||||||
|
|
||||||
|
read_zaak_status() {
|
||||||
|
# -i so the heredoc reaches `python -` on the container's stdin (without it the script is empty).
|
||||||
|
docker run --rm -i --network "$net" \
|
||||||
|
-e OZ_CLIENT_ID="${OZ_CLIENT_ID:-big-reference-seed}" \
|
||||||
|
-e OZ_SECRET="${OZ_SECRET:-insecure-dev-secret-change-me}" \
|
||||||
|
python:3-slim python - "$1" <<'PY'
|
||||||
|
import base64, hashlib, hmac, json, os, sys, time, urllib.request
|
||||||
|
cid, sec = os.environ["OZ_CLIENT_ID"], os.environ["OZ_SECRET"]
|
||||||
|
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
def token():
|
||||||
|
hdr = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
pl = {"iss": cid, "iat": int(time.time()), "client_id": cid, "user_id": "verify", "user_representation": "verify"}
|
||||||
|
seg = b64(json.dumps(hdr, separators=(",", ":")).encode()) + b"." + b64(json.dumps(pl, separators=(",", ":")).encode())
|
||||||
|
return (seg + b"." + b64(hmac.new(sec.encode(), seg, hashlib.sha256).digest())).decode()
|
||||||
|
def get(url):
|
||||||
|
req = urllib.request.Request(url, headers={
|
||||||
|
"Authorization": "Bearer " + token(), "Accept": "application/json", "Accept-Crs": "EPSG:4326"})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
zaak = get(sys.argv[1])
|
||||||
|
status_url = zaak.get("status")
|
||||||
|
if not status_url:
|
||||||
|
print(""); sys.exit(0)
|
||||||
|
print(get(get(status_url)["statustype"]).get("omschrijving", ""))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
geannuleerd=""
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
oms="$(read_zaak_status "$zaak_url_v" 2>/dev/null | tr -d '\r' || true)"
|
||||||
|
[ "$oms" = "Geannuleerd" ] && { geannuleerd=1; break; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$geannuleerd" ] || { echo "FAIL — zaak $zaak_url_v not Geannuleerd after timeout (current status omschrijving: '$oms')" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||||
|
echo "OK — the timed-out registration's zaak was cancelled to Geannuleerd in OpenZaak"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel a zaak on document-timeout expiry (S-10c): set it to its zaaktype's cancellation statustype
|
||||||
|
// + resultaat. The domain hands over only the zaak URL; the ACL owns the ZGW resolution (§8.1).
|
||||||
|
app.MapPost("/annuleringen", async (CancelZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
await acl.CancelZaakAsync(new Uri(body.ZaakUrl), ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
|
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
|
||||||
// the read projection without reading ZGW itself (§8.1, #78).
|
// the read projection without reading ZGW itself (§8.1, #78).
|
||||||
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
|
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
|
||||||
@@ -55,6 +63,8 @@ public sealed record OpenZaakRequest(string Bsn, string Reference);
|
|||||||
|
|
||||||
public sealed record SetStatusRequest(string ZaakUrl);
|
public sealed record SetStatusRequest(string ZaakUrl);
|
||||||
|
|
||||||
|
public sealed record CancelZaakRequest(string ZaakUrl);
|
||||||
|
|
||||||
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||||
|
|
||||||
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
||||||
|
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
|
/// statustype/resultaat means "cancelled" (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ public interface IZaakGateway
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set the given zaak to the <em>cancellation</em> statustype ("Geannuleerd") and record the
|
||||||
|
/// matching cancellation resultaat ("Vervallen") — the ZGW translation of "the 30-day document term
|
||||||
|
/// lapsed" (S-10c). Distinct from <see cref="SetZaakToEindstatusAsync"/> (approval): the gateway
|
||||||
|
/// resolves both the cancellation statustype and resultaattype from the catalogus by their
|
||||||
|
/// omschrijving, POSTs the resultaat then the status, dated <paramref name="datumStatusGezet"/>.
|
||||||
|
/// </summary>
|
||||||
|
Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
|
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
|
||||||
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
|
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
|
||||||
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
|
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ namespace Acl.Infrastructure;
|
|||||||
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
|
/// <summary>The only code that talks to OpenZaak's Zaken API (ADR-0001).</summary>
|
||||||
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
|
public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : IZaakGateway
|
||||||
{
|
{
|
||||||
|
// The ACL owns which ZGW statustype/resultaat carries each domain outcome (§8.1). These
|
||||||
|
// omschrijvingen match the seeded BIG catalogus (infra/openzaak/seed_catalogus.py).
|
||||||
|
private const string GeregistreerdResultaat = "Geregistreerd"; // approval outcome
|
||||||
|
private const string GeannuleerdStatus = "Geannuleerd"; // document-timeout cancellation status (S-10c)
|
||||||
|
private const string VervallenResultaat = "Vervallen"; // document-timeout cancellation outcome (S-10c)
|
||||||
|
|
||||||
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -48,7 +54,9 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||||
|
|
||||||
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
||||||
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
|
// Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
|
||||||
|
// the first would be ambiguous (the Zaken API does not guarantee order).
|
||||||
|
var resultaattype = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, GeregistreerdResultaat, ct);
|
||||||
|
|
||||||
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
|
// 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.
|
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
|
||||||
@@ -62,6 +70,27 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
"Setting the zaak status", ct);
|
"Setting the zaak status", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||||
|
|
||||||
|
// Distinct from approval: resolve the cancellation statustype + resultaat by name (Geannuleerd
|
||||||
|
// is a non-terminal statustype, so it is never the eindstatus the approval path resolves).
|
||||||
|
var cancellationStatus = await ResolveStatustypeByOmschrijvingAsync(zaaktypeUrl, GeannuleerdStatus, ct);
|
||||||
|
var cancellationResultaat = await ResolveResultaattypeByOmschrijvingAsync(zaaktypeUrl, VervallenResultaat, ct);
|
||||||
|
|
||||||
|
// As with approval, OpenZaak wants the resultaat recorded before the status.
|
||||||
|
await PostAsync("/zaken/api/v1/resultaten",
|
||||||
|
new ResultaatDto(zaakUrl.ToString(), cancellationResultaat.ToString()),
|
||||||
|
"Setting the zaak cancellation resultaat", ct);
|
||||||
|
|
||||||
|
await PostAsync("/zaken/api/v1/statussen",
|
||||||
|
new StatusDto(zaakUrl.ToString(), cancellationStatus.ToString(),
|
||||||
|
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||||
|
"Setting the zaak cancellation status", ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
@@ -174,13 +203,23 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(eindstatus.Url);
|
return new Uri(eindstatus.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
|
/// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
|
||||||
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
|
private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault(s => s.Omschrijving == omschrijving)
|
||||||
|
?? throw new InvalidOperationException($"No '{omschrijving}' statustype found for zaaktype {zaaktypeUrl}");
|
||||||
|
return new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a specific resultaattype from the catalogus by its omschrijving (the seed defines
|
||||||
|
/// "Geregistreerd" for approval and "Vervallen" for a document-timeout cancellation).</summary>
|
||||||
|
private async Task<Uri> ResolveResultaattypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
|
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
|
||||||
var resultaattype = (page.Results ?? []).FirstOrDefault()
|
var match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
|
||||||
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
|
?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
|
||||||
return new Uri(resultaattype.Url);
|
return new Uri(match.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
|
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
|
||||||
@@ -224,7 +263,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
private sealed record StatustypeDto(
|
private sealed record StatustypeDto(
|
||||||
[property: JsonPropertyName("url")] string Url,
|
[property: JsonPropertyName("url")] string Url,
|
||||||
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
||||||
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
|
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||||
|
|
||||||
private sealed record ResultaatDto(
|
private sealed record ResultaatDto(
|
||||||
[property: JsonPropertyName("zaak")] string Zaak,
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
@@ -234,7 +274,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
||||||
|
|
||||||
private sealed record ResultaattypeDto(
|
private sealed record ResultaattypeDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||||
|
|
||||||
private sealed record CreatedDto(
|
private sealed record CreatedDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|||||||
@@ -114,6 +114,19 @@ public sealed class OpenZaakFixture : IDisposable
|
|||||||
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
|
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve a statustype by its omschrijving (e.g. the S-10c "Geannuleerd" cancellation status).</summary>
|
||||||
|
public async Task<Uri> FindStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, 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);
|
||||||
|
foreach (var st in page.GetProperty("results").EnumerateArray())
|
||||||
|
if (st.TryGetProperty("omschrijving", out var o) && o.GetString() == omschrijving)
|
||||||
|
return new Uri(st.GetProperty("url").GetString()!);
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"No '{omschrijving}' statustype 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()
|
||||||
|
|||||||
@@ -75,6 +75,39 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
|
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_zaak_records_the_geannuleerd_status_and_a_resultaat()
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
Identificatie: Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
|
||||||
|
// The zaak's current status is the Geannuleerd statustype — distinct from the approval eindstatus.
|
||||||
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
|
var statusUrl = zaak.GetProperty("status").GetString();
|
||||||
|
Assert.False(string.IsNullOrEmpty(statusUrl), "the cancelled zaak has no current status");
|
||||||
|
|
||||||
|
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
|
||||||
|
var geannuleerd = await stack.FindStatustypeByOmschrijvingAsync(zaaktype!, "Geannuleerd");
|
||||||
|
Assert.Equal(geannuleerd.ToString(), status.GetProperty("statustype").GetString());
|
||||||
|
|
||||||
|
// ...and a resultaat is recorded (OpenZaak requires it before a closing/terminal status).
|
||||||
|
Assert.False(string.IsNullOrEmpty(zaak.GetProperty("resultaat").GetString()),
|
||||||
|
"the cancelled zaak has no resultaat");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak()
|
public async Task Storing_a_diploma_creates_a_real_informatieobject_related_to_the_zaak()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ public class AclServiceTests
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled;
|
||||||
|
|
||||||
|
public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Uri? ReadReferenceFor;
|
public Uri? ReadReferenceFor;
|
||||||
|
|
||||||
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
@@ -126,6 +134,34 @@ public class AclServiceTests
|
|||||||
Assert.Null(gateway.Approved);
|
Assert.Null(gateway.Approved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_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.CancelZaakAsync(zaak);
|
||||||
|
|
||||||
|
Assert.NotNull(gateway.Cancelled);
|
||||||
|
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
||||||
|
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
||||||
|
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
||||||
|
// Cancellation must not touch the approval path.
|
||||||
|
Assert.Null(gateway.Approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_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.CancelZaakAsync(null!));
|
||||||
|
Assert.Null(gateway.Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ public class OpenZaakGatewayTests
|
|||||||
private sealed class OzRoutes
|
private sealed class OzRoutes
|
||||||
{
|
{
|
||||||
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
|
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
|
||||||
public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
|
public string ResultaattypenJson { get; init; } =
|
||||||
|
"""{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1","omschrijving":"Geregistreerd"}]}""";
|
||||||
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
|
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
|
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
|
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
|
||||||
@@ -251,6 +252,138 @@ public class OpenZaakGatewayTests
|
|||||||
Assert.True(status.Length > 0);
|
Assert.True(status.Length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_selects_the_geregistreerd_resultaat_by_name_when_several_exist()
|
||||||
|
{
|
||||||
|
// Once S-10c adds a second resultaattype (Vervallen), picking the first is ambiguous — the
|
||||||
|
// Zaken API does not guarantee order. Approval must resolve its resultaat by omschrijving.
|
||||||
|
var rec = new Recorder();
|
||||||
|
var twoResultaattypen = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = twoResultaattypen }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd\"",
|
||||||
|
rec.Sent("/resultaten").Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SetZaakToCancellationStatusAsync (document-timeout cancellation / S-10c) ---
|
||||||
|
|
||||||
|
// A catalogus with the three statustypen S-10c seeds (Geannuleerd is non-terminal, below the
|
||||||
|
// Afgehandeld eindstatus) and both resultaattypen. Cancellation must resolve "Geannuleerd" and
|
||||||
|
// "Vervallen" by omschrijving, never the approval pair.
|
||||||
|
private const string CancellationStatustypenJson = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/ontvangen","volgnummer":1,"omschrijving":"Ontvangen","isEindstatus":false},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/geannuleerd","volgnummer":2,"omschrijving":"Geannuleerd","isEindstatus":false},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/statustypen/afgehandeld","volgnummer":3,"omschrijving":"Afgehandeld","isEindstatus":true}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
private const string CancellationResultaattypenJson = """
|
||||||
|
{"results":[
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/geregistreerd","omschrijving":"Geregistreerd"},
|
||||||
|
{"url":"http://openzaak/catalogi/api/v1/resultaattypen/vervallen","omschrijving":"Vervallen"}
|
||||||
|
]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_records_the_vervallen_resultaat_then_the_geannuleerd_status_against_the_zaak()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
// Resultaat precedes status (OpenZaak requires a resultaat before a closing/terminal status).
|
||||||
|
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
|
||||||
|
|
||||||
|
var resultaat = rec.Sent("/resultaten");
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
|
||||||
|
// The cancellation resultaat (Vervallen) is chosen by name — not the approval one (Geregistreerd).
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/vervallen\"", resultaat.Body);
|
||||||
|
|
||||||
|
var status = rec.Sent("/statussen");
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
|
||||||
|
// The Geannuleerd statustype is chosen by name — not the Afgehandeld eindstatus (approval).
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/geannuleerd\"", status.Body);
|
||||||
|
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_throws_when_the_zaaktype_has_no_geannuleerd_statustype()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
// Only the approval statustypen — no "Geannuleerd".
|
||||||
|
StatustypenJson = StatustypenPage(withEindstatusFlag: true),
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Geannuleerd", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_rejects_a_null_zaak_without_calling_openzaak()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToCancellationStatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_rejects_a_null_zaaktype_without_calling_openzaak()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_surfaces_the_failure_when_recording_the_resultaat_is_rejected()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
ResultaatPostStatus = HttpStatusCode.BadRequest,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("cancellation resultaat", ex.Message);
|
||||||
|
// It fails on the resultaat, before it ever posts the status.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_surfaces_the_failure_when_recording_the_status_is_rejected()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes
|
||||||
|
{
|
||||||
|
StatustypenJson = CancellationStatustypenJson,
|
||||||
|
ResultaattypenJson = CancellationResultaattypenJson,
|
||||||
|
StatusPostStatus = HttpStatusCode.BadRequest,
|
||||||
|
})).SetZaakToCancellationStatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("cancellation status", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
|
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
|
||||||
{
|
{
|
||||||
@@ -325,7 +458,7 @@ public class OpenZaakGatewayTests
|
|||||||
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
|
||||||
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
Assert.Contains("No resultaattypen found", ex.Message);
|
Assert.Contains("'Geregistreerd' resultaattype", ex.Message);
|
||||||
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
|
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
|
||||||
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
||||||
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Big.Application;
|
|||||||
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
|
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
|
||||||
/// <see cref="OpenZaakWorker"/>.
|
/// <see cref="OpenZaakWorker"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ExpireRegistrationWorker(IRegistrationStore store)
|
public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClient acl)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
|
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
|
||||||
@@ -31,6 +31,14 @@ public sealed class ExpireRegistrationWorker(IRegistrationStore store)
|
|||||||
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Cancel the ZGW zaak before advancing the aggregate (mirrors the approval path): if the ACL
|
||||||
|
// call fails it throws, the aggregate stays open, and the job is redelivered (§8.6) — rather
|
||||||
|
// than leaving the aggregate VERLOPEN while the zaak stays open. The status guard above stops a
|
||||||
|
// redelivered job from cancelling the zaak twice (a second resultaat would be a 400). A
|
||||||
|
// registration expired before its zaak was opened has nothing to cancel.
|
||||||
|
if (registration.ZaakUrl is not null)
|
||||||
|
await acl.CancelZaakAsync(registration.ZaakUrl, ct);
|
||||||
|
|
||||||
registration.Expire();
|
registration.Expire();
|
||||||
await store.SaveAsync(registration, ct);
|
await store.SaveAsync(registration, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ public interface IAclClient
|
|||||||
/// zaak (§8.1). Returns the stored document's URL.
|
/// zaak (§8.1). Returns the stored document's URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default);
|
Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cancel the zaak on document-timeout expiry (S-10c): the 30-day document term lapsed, so the ACL
|
||||||
|
/// translates this to the ZGW cancellation status/resultaat. The domain never names statustypen.
|
||||||
|
/// </summary>
|
||||||
|
Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
new Uri(options.BaseUrl, "annuleringen"), new CancelZaakRequest(zaakUrl.ToString()), ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
@@ -56,6 +65,8 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
|||||||
|
|
||||||
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||||
|
|
||||||
|
private sealed record CancelZaakRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||||
|
|
||||||
private sealed record StoreDocumentRequest(
|
private sealed record StoreDocumentRequest(
|
||||||
[property: JsonPropertyName("zaakUrl")] string ZaakUrl,
|
[property: JsonPropertyName("zaakUrl")] string ZaakUrl,
|
||||||
[property: JsonPropertyName("contentBase64")] string ContentBase64,
|
[property: JsonPropertyName("contentBase64")] string ContentBase64,
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ public class ExpireRegistrationWorkerTests
|
|||||||
{
|
{
|
||||||
private const string Bsn = "123456782";
|
private const string Bsn = "123456782";
|
||||||
|
|
||||||
private static Registration Submitted(string processInstanceId = "proc-1")
|
// By the time the 30-day document timer fires, the zaak was opened long ago (OpenZaakAanmaken runs
|
||||||
|
// early in the flow), so a timed-out registration carries a zaak the worker can cancel.
|
||||||
|
private static Registration Submitted(string processInstanceId = "proc-1", Uri? zaakUrl = null)
|
||||||
{
|
{
|
||||||
var registration = Registration.Submit(Bsn);
|
var registration = Registration.Submit(Bsn);
|
||||||
registration.RecordProcessStarted(processInstanceId);
|
registration.RecordProcessStarted(processInstanceId);
|
||||||
|
registration.AttachZaak(zaakUrl ?? FakeAclClient.DefaultZaakUrl);
|
||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +27,7 @@ public class ExpireRegistrationWorkerTests
|
|||||||
var registration = Submitted();
|
var registration = Submitted();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
|
|
||||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
await new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
|
||||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
var saved = await store.GetAsync(registration.Id);
|
var saved = await store.GetAsync(registration.Id);
|
||||||
@@ -33,37 +36,60 @@ public class ExpireRegistrationWorkerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task An_already_verlopen_registration_is_not_persisted_again()
|
public async Task Cancels_the_zaak_via_the_acl_when_expiring_a_still_open_registration()
|
||||||
{
|
{
|
||||||
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
|
// S-10c: expiring the aggregate is not enough — the ZGW zaak must also be set to its
|
||||||
|
// cancellation status, which the ACL owns (§8.1). The worker hands the ACL the zaak URL.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/timed-out");
|
||||||
|
var registration = Submitted(zaakUrl: zaak);
|
||||||
|
store.Seed(registration);
|
||||||
|
var acl = new FakeAclClient();
|
||||||
|
|
||||||
|
await new ExpireRegistrationWorker(store, acl).HandleAsync(
|
||||||
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
|
Assert.Equal(1, acl.CancelCallCount);
|
||||||
|
Assert.Equal(zaak, acl.CancelledZaakUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_already_verlopen_registration_is_not_persisted_again_and_the_zaak_is_not_recancelled()
|
||||||
|
{
|
||||||
|
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again — and
|
||||||
|
// the ACL is not asked to cancel the zaak a second time (posting a second resultaat would 400).
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var registration = Submitted();
|
var registration = Submitted();
|
||||||
registration.Expire();
|
registration.Expire();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
|
var acl = new FakeAclClient();
|
||||||
|
|
||||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
await new ExpireRegistrationWorker(store, acl).HandleAsync(
|
||||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
Assert.Equal(0, store.SaveCount);
|
Assert.Equal(0, store.SaveCount);
|
||||||
|
Assert.Equal(0, acl.CancelCallCount);
|
||||||
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
|
public async Task An_already_resolved_registration_is_left_alone_and_the_zaak_is_not_cancelled()
|
||||||
{
|
{
|
||||||
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
|
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
|
||||||
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
|
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
|
||||||
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
|
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
|
||||||
// into a redelivery loop.
|
// into a redelivery loop — and it must not cancel the zaak of a registration it didn't expire.
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var registration = Submitted();
|
var registration = Submitted();
|
||||||
registration.Withdraw();
|
registration.Withdraw();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
|
var acl = new FakeAclClient();
|
||||||
|
|
||||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
await new ExpireRegistrationWorker(store, acl).HandleAsync(
|
||||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||||
|
|
||||||
Assert.Equal(0, store.SaveCount);
|
Assert.Equal(0, store.SaveCount);
|
||||||
|
Assert.Equal(0, acl.CancelCallCount);
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +99,12 @@ public class ExpireRegistrationWorkerTests
|
|||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
new ExpireRegistrationWorker(store).HandleAsync(
|
new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
|
||||||
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
|
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rejects_a_null_job()
|
public async Task Rejects_a_null_job()
|
||||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
|
new ExpireRegistrationWorker(new FakeRegistrationStore(), new FakeAclClient()).HandleAsync(null!));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,4 +119,14 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
|||||||
StoredDiploma = (zaakUrl, content, fileName, contentType);
|
StoredDiploma = (zaakUrl, content, fileName, contentType);
|
||||||
return Task.FromResult(DefaultDocumentUrl);
|
return Task.FromResult(DefaultDocumentUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Uri? CancelledZaakUrl { get; private set; }
|
||||||
|
public int CancelCallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
CancelCallCount++;
|
||||||
|
CancelledZaakUrl = zaakUrl;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class RegistratieVerlopenProcessorTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
|
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store, new FakeAclClient());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
|
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Feature: Een documenttermijn laten verlopen
|
|||||||
When the 30-day document timer fires
|
When the 30-day document timer fires
|
||||||
And the document-timeout worker runs
|
And the document-timeout worker runs
|
||||||
Then the registration is verlopen
|
Then the registration is verlopen
|
||||||
|
And the zaak is cancelled in ZGW
|
||||||
|
|
||||||
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
|
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
|
||||||
Given a registration parked at the WachtOpDocumenten task
|
Given a registration parked at the WachtOpDocumenten task
|
||||||
@@ -21,3 +22,4 @@ Feature: Een documenttermijn laten verlopen
|
|||||||
And the 30-day document timer fires
|
And the 30-day document timer fires
|
||||||
And the document-timeout worker runs
|
And the document-timeout worker runs
|
||||||
Then the registration is not verlopen
|
Then the registration is not verlopen
|
||||||
|
And the zaak is not cancelled in ZGW
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ namespace Acceptance.Steps;
|
|||||||
[Scope(Feature = "Een documenttermijn laten verlopen")]
|
[Scope(Feature = "Een documenttermijn laten verlopen")]
|
||||||
public sealed class EenDocumentTermijnVerlopenSteps
|
public sealed class EenDocumentTermijnVerlopenSteps
|
||||||
{
|
{
|
||||||
|
private static readonly Uri ZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-timeout");
|
||||||
|
|
||||||
private readonly InMemoryDocumentTimeoutClient _flowable = new();
|
private readonly InMemoryDocumentTimeoutClient _flowable = new();
|
||||||
private readonly Support.InMemoryRegistrationStore _store = new();
|
private readonly Support.InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryAclClient _acl = new();
|
||||||
private Registration _registration = null!;
|
private Registration _registration = null!;
|
||||||
private string _processInstanceId = "";
|
private string _processInstanceId = "";
|
||||||
|
|
||||||
@@ -26,6 +29,9 @@ public sealed class EenDocumentTermijnVerlopenSteps
|
|||||||
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
|
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
|
||||||
{
|
{
|
||||||
_registration = Registration.Submit("123456782");
|
_registration = Registration.Submit("123456782");
|
||||||
|
// By the time it parks at WachtOpDocumenten its zaak has been opened (OpenZaakAanmaken runs
|
||||||
|
// earlier), so a timeout has a zaak to cancel.
|
||||||
|
_registration.AttachZaak(ZaakUrl);
|
||||||
await _store.SaveAsync(_registration);
|
await _store.SaveAsync(_registration);
|
||||||
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
|
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
|
||||||
}
|
}
|
||||||
@@ -39,7 +45,7 @@ public sealed class EenDocumentTermijnVerlopenSteps
|
|||||||
[When("the document-timeout worker runs")]
|
[When("the document-timeout worker runs")]
|
||||||
public async Task WhenTheTimeoutWorkerRuns()
|
public async Task WhenTheTimeoutWorkerRuns()
|
||||||
=> await new RegistratieVerlopenProcessor(
|
=> await new RegistratieVerlopenProcessor(
|
||||||
_flowable, new ExpireRegistrationWorker(_store),
|
_flowable, new ExpireRegistrationWorker(_store, _acl),
|
||||||
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
[Then("the registration is verlopen")]
|
[Then("the registration is verlopen")]
|
||||||
@@ -49,4 +55,10 @@ public sealed class EenDocumentTermijnVerlopenSteps
|
|||||||
[Then("the registration is not verlopen")]
|
[Then("the registration is not verlopen")]
|
||||||
public async Task ThenTheRegistrationIsNotVerlopen()
|
public async Task ThenTheRegistrationIsNotVerlopen()
|
||||||
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
|
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
|
||||||
|
|
||||||
|
[Then("the zaak is cancelled in ZGW")]
|
||||||
|
public void ThenTheZaakIsCancelled() => Assert.Equal(ZaakUrl, _acl.CancelledZaakUrl);
|
||||||
|
|
||||||
|
[Then("the zaak is not cancelled in ZGW")]
|
||||||
|
public void ThenTheZaakIsNotCancelled() => Assert.Null(_acl.CancelledZaakUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ public sealed class InMemoryAclClient : IAclClient
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Uri? CancelledZaakUrl { get; private set; }
|
||||||
|
|
||||||
|
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
CancelledZaakUrl = zaakUrl;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; }
|
public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; }
|
||||||
|
|
||||||
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
|
|
||||||
public ZaakRequest? Captured { get; private set; }
|
public ZaakRequest? Captured { get; private set; }
|
||||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
||||||
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; }
|
||||||
|
|
||||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -25,6 +26,12 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SetZaakToCancellationStatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Cancelled = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
=> Task.FromResult("ACC-REF-1");
|
=> Task.FromResult("ACC-REF-1");
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export default defineConfig({
|
|||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
expect: { timeout: 15_000 },
|
expect: { timeout: 15_000 },
|
||||||
retries: 1,
|
retries: 1,
|
||||||
|
// Run the specs serially. Each spec drives a full `channel: 'chromium'` browser, and the e2e
|
||||||
|
// shares an 8 GB runner with the entire compose stack (OpenZaak, NRC, Keycloak, Flowable, 4×
|
||||||
|
// Postgres, every service + 3 portals). Two parallel browsers exhaust memory and the renderer is
|
||||||
|
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
||||||
|
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
||||||
|
workers: 1,
|
||||||
reporter: [['list']],
|
reporter: [['list']],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -26,7 +32,13 @@ export default defineConfig({
|
|||||||
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
||||||
channel: 'chromium',
|
channel: 'chromium',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
args: [
|
||||||
|
`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`,
|
||||||
|
// Write Chromium's shared memory to /tmp instead of the container's small /dev/shm, so a
|
||||||
|
// large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces
|
||||||
|
// alongside the single worker above).
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
|||||||
Reference in New Issue
Block a user