Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd24d17a3 | ||
|
|
d5e5fa254c | ||
|
|
a693137c7c | ||
|
|
a940a6c9ce | ||
|
|
97ef3b9535 | ||
|
|
bf234e1322 | ||
|
|
c8fdfbb699 |
+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`)*
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
@@ -114,6 +114,11 @@ local:
|
|||||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||||
|
|
||||||
|
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||||
|
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||||
|
verify-local:
|
||||||
|
bash infra/run-local-flow-check.sh
|
||||||
|
|
||||||
## local-down: stop and remove the bind-mount stack
|
## local-down: stop and remove the bind-mount stack
|
||||||
local-down:
|
local-down:
|
||||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||||
|
|||||||
@@ -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".
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-22
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||||
|
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||||
|
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||||
|
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||||
|
could not complete the flow:
|
||||||
|
|
||||||
|
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||||
|
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||||
|
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||||
|
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||||
|
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||||
|
and the openbaar register stayed empty (S-06).
|
||||||
|
|
||||||
|
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||||
|
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||||
|
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||||
|
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||||
|
the compose file is written and cannot be a static value.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||||
|
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||||
|
|
||||||
|
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||||
|
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||||
|
identical to the CI `flowable-init`. Idempotent.
|
||||||
|
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||||
|
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||||
|
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||||
|
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||||
|
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||||
|
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||||
|
(service_completed_successfully)`.
|
||||||
|
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||||
|
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||||
|
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||||
|
cycle with the ACL bootstrap.
|
||||||
|
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||||
|
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||||
|
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||||
|
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||||
|
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||||
|
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||||
|
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||||
|
in the openbaar register — the red-to-green test for all three gaps.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||||
|
the slice's stated outcome.
|
||||||
|
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||||
|
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||||
|
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||||
|
image and the CI stack are untouched.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||||
|
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||||
|
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||||
|
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||||
|
running with a placeholder).
|
||||||
|
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||||
|
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||||
|
idempotent re-seed reports current-IP URLs.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||||
|
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||||
|
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||||
|
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||||
|
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||||
|
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||||
|
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||||
|
browser. Rejected.
|
||||||
|
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||||
|
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||||
|
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||||
|
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||||
+48
-1
@@ -5,6 +5,33 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||||
|
|
||||||
|
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||||
|
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
||||||
|
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
||||||
|
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
||||||
|
empty, and the openbaar register showed nothing.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
||||||
|
make local
|
||||||
|
|
||||||
|
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
||||||
|
# the werkbak, and the reference appears in the openbaar register:
|
||||||
|
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
||||||
|
|
||||||
|
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||||
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||||
|
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||||
|
```
|
||||||
|
|
||||||
|
> The zaaktype UUID is server-assigned, so `local-seed` writes the real URL into a shared volume as
|
||||||
|
> `acl.env` and the ACL sources it on startup (ADR-0020). The cleaner long-term fix — the ACL
|
||||||
|
> resolving its zaaktype by `identificatie` — is tracked separately as S-27 (#113).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||||
|
|
||||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||||
@@ -461,4 +488,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).
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||||
# macOS, or rootless Podman on Linux).
|
# macOS, or rootless Podman on Linux).
|
||||||
#
|
#
|
||||||
|
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
|
||||||
|
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
|
||||||
|
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
|
||||||
|
# completes the whole flow with no manual steps. `make verify-local` asserts it.
|
||||||
|
#
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||||
@@ -257,28 +262,65 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
volumes:
|
volumes:
|
||||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||||
|
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
|
||||||
|
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
|
||||||
|
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
|
||||||
|
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
|
||||||
|
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
|
||||||
|
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
|
||||||
|
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
|
||||||
|
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
|
||||||
|
local-seed:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
|
||||||
|
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
|
||||||
|
- seed-env:/out
|
||||||
|
command: ["sh", "/work/seed-zaaktype.sh"]
|
||||||
|
depends_on:
|
||||||
|
openzaak:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||||
acl:
|
acl:
|
||||||
build:
|
build:
|
||||||
context: ../services/acl
|
context: ../services/acl
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/acl:dev
|
image: register-referentie/acl:dev
|
||||||
|
# The base/zaaktype/informatieobjecttype below are PLACEHOLDERS. The real, server-assigned
|
||||||
|
# values are written by the local-seed one-shot into seed-env:/seed/acl.env, which the entrypoint
|
||||||
|
# sources (set -a) so they override these before the app starts (S-B04, #110, ADR-0020). Sourcing
|
||||||
|
# a runtime-generated env file is why we override the entrypoint here rather than use `env_file:`
|
||||||
|
# (which compose reads at parse time, before the seed has run).
|
||||||
|
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||||
environment:
|
environment:
|
||||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
||||||
Acl__OpenZaak__ClientId: big-reference-seed
|
Acl__OpenZaak__ClientId: big-reference-seed
|
||||||
@@ -286,9 +328,12 @@ services:
|
|||||||
Acl__Defaults__Bronorganisatie: "517439943"
|
Acl__Defaults__Bronorganisatie: "517439943"
|
||||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeUrl: http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000
|
||||||
|
Acl__Defaults__InformatieobjecttypeUrl: http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
|
volumes:
|
||||||
|
- seed-env:/seed:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -298,6 +343,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
openzaak:
|
openzaak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
local-seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||||
@@ -400,6 +447,31 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
|
||||||
|
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
|
||||||
|
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
|
||||||
|
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
|
||||||
|
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
|
||||||
|
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
|
||||||
|
# for the event-subscriber without creating a cycle with the ACL bootstrap.
|
||||||
|
nrc-subscribe:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
|
||||||
|
environment:
|
||||||
|
NRC_BASE: http://nrc-web:8000
|
||||||
|
SINK_HOST: event-subscriber
|
||||||
|
SINK_PORT: "8080"
|
||||||
|
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
|
command: ["python", "/work/register-abonnement.py"]
|
||||||
|
depends_on:
|
||||||
|
nrc-web:
|
||||||
|
condition: service_healthy
|
||||||
|
event-subscriber:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -492,6 +564,8 @@ volumes:
|
|||||||
nrc-db:
|
nrc-db:
|
||||||
flowable-db:
|
flowable-db:
|
||||||
projection-db:
|
projection-db:
|
||||||
|
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||||
|
seed-env:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cg:
|
cg:
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||||
|
|
||||||
|
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||||
|
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||||
|
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||||
|
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||||
|
the test zaak it also creates).
|
||||||
|
|
||||||
|
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||||
|
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||||
|
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
|
||||||
|
abonnement first, then registers one for the current IP. Stdlib only.
|
||||||
|
|
||||||
|
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||||
|
"""
|
||||||
|
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
|
||||||
|
|
||||||
|
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
|
||||||
|
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
|
||||||
|
SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
||||||
|
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||||
|
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
|
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
seg = (
|
||||||
|
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||||
|
+ b"."
|
||||||
|
+ b64(json.dumps(
|
||||||
|
{"iss": CID, "iat": int(time.time()), "client_id": CID,
|
||||||
|
"user_id": "local-seed", "user_representation": "local-seed"},
|
||||||
|
separators=(",", ":")).encode())
|
||||||
|
)
|
||||||
|
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, url, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||||
|
"Authorization": "Bearer " + token(),
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return r.status, (json.loads(raw) if raw else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
return e.code, (json.loads(raw) if raw else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ip = socket.gethostbyname(SINK_HOST)
|
||||||
|
callback = f"http://{ip}:{SINK_PORT}/notifications"
|
||||||
|
|
||||||
|
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
|
||||||
|
# fresh one for the current event-subscriber IP.
|
||||||
|
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||||
|
for ab in (body or []) if status == 200 else []:
|
||||||
|
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||||
|
if ab.get("callbackUrl") == callback:
|
||||||
|
print(f"abonnement already current: {ab['url']}")
|
||||||
|
return
|
||||||
|
call("DELETE", ab["url"])
|
||||||
|
print(f"removed stale abonnement {ab['url']}")
|
||||||
|
|
||||||
|
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||||
|
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||||
|
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||||
|
if status != 201:
|
||||||
|
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||||
|
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+35
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
|
||||||
|
#
|
||||||
|
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
|
||||||
|
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
|
||||||
|
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
|
||||||
|
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
|
||||||
|
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
|
||||||
|
#
|
||||||
|
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
|
||||||
|
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
|
||||||
|
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
|
||||||
|
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
|
||||||
|
OZ_BASE="http://${oz_ip}:8000"
|
||||||
|
export OZ_BASE OZ_PUBLISH=1
|
||||||
|
|
||||||
|
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||||
|
out="$(python3 /work/seed_catalogus.py)"
|
||||||
|
echo "$out"
|
||||||
|
|
||||||
|
zt="$(printf '%s\n' "$out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||||
|
iot="$(printf '%s\n' "$out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
||||||
|
[ -n "$zt" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||||
|
[ -n "$iot" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The ACL entrypoint sources this; these keys override the placeholder defaults in the compose file.
|
||||||
|
cat > /out/acl.env <<EOF
|
||||||
|
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||||
|
Acl__Defaults__ZaaktypeUrl=${zt}
|
||||||
|
Acl__Defaults__InformatieobjecttypeUrl=${iot}
|
||||||
|
EOF
|
||||||
|
echo ">> wrote /out/acl.env (base=${OZ_BASE}/ zaaktype=${zt})"
|
||||||
@@ -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)}")
|
||||||
|
|
||||||
|
for i, (naam, archiefnominatie) in enumerate(wanted):
|
||||||
|
if naam in have_rt:
|
||||||
|
print(f"skip resultaattype {naam}")
|
||||||
|
continue
|
||||||
st, body = api("POST", "/resultaattypen", {
|
st, body = api("POST", "/resultaattypen", {
|
||||||
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
"zaaktype": zt["url"], "omschrijving": naam,
|
||||||
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
"resultaattypeomschrijving": oms_list[i]["url"], "selectielijstklasse": resultaten[i]["url"],
|
||||||
"archiefnominatie": "blijvend_bewaren",
|
"archiefnominatie": archiefnominatie,
|
||||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||||
})
|
})
|
||||||
if st != 201:
|
if st != 201:
|
||||||
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
sys.exit(f"create resultaattype {naam} -> {st}: {json.dumps(body, indent=2)}")
|
||||||
print("create resultaattype Geregistreerd")
|
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
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
|
||||||
|
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
|
||||||
|
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
|
||||||
|
# fixes:
|
||||||
|
#
|
||||||
|
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
|
||||||
|
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
|
||||||
|
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
|
||||||
|
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
|
||||||
|
#
|
||||||
|
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOM=${DOM:-http://localhost:8130} # domain
|
||||||
|
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
|
||||||
|
BSN=${BSN:-123456782}
|
||||||
|
# A minimal, valid PDF, base64-encoded (the diploma upload).
|
||||||
|
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
|
||||||
|
|
||||||
|
echo ">> 1. submit a registration (no manual seeding expected)"
|
||||||
|
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
id="${loc##*/}"
|
||||||
|
echo " accepted: $id"
|
||||||
|
|
||||||
|
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
|
||||||
|
zaak=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
|
||||||
|
[ -n "$zaak" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
|
||||||
|
echo " zaak opened: $zaak"
|
||||||
|
|
||||||
|
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
|
||||||
|
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
|
||||||
|
echo " documents accepted (204)"
|
||||||
|
|
||||||
|
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
|
||||||
|
in_werkbak=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$in_werkbak" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
|
||||||
|
echo " in the werkbak"
|
||||||
|
|
||||||
|
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
|
||||||
|
public=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$public" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
|
||||||
|
echo " visible in the openbaar register"
|
||||||
|
|
||||||
|
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
|
||||||
@@ -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