Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af83194e79 | ||
|
|
756e718ee2 | ||
|
|
53e9564f94 | ||
|
|
5325a99755 | ||
|
|
036005e486 | ||
|
|
ae7fc1b8f0 | ||
|
|
1abd4b6472 | ||
|
|
d354fe507a | ||
|
|
4c516cdad3 | ||
|
|
9d327bbd81 | ||
|
|
3c344caa29 | ||
|
|
ecad42873c | ||
|
|
4b4b58b486 | ||
|
|
dca9455bb5 | ||
|
|
aaa7135fb1 |
+1
-9
@@ -215,9 +215,7 @@ 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 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.
|
**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.
|
||||||
|
|
||||||
**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)
|
||||||
|
|
||||||
@@ -239,12 +237,6 @@ 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`)*
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
# 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".
|
|
||||||
+1
-21
@@ -461,24 +461,4 @@ 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).
|
||||||
|
|
||||||
## S-10c — the ZGW zaak is cancelled when the document term lapses (#106)
|
> Setting the ZGW zaak to a cancellation status on 30-day expiry is a follow-up (S-10c, #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.parse, urllib.request
|
import base64, hashlib, hmac, json, os, sys, time, urllib.error, 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,12 +77,8 @@ 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, "Geannuleerd"), (3, "Afgehandeld")]:
|
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "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})
|
||||||
@@ -99,42 +95,25 @@ 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")
|
||||||
|
|
||||||
# Two resultaattypen, keyed by omschrijving so each is created independently (idempotent):
|
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
|
||||||
# "Geregistreerd" — the approval outcome (S-09b)
|
print("skip resultaattype Geregistreerd")
|
||||||
# "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:
|
||||||
# Anchor on the procestype of an arbitrary resultaat, then fetch that procestype's resultaten so
|
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
|
||||||
# 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_list = omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"]
|
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
|
||||||
|
# The selectielijstklasse and the zaaktype must share a procestype.
|
||||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": procestype})
|
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["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", {
|
||||||
for i, (naam, archiefnominatie) in enumerate(wanted):
|
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
||||||
if naam in have_rt:
|
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
||||||
print(f"skip resultaattype {naam}")
|
"archiefnominatie": "blijvend_bewaren",
|
||||||
continue
|
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||||
st, body = api("POST", "/resultaattypen", {
|
})
|
||||||
"zaaktype": zt["url"], "omschrijving": naam,
|
if st != 201:
|
||||||
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
|
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
||||||
"archiefnominatie": archiefnominatie,
|
print("create resultaattype Geregistreerd")
|
||||||
"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,11 +309,10 @@ 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/S-10c: document timeout. A registration parks at WachtOpDocumenten and — unlike every block
|
# ── S-10a: document timeout. A registration parks at WachtOpDocumenten and — unlike every block above —
|
||||||
# above — its documents never arrive. We fire its 30-day boundary timer early via the management API;
|
# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
|
||||||
# the INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task.
|
# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
|
||||||
# The domain's timeout worker acquires it, cancels the ZGW zaak via the ACL (S-10c), and expires the
|
# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
|
||||||
# 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" \
|
||||||
@@ -355,47 +354,4 @@ 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,14 +32,6 @@ 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) =>
|
||||||
@@ -63,8 +55,6 @@ 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,18 +30,6 @@ 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,15 +13,6 @@ 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,12 +8,6 @@ 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);
|
||||||
@@ -54,9 +48,7 @@ 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);
|
||||||
// Resolve the approval resultaat by name: once S-10c adds the Vervallen resultaattype, taking
|
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
|
||||||
// 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.
|
||||||
@@ -70,27 +62,6 @@ 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);
|
||||||
@@ -203,23 +174,13 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(eindstatus.Url);
|
return new Uri(eindstatus.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve a specific statustype from the catalogus by its omschrijving (e.g. "Geannuleerd").</summary>
|
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
|
||||||
private async Task<Uri> ResolveStatustypeByOmschrijvingAsync(Uri zaaktypeUrl, string omschrijving, CancellationToken ct)
|
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, 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 match = (page.Results ?? []).FirstOrDefault(r => r.Omschrijving == omschrijving)
|
var resultaattype = (page.Results ?? []).FirstOrDefault()
|
||||||
?? throw new InvalidOperationException($"No '{omschrijving}' resultaattype found for zaaktype {zaaktypeUrl}");
|
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
|
||||||
return new Uri(match.Url);
|
return new Uri(resultaattype.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).
|
||||||
@@ -263,8 +224,7 @@ 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,
|
||||||
@@ -274,8 +234,7 @@ 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,19 +114,6 @@ 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,39 +75,6 @@ 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,14 +23,6 @@ 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)
|
||||||
@@ -134,34 +126,6 @@ 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,8 +173,7 @@ 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; } =
|
public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
|
||||||
"""{"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;
|
||||||
@@ -252,138 +251,6 @@ 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()
|
||||||
{
|
{
|
||||||
@@ -458,7 +325,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("'Geregistreerd' resultaattype", ex.Message);
|
Assert.Contains("No resultaattypen found", 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, IAclClient acl)
|
public sealed class ExpireRegistrationWorker(IRegistrationStore store)
|
||||||
{
|
{
|
||||||
/// <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,14 +31,6 @@ public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClien
|
|||||||
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,12 +59,6 @@ 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,15 +31,6 @@ 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);
|
||||||
@@ -65,8 +56,6 @@ 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,13 +10,10 @@ public class ExpireRegistrationWorkerTests
|
|||||||
{
|
{
|
||||||
private const string Bsn = "123456782";
|
private const string Bsn = "123456782";
|
||||||
|
|
||||||
// By the time the 30-day document timer fires, the zaak was opened long ago (OpenZaakAanmaken runs
|
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +24,7 @@ public class ExpireRegistrationWorkerTests
|
|||||||
var registration = Submitted();
|
var registration = Submitted();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
|
|
||||||
await new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
|
await new ExpireRegistrationWorker(store).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);
|
||||||
@@ -36,60 +33,37 @@ public class ExpireRegistrationWorkerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Cancels_the_zaak_via_the_acl_when_expiring_a_still_open_registration()
|
public async Task An_already_verlopen_registration_is_not_persisted_again()
|
||||||
{
|
{
|
||||||
// S-10c: expiring the aggregate is not enough — the ZGW zaak must also be set to its
|
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
|
||||||
// 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, acl).HandleAsync(
|
await new ExpireRegistrationWorker(store).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_zaak_is_not_cancelled()
|
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
|
||||||
{
|
{
|
||||||
// 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 — and it must not cancel the zaak of a registration it didn't expire.
|
// into a redelivery loop.
|
||||||
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, acl).HandleAsync(
|
await new ExpireRegistrationWorker(store).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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +73,12 @@ public class ExpireRegistrationWorkerTests
|
|||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
|
new ExpireRegistrationWorker(store).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(), new FakeAclClient()).HandleAsync(null!));
|
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,14 +119,4 @@ 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, new FakeAclClient());
|
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
|
||||||
|
|
||||||
[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,7 +14,6 @@ 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
|
||||||
@@ -22,4 +21,3 @@ 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,11 +17,8 @@ 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 = "";
|
||||||
|
|
||||||
@@ -29,9 +26,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -45,7 +39,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, _acl),
|
_flowable, new ExpireRegistrationWorker(_store),
|
||||||
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||||
|
|
||||||
[Then("the registration is verlopen")]
|
[Then("the registration is verlopen")]
|
||||||
@@ -55,10 +49,4 @@ 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,14 +60,6 @@ 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,7 +12,6 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -26,12 +25,6 @@ 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,12 +12,6 @@ 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,
|
||||||
@@ -32,13 +26,7 @@ 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: [
|
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
||||||
`--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