feat(workflow): document-wait task + 30-day timeout cancellation (S-10a, closes #102) #105
+12
-2
@@ -199,9 +199,19 @@ _Split from the original S-09 — scoped to the portal only; the approval flow i
|
||||
|
||||
### S-10 · Document upload + boundary timer for document timeout (Flow 2)
|
||||
|
||||
**Outcome:** BPMN extended with a "wacht op documenten" user task with a 30-day boundary timer. Self-service portal supports diploma upload. On timeout the case is cancelled.
|
||||
Split (issue #11 closed) into two independently-demoable slices per §13 — the original spanned six net-new surfaces including a new ZGW boundary:
|
||||
|
||||
**Acceptance:** BDD scenarios for both branches; integration tests for the timer firing.
|
||||
#### S-10a · Document-wait task + 30-day timeout cancellation + provision trigger — #102
|
||||
|
||||
**Outcome:** BPMN gains a `WachtOpDocumenten` user task with a 30-day (P30D) interrupting boundary timer. On timeout the case is cancelled — the timer runs to a dedicated cancel end-event and the domain aggregate moves to a new terminal status `Verlopen` via an external-worker (mirrors S-14 escalation / S-11 withdrawal). "Documents received" is wired end-to-end (domain endpoint + BFF + a "Documenten aanleveren" button on the self-service page) so the walking-skeleton e2e stays green — but the document is **not yet stored** in ZGW; that is S-10b.
|
||||
|
||||
**Acceptance:** BDD both branches (documents-in-time vs timeout-cancel); live timer-fire via the management-API "move" idiom; the registration e2e provides documents before the behandelaar step.
|
||||
|
||||
#### S-10b · Real diploma upload stored via the ACL Documenten API — #103
|
||||
|
||||
**Outcome:** the self-service "Documenten aanleveren" action becomes a real file upload; the document is stored in the ZGW Documenten (DRC) API and related to the zaak, with all document calls routed through the ACL (§8.1), and the zaak is set to a cancellation status on timeout expiry. Builds on the S-10a trigger/wait. Depends on #102.
|
||||
|
||||
**Acceptance:** ACL Documenten gateway integration test; Playwright e2e uploads a real document; the openbaar/zaak reflects the stored document.
|
||||
|
||||
### S-11 · Withdrawal (Flow 3)
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@
|
||||
<p utrecht-paragraph role="status">
|
||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||
</p>
|
||||
@if (documentsProvided()) {
|
||||
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
|
||||
} @else {
|
||||
@if (provideDocumentsFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
<button
|
||||
utrecht-button
|
||||
appearance="primary-action-button"
|
||||
type="button"
|
||||
[disabled]="providingDocuments()"
|
||||
(click)="provideDocuments()"
|
||||
>
|
||||
Documenten aanleveren
|
||||
</button>
|
||||
}
|
||||
@if (withdrawFailed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||
|
||||
@@ -20,10 +20,12 @@ class FakeAuth extends AuthService {
|
||||
function providers(
|
||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{
|
||||
@@ -31,6 +33,7 @@ function providers(
|
||||
useValue: {
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -80,6 +83,37 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers to provide documents after submitting, and doing so confirms', async () => {
|
||||
const { provideDocuments, providers: p } = providers();
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
// The provide-documents call is keyed by the reference the submit returned, and the page confirms.
|
||||
expect(provideDocuments).toHaveBeenCalledWith('reg-9');
|
||||
expect(await screen.findByText(/documenten.*aangeleverd/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a provide-documents failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
vi.fn().mockReturnValue(of(undefined)),
|
||||
vi.fn().mockReturnValue(throwError(() => new Error('documents rejected'))),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||
await screen.findByText(/ontvangen/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /documenten aanleveren/i }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||
expect(screen.queryByText(/aangeleverd/i)).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /documenten aanleveren/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||
const { providers: p } = providers(
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
|
||||
@@ -26,6 +26,9 @@ export class RegistrationPage {
|
||||
protected readonly withdrawing = signal(false);
|
||||
protected readonly withdrawn = signal(false);
|
||||
protected readonly withdrawFailed = signal(false);
|
||||
protected readonly providingDocuments = signal(false);
|
||||
protected readonly documentsProvided = signal(false);
|
||||
protected readonly provideDocumentsFailed = signal(false);
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
@@ -44,6 +47,26 @@ export class RegistrationPage {
|
||||
});
|
||||
}
|
||||
|
||||
provideDocuments(): void {
|
||||
const reference = this.reference();
|
||||
if (!reference) {
|
||||
return;
|
||||
}
|
||||
this.providingDocuments.set(true);
|
||||
this.provideDocumentsFailed.set(false);
|
||||
this.bff.postSelfServiceRegistrationsIdDocuments(reference).subscribe({
|
||||
next: () => {
|
||||
this.documentsProvided.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||
error: () => {
|
||||
this.provideDocumentsFailed.set(true);
|
||||
this.providingDocuments.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
withdraw(): void {
|
||||
const reference = this.reference();
|
||||
if (!reference) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# ADR-0017: A document-wait task with a 30-day interrupting timer cancels the registration
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-20
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-10a (#102); proposal #104; split from S-10 (#11). Builds on ADR-0009 (external-task
|
||||
worker / Workflow Client), ADR-0014 (withdrawal cancels the process), ADR-0015 (beoordeling
|
||||
escalation — the boundary-timer + external-worker pattern), ADR-0016 (diploma-eligibility DMN).
|
||||
|
||||
## Context
|
||||
|
||||
Flow 2 (PRD §5) requires the citizen to supply documents (their diploma) after submitting. The
|
||||
registratie process must park waiting for those documents and, if they do not arrive within 30 days,
|
||||
cancel the case. S-10 was split (§13): **S-10a** is this workflow/timeout spine (backend only);
|
||||
**S-10b** wires the actual upload (portal → BFF → domain → ACL → Documenten API) that completes the
|
||||
wait. This ADR records the spine: where the wait sits, how the timeout cancels, and how the domain
|
||||
aggregate stays in sync.
|
||||
|
||||
## Decision
|
||||
|
||||
**A `WachtOpDocumenten` user task is inserted immediately after `OpenZaakAanmaken`, carrying an
|
||||
`cancelActivity="true"` (interrupting) `P30D` boundary timer. "Documents received" completes the task
|
||||
and the process continues into the diploma-eligibility routing; on timeout the timer cancels the task,
|
||||
runs a `RegistratieVerlopen` external-worker task, and ends the process at `endVerlopen`. A domain
|
||||
worker expires the correlated aggregate to a new terminal status `Verlopen`.**
|
||||
|
||||
- **Where the wait sits.** Right after the zaak is opened, before the diploma-eligibility DMN: the zaak
|
||||
exists, then the process waits for documents; on receipt it continues to the DMN routing → Beoordelen
|
||||
(ADR-0016). The wait gates the whole assessment, so it precedes the routing rather than sitting
|
||||
between the gateway and Beoordelen.
|
||||
- **Interrupting timer, mirroring the existing constructs.** Unlike the S-14 escalation timer
|
||||
(non-interrupting — the Beoordelen task stays open), this timer is interrupting: when it fires the
|
||||
wait token is consumed and the case is cancelled, like the S-11 withdrawal boundary (ADR-0014). The
|
||||
timeout branch runs a `RegistratieVerlopen` external-worker task (topic mirrors
|
||||
`OpenZaakAanmaken`/`BeoordelingEscaleren`) → `endVerlopen`.
|
||||
- **The domain stays authoritative.** The `RegistratieVerlopen` job carries the `registrationId`; the
|
||||
`RegistratieVerlopenProcessor` drains it and the `ExpireRegistrationWorker` loads the aggregate and
|
||||
calls `Registration.Expire()`, moving it to the new terminal status `Verlopen`. This keeps the
|
||||
aggregate — which the projection/openbaar view reads — the source of truth, exactly as escalation and
|
||||
withdrawal do. Idempotent per §8.6: a redelivered job whose aggregate is already `Verlopen` completes
|
||||
without persisting again; an unknown registration throws so the job is redelivered.
|
||||
- **Documents-in-time transition.** `IWorkflowClient.CompleteDocumentWaitAsync(processInstanceId)`
|
||||
completes the `WachtOpDocumenten` task (the Workflow Client remains the only code that talks to
|
||||
Flowable, §8.2). It is best-effort — a no-op if the instance already left the wait (continued, or
|
||||
timed out). The trigger is wired end-to-end in S-10a: a `ProvideDocuments` application use case behind
|
||||
an owner-scoped domain endpoint `POST /registrations/{id}/documents`, a BFF passthrough
|
||||
`POST /self-service/registrations/{id}/documents` (bsn from the DigiD token), and a "Documenten
|
||||
aanleveren" action on the self-service page — so the walking-skeleton e2e stays green (a registration
|
||||
can still reach the behandelaar). **S-10b replaces the stub trigger with a real file upload stored in
|
||||
the ZGW Documenten (DRC) API via the ACL**; the completion of the wait is unchanged.
|
||||
- *Why the trigger lives here, not in S-10b:* inserting the `WachtOpDocumenten` gate without any way
|
||||
to pass it breaks the submit→beoordeling e2e (a merge gate). Splitting "gate" from "means to pass
|
||||
the gate" across slices would leave `main` red, so S-10a owns both; S-10b is purely the ZGW storage
|
||||
behind the same action.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The wait/timeout is a first-class workflow construct that reuses the boundary-timer + external-worker
|
||||
pattern already proven by S-14, so the domain change is small and additive: one terminal status, one
|
||||
worker trio (worker + processor + pump), one Workflow Client method.
|
||||
- §8 stays clean: the Workflow Client is still the only Flowable caller, and no new ZGW boundary is
|
||||
introduced in S-10a.
|
||||
- The timeout is verified live (verify-domain fires the P30D timer via the management-API "move" idiom
|
||||
and asserts the domain reaches `Verlopen`), consistent with ADR-0009/0014/0015.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Every registration now parks at `WachtOpDocumenten` before Beoordelen, so the other flows must supply
|
||||
documents first: the live-check blocks (S-11/S-12b/S-13/S-14) complete the task via Flowable, and the
|
||||
registration e2e clicks "Documenten aanleveren". A small, explicit step, but it touches every path
|
||||
through the process.
|
||||
- On expiry S-10a cancels the *process* and marks the aggregate `Verlopen` but does **not** set the ZGW
|
||||
*zaak* to a cancellation status — that needs a new ACL method + statustype seeding, which overlaps
|
||||
S-10b's ACL/infra work. Deferred to S-10b (or a follow-up); noted here as the S-10a/S-10b boundary.
|
||||
- Withdrawing while parked at `WachtOpDocumenten` marks the aggregate `Ingetrokken` but does not cancel
|
||||
the process (the withdrawal message boundary is on `Beoordelen`); the timeout worker tolerates this
|
||||
by no-op'ing on an already-resolved aggregate. Extending withdrawal to the wait state is a follow-up.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Pure-BPMN cancellation (timer → end event, no worker).** Rejected: the domain aggregate would then
|
||||
be out of sync with the cancelled process, and the openbaar/projection view reads the aggregate's
|
||||
status — the case would still look open.
|
||||
- **Wait task between the gateway and Beoordelen.** Rejected: documents gate the whole assessment
|
||||
(including the CBGV-advies routing), so the wait belongs before the DMN, not after it.
|
||||
- **A dedicated timeout status per branch vs. reusing an open-state guard.** `Expire()` reuses the same
|
||||
`RequireOpenForDecision` guard as withdrawal/decision, so only an `INGEDIEND`/`IN_BEHANDELING`
|
||||
registration can lapse and the terminal states stay mutually exclusive — no new guard logic.
|
||||
+53
-1
@@ -361,7 +361,8 @@ DOM=http://localhost:8080 # domain service
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782","diplomaOrigin":"Buitenlands"}' | grep -i '^location:'
|
||||
#
|
||||
# 2. Once the zaak is opened, the process parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
||||
# 2. Once the zaak is opened, the process first parks at WachtOpDocumenten (S-10a); complete that task
|
||||
# (documents received) — then it parks at the CBGV-advies task (NOT Beoordelen). In Flowable:
|
||||
FL=http://localhost:8090/flowable-rest/service
|
||||
curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d '{"processDefinitionKey":"registratie","taskDefinitionKey":"CBGVAdvies"}' | python3 -m json.tool
|
||||
@@ -382,3 +383,54 @@ domestic: `Beoordelen` directly (§8.2, ADR-0016).
|
||||
> The domestic/foreign paths are covered by the `Een diploma op herkomst routeren` acceptance
|
||||
> scenarios and unit tests (the origin is carried into the process); the DMN decision and the
|
||||
> foreign→CBGV routing are asserted live by the verify-domain check.
|
||||
|
||||
## S-10a — Document wait + 30-day timeout cancels the registration (#102, ADR-0017)
|
||||
|
||||
After the zaak is opened the registratie process parks at a **WachtOpDocumenten** user task, waiting
|
||||
for the citizen's documents (their diploma). Two things can happen:
|
||||
|
||||
- **Documents arrive in time** → the task completes and the process continues to the diploma-eligibility
|
||||
routing (S-13) → beoordeling.
|
||||
- **30 days pass with no documents** → an interrupting `P30D` boundary timer cancels the wait, runs the
|
||||
`RegistratieVerlopen` external task, and the domain expires the registration to the terminal status
|
||||
**VERLOPEN** (the case is cancelled).
|
||||
|
||||
The "documents received" trigger is wired end-to-end in S-10a: the self-service page shows a
|
||||
**"Documenten aanleveren"** button after submit (portal → BFF → domain → completes the wait). S-10b
|
||||
turns that into a real file upload stored in the ZGW Documenten API via the ACL. The timeout branch is
|
||||
demonstrated by firing the 30-day timer early via the management API.
|
||||
|
||||
```bash
|
||||
DOM=http://localhost:8080 # domain service
|
||||
FL=http://localhost:8090/flowable-rest/service # flowable-rest
|
||||
|
||||
# 1. Submit a registration; once the zaak is opened it parks at WachtOpDocumenten:
|
||||
curl -s -i -X POST "$DOM/registrations" -H 'Content-Type: application/json' \
|
||||
-d '{"bsn":"123456782"}' | grep -i '^location:' # note the /registrations/<id> reference
|
||||
WQ='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten"}'
|
||||
|
||||
# 2a. Documents-in-time: complete the WachtOpDocumenten task → the process advances to beoordeling.
|
||||
TID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/runtime/tasks/$TID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"complete"}'
|
||||
|
||||
# 2b. Timeout: instead of completing it, fire the 30-day timer early via the management API. Find the
|
||||
# instance's timer job, "move" it to executable; the async executor fires the interrupting event.
|
||||
PID=$(curl -s -u rest-admin:test -X POST "$FL/query/tasks" -H 'Content-Type: application/json' \
|
||||
-d "$WQ" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["processInstanceId"])')
|
||||
JID=$(curl -s -u rest-admin:test "$FL/management/timer-jobs?processInstanceId=$PID" \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"][0]["id"])')
|
||||
curl -s -u rest-admin:test -X POST "$FL/management/timer-jobs/$JID" \
|
||||
-H 'Content-Type: application/json' -d '{"action":"move"}'
|
||||
# The RegistratieVerlopen worker then expires the aggregate — read it back as VERLOPEN:
|
||||
curl -s "$DOM/registrations/<id>" # → {"status":"Verlopen", ...}
|
||||
```
|
||||
|
||||
**The path:** registratie process parks at `WachtOpDocumenten` → documents received completes it (→
|
||||
routing → `Beoordelen`), OR the `P30D` interrupting timer fires → `RegistratieVerlopen` external task
|
||||
→ domain worker expires the aggregate to `Verlopen` → `endVerlopen` (§8.2, ADR-0017).
|
||||
|
||||
> Both branches are covered by the `Een documenttermijn laten verlopen` acceptance scenarios (worker +
|
||||
> aggregate) and unit tests; the wait completion and the 30-day timer firing are asserted live by the
|
||||
> verify-domain check.
|
||||
|
||||
@@ -93,6 +93,27 @@ print(next((t['id'] for t in (d.get('data') or [])
|
||||
|
||||
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||
wacht_query='{"processDefinitionKey":"registratie","taskDefinitionKey":"WachtOpDocumenten","includeProcessVariables":true}'
|
||||
|
||||
# S-10a: every registration now parks at WachtOpDocumenten first (interrupting P30D timer). Completing
|
||||
# that task stands in for the citizen's document upload (wired for real in S-10b), letting the process
|
||||
# advance to the diploma routing / Beoordelen so the checks below still hold. The 30-day timeout branch
|
||||
# is exercised separately at the end.
|
||||
complete_wacht() { # reg_id
|
||||
local rid="$1" wid="" r
|
||||
for _ in $(seq 1 30); do
|
||||
r="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||
wid="$(printf '%s' "$r" | task_for_reg "$rid")"
|
||||
[ -n "$wid" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$wid" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $rid" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$wid" -H 'Content-Type: application/json' -d '{"action":"complete"}' >/dev/null
|
||||
echo ">> completed WachtOpDocumenten for $rid (documents received)"
|
||||
}
|
||||
|
||||
echo ">> completing WachtOpDocumenten so the process advances (documents received)"
|
||||
complete_wacht "$reg_id"
|
||||
|
||||
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||
task_id=""
|
||||
@@ -130,6 +151,7 @@ loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_id2="${loc2##*/}"
|
||||
echo ">> second registration $reg_id2"
|
||||
complete_wacht "$reg_id2"
|
||||
|
||||
echo ">> polling Flowable for its Beoordelen task"
|
||||
task_id2=""
|
||||
@@ -171,6 +193,7 @@ locf="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
[ -n "$locf" ] || { echo "FAIL — foreign POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_idf="${locf##*/}"
|
||||
echo ">> foreign registration $reg_idf"
|
||||
complete_wacht "$reg_idf"
|
||||
|
||||
echo ">> polling Flowable for its CBGV-advies task (foreign diplomas route here first)"
|
||||
cbgv_task=""
|
||||
@@ -241,6 +264,8 @@ except Exception:
|
||||
d={}
|
||||
print(((d.get('data') or [{}])[0]).get('id',''))"; }
|
||||
|
||||
complete_wacht "$reg_id3"
|
||||
|
||||
echo ">> polling Flowable for its Beoordelen task"
|
||||
task_id3=""; pid3=""
|
||||
for _ in $(seq 1 30); do
|
||||
@@ -278,4 +303,50 @@ for _ in $(seq 1 30); do
|
||||
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; }
|
||||
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 —
|
||||
# its documents never arrive. We fire its 30-day boundary timer early via the management API; the
|
||||
# INTERRUPTING timer cancels the wait and routes a token to the RegistratieVerlopen external task. The
|
||||
# domain's timeout worker acquires it and expires the registration to VERLOPEN (ADR-0017). ────────────
|
||||
echo ">> submitting a registration to let its document term lapse"
|
||||
locv="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$locv" ] || { echo "FAIL — timeout POST /registrations returned no Location" >&2; exit 1; }
|
||||
reg_idv="${locv##*/}"
|
||||
echo ">> timeout registration $reg_idv"
|
||||
|
||||
echo ">> polling Flowable for its WachtOpDocumenten task"
|
||||
wacht_id=""; pidv=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$wacht_query" 2>/dev/null || true)"
|
||||
read -r wacht_id pidv <<<"$(printf '%s' "$resp" | task_and_pid_for_reg "$reg_idv")"
|
||||
[ -n "$wacht_id" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$wacht_id" ] || { echo "FAIL — no WachtOpDocumenten task appeared for $reg_idv" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> WachtOpDocumenten task $wacht_id (instance $pidv) is waiting for documents"
|
||||
|
||||
echo ">> firing the 30-day document timer early via the management API"
|
||||
timer_idv="$(flcurl "$fl_base/management/timer-jobs?processInstanceId=$pidv" | first_job_id)"
|
||||
[ -n "$timer_idv" ] || { echo "FAIL — no timer job found for instance $pidv" >&2; exit 1; }
|
||||
# Move the timer job to an executable async job; the async executor fires the interrupting boundary
|
||||
# event. It may run before we look, so executing it explicitly is a best-effort nudge (as for S-14).
|
||||
flcurl -X POST "$fl_base/management/timer-jobs/$timer_idv" -H 'Content-Type: application/json' -d '{"action":"move"}' >/dev/null
|
||||
async_idv="$(flcurl "$fl_base/management/jobs?processInstanceId=$pidv" 2>/dev/null | first_job_id || true)"
|
||||
if [ -n "$async_idv" ]; then
|
||||
flcurl -X POST "$fl_base/management/jobs/$async_idv" -H 'Content-Type: application/json' -d '{"action":"execute"}' >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo ">> timer fired; the RegistratieVerlopen token is parked for the domain worker"
|
||||
|
||||
echo ">> polling the domain until the timeout worker expires the registration to VERLOPEN"
|
||||
verlopen=""
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest -fsS "http://$dom_ip:8080$locv" 2>/dev/null || true)"
|
||||
printf '%s' "$body" | grep -qi 'verlopen' && { verlopen=1; break; }
|
||||
sleep 2
|
||||
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; }
|
||||
echo "OK — the 30-day document timer expired the registration to VERLOPEN"
|
||||
exit 0
|
||||
|
||||
@@ -226,6 +226,40 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
postSelfServiceRegistrationsIdDocuments<TData = void>(
|
||||
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/documents`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
|
||||
@@ -27,6 +27,11 @@ public interface IDomainClient
|
||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Provide the documents the caller's own registration is waiting for ("documenten
|
||||
/// aanleveren"). Owner-scoped by <paramref name="bsn"/>. Returns <c>false</c> when the domain
|
||||
/// reports the registration is unknown or not the caller's (404), so the BFF can relay a 404.</summary>
|
||||
Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -63,6 +68,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"registrations/{registrationId}/documents", new { bsn }, ct);
|
||||
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
|
||||
@@ -104,6 +104,26 @@ app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, Claim
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Self-service provide-documents (S-10a): the signed-in zorgprofessional supplies the documents their
|
||||
// registration is waiting for ("documenten aanleveren"). The bsn comes from the DigiD token and is
|
||||
// forwarded to the domain, which owner-scopes the action and completes the WachtOpDocumenten task; a
|
||||
// registration that is unknown or not the caller's comes back 404. The real file upload + ZGW storage
|
||||
// is S-10b — this is the trigger that unblocks the process.
|
||||
app.MapPost("/self-service/registrations/{id}/documents", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
var bsn = user.FindFirstValue("bsn");
|
||||
if (string.IsNullOrWhiteSpace(bsn))
|
||||
return Results.BadRequest("The token carries no bsn claim.");
|
||||
|
||||
var provided = await domain.ProvideDocumentsAsync(id, bsn, ct);
|
||||
return provided ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -94,6 +94,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(WithdrawSucceeds);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Bsn)? DocumentsProvidedFor { get; private set; }
|
||||
|
||||
/// <summary>Whether the fake domain reports the provide-documents as done (true → 204) or
|
||||
/// not-found/not-owned (false → 404). Tests set this to exercise the relay.</summary>
|
||||
public bool ProvideDocumentsSucceeds { get; set; } = true;
|
||||
|
||||
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
DocumentsProvidedFor = (registrationId, bsn);
|
||||
return Task.FromResult(ProvideDocumentsSucceeds);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -112,5 +112,46 @@ public class SelfServiceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_providing_documents_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.DocumentsProvidedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provides_documents_for_the_callers_registration_forwarding_the_id_and_bsn()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal(("reg-9", "123456782"), factory.Domain.DocumentsProvidedFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.ProvideDocumentsSucceeds = false;
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/self-service/registrations/{id}/documents": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openbaar/register": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -22,22 +22,29 @@ builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<Flowa
|
||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IBeoordelingEscalatieClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IRegistratieVerlopenClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||
builder.Services.AddScoped<WithdrawRegistration>();
|
||||
builder.Services.AddScoped<ProvideDocuments>();
|
||||
builder.Services.AddScoped<Werkbak>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
|
||||
builder.Services.AddScoped<ExpireRegistrationWorker>();
|
||||
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
|
||||
|
||||
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
||||
builder.Services.AddHostedService<OpenZaakJobPump>();
|
||||
// The escalation worker polls the BeoordelingEscaleren jobs the 14-day timer parks and reassigns
|
||||
// each overdue beoordeling to the teamlead (S-14).
|
||||
builder.Services.AddHostedService<BeoordelingEscalatiePump>();
|
||||
// The document-timeout worker polls the RegistratieVerlopen jobs the 30-day timer on WachtOpDocumenten
|
||||
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
|
||||
builder.Services.AddHostedService<RegistratieVerlopenPump>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -101,6 +108,23 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo
|
||||
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
// Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked
|
||||
// waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017).
|
||||
// Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the-
|
||||
// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The
|
||||
// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process.
|
||||
app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
||||
return Results.BadRequest(new { error = "A bsn is required to provide documents." });
|
||||
|
||||
var outcome = await provide.HandleAsync(new ProvideDocumentsCommand(new RegistrationId(guid), body.Bsn), ct);
|
||||
return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
||||
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||
@@ -128,6 +152,8 @@ public sealed record DecideRequest(string Besluit);
|
||||
|
||||
public sealed record WithdrawRequest(string Bsn);
|
||||
|
||||
public sealed record ProvideDocumentsRequest(string Bsn);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Handles one acquired <c>RegistratieVerlopen</c> external-worker job (S-10a, ADR-0017): load the
|
||||
/// registration the job correlates to and expire it to VERLOPEN — the 30-day document-wait timer fired
|
||||
/// before the documents arrived, so the case is cancelled. Pure application logic over ports; it knows
|
||||
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
|
||||
/// <see cref="OpenZaakWorker"/>.
|
||||
/// </summary>
|
||||
public sealed class ExpireRegistrationWorker(IRegistrationStore store)
|
||||
{
|
||||
/// <summary>
|
||||
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
|
||||
/// registration is already resolved — a redelivered expiry (VERLOPEN), or one withdrawn/decided
|
||||
/// while it waited (INGETROKKEN/INGESCHREVEN/AFGEWEZEN) — is a no-op, so the job still completes
|
||||
/// rather than throwing into a redelivery loop. Only a still-open registration is expired. An
|
||||
/// unknown registration is an error: it throws, leaving the job un-completed for Flowable to redeliver.
|
||||
/// </summary>
|
||||
public async Task HandleAsync(RegistratieVerlopenJob job, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var registration = await store.GetAsync(job.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No registration {job.RegistrationId} for RegistratieVerlopen job {job.JobId}.");
|
||||
|
||||
// Only a still-open registration lapses; an already-resolved one (expired, or withdrawn/decided
|
||||
// while it waited) is left untouched so the job can complete without violating the aggregate.
|
||||
if (registration.Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||
return;
|
||||
|
||||
registration.Expire();
|
||||
await store.SaveAsync(registration, ct);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ public interface IWorkflowClient
|
||||
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
|
||||
/// </summary>
|
||||
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Signal that the required documents have arrived (S-10a): complete the <c>WachtOpDocumenten</c>
|
||||
/// user task in the instance so the process leaves the 30-day wait state and continues to
|
||||
/// beoordeling (ADR-0017). Best-effort — if the instance is not parked at that task (already
|
||||
/// continued, or timed out) it is a no-op. The upload trigger that calls this is wired in S-10b.
|
||||
/// </summary>
|
||||
Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,3 +103,11 @@ public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
||||
/// once the 14-day boundary timer fires (ADR-0015).
|
||||
/// </summary>
|
||||
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
||||
|
||||
/// <summary>
|
||||
/// An acquired <c>RegistratieVerlopen</c> job (S-10a): the Flowable job id and the registration id it
|
||||
/// carries as a process variable. The 30-day boundary timer on <c>WachtOpDocumenten</c> spawns it when
|
||||
/// the required documents were not supplied in time; expiring the correlated registration to VERLOPEN
|
||||
/// cancels the case (ADR-0017).
|
||||
/// </summary>
|
||||
public sealed record RegistratieVerlopenJob(string JobId, RegistrationId RegistrationId);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A zorgprofessional's signal that they have supplied the documents their registration is
|
||||
/// waiting for ("documenten aanleveren"). <paramref name="Bsn"/> is the authenticated caller (from the
|
||||
/// DigiD token, forwarded by the BFF): only the registration's own bsn may provide its documents.</summary>
|
||||
public sealed record ProvideDocumentsCommand(RegistrationId RegistrationId, string Bsn);
|
||||
|
||||
/// <summary>The outcome of a provide-documents request.</summary>
|
||||
public enum ProvideDocumentsOutcome
|
||||
{
|
||||
/// <summary>The documents were accepted; the process's document wait was completed (if any).</summary>
|
||||
Accepted,
|
||||
|
||||
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone else
|
||||
/// (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The provide-documents use case (S-10a): a zorgprofessional supplies the documents their registration
|
||||
/// is parked waiting for, completing the WachtOpDocumenten task so the registratie process leaves the
|
||||
/// 30-day wait and continues to beoordeling (ADR-0017). Owner-scoped by bsn. Completing the wait is
|
||||
/// best-effort: if the registration never started a process (or already left the wait), the request
|
||||
/// still stands, mirroring how <see cref="WithdrawRegistration"/> cancels best-effort. The actual file
|
||||
/// upload and its ZGW storage via the ACL is S-10b; this is the trigger that unblocks the process.
|
||||
/// </summary>
|
||||
public sealed class ProvideDocuments(IRegistrationStore store, IWorkflowClient workflow)
|
||||
{
|
||||
public async Task<ProvideDocumentsOutcome> HandleAsync(ProvideDocumentsCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct);
|
||||
|
||||
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
|
||||
if (registration is null || registration.Bsn != command.Bsn)
|
||||
return ProvideDocumentsOutcome.NotFound;
|
||||
|
||||
// Complete the document wait (if a process is running) so beoordeling can proceed.
|
||||
if (registration.ProcessInstanceId is not null)
|
||||
await workflow.CompleteDocumentWaitAsync(registration.ProcessInstanceId, ct);
|
||||
|
||||
return ProvideDocumentsOutcome.Accepted;
|
||||
}
|
||||
}
|
||||
@@ -133,8 +133,24 @@ public sealed class Registration
|
||||
Status = RegistrationStatus.Ingetrokken;
|
||||
}
|
||||
|
||||
// A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
|
||||
// IN_BEHANDELING).
|
||||
/// <summary>
|
||||
/// Expire the registration — the 30-day document-wait timer fired before the required documents
|
||||
/// were supplied, so the registratie process cancels the case (S-10a). Allowed while it is still
|
||||
/// open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a decided (INGESCHREVEN/AFGEWEZEN) or
|
||||
/// withdrawn (INGETROKKEN) registration can no longer expire. Re-expiring one already
|
||||
/// <see cref="RegistrationStatus.Verlopen"/> is a no-op — the worker job may be redelivered (§8.6).
|
||||
/// </summary>
|
||||
public void Expire()
|
||||
{
|
||||
if (Status == RegistrationStatus.Verlopen)
|
||||
return;
|
||||
|
||||
RequireOpenForDecision(nameof(Expire));
|
||||
Status = RegistrationStatus.Verlopen;
|
||||
}
|
||||
|
||||
// A decision (or withdrawal, or expiry) is only valid while the registration is still open
|
||||
// (INGEDIEND or IN_BEHANDELING).
|
||||
private void RequireOpenForDecision(string decision)
|
||||
{
|
||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||
|
||||
@@ -21,4 +21,8 @@ public enum RegistrationStatus
|
||||
|
||||
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
|
||||
Ingetrokken,
|
||||
|
||||
/// <summary>Lapsed: the required documents were not supplied within the 30-day window, so the
|
||||
/// registratie process cancelled the case (S-10a). Terminal.</summary>
|
||||
Verlopen,
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
|
||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||
/// </summary>
|
||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient
|
||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient, IBeoordelingEscalatieClient, IRegistratieVerlopenClient
|
||||
{
|
||||
private const string Topic = "OpenZaakAanmaken";
|
||||
private const string EscalatieTopic = "BeoordelingEscaleren";
|
||||
private const string VerlopenTopic = "RegistratieVerlopen";
|
||||
private const string ProcessDefinitionKey = "registratie";
|
||||
private const string BeoordelenTaskKey = "Beoordelen";
|
||||
private const string WachtOpDocumentenTaskKey = "WachtOpDocumenten";
|
||||
private const string BehandelaarGroup = "behandelaar";
|
||||
private const string TeamleadGroup = "teamlead";
|
||||
private const string RegistrationIdVariable = "registrationId";
|
||||
@@ -114,6 +116,25 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
// Find the still-open WachtOpDocumenten task in this instance and complete it, so the process
|
||||
// leaves the 30-day wait and continues to beoordeling (S-10a, ADR-0017). If the instance is no
|
||||
// longer parked there (already continued, or the timer already cancelled it) this is a
|
||||
// best-effort no-op — mirroring the withdrawal/escalation correlation (§8.6).
|
||||
var query = new TaskByInstanceQueryRequest(processInstanceId, WachtOpDocumentenTaskKey);
|
||||
var page = await PostAsync<TaskByInstanceQueryRequest, TaskQueryResult>(
|
||||
"service/query/tasks", query, ct);
|
||||
|
||||
var task = page?.Data?.FirstOrDefault();
|
||||
if (task is null)
|
||||
return;
|
||||
|
||||
using var response = await SendAsync(
|
||||
$"service/runtime/tasks/{task.Id}", new CompleteTaskRequest("complete", []), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AcquireJobsRequest(EscalatieTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||
@@ -155,6 +176,23 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AcquireJobsRequest(VerlopenTopic, options.LockDuration, maxJobs, options.WorkerId);
|
||||
|
||||
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
|
||||
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||
|
||||
return [.. jobs.Select(job => new RegistratieVerlopenJob(job.Id, RegistrationId.Parse(job.RegistrationId())))];
|
||||
}
|
||||
|
||||
public async Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(
|
||||
$"external-job-api/acquire/jobs/{jobId}/complete", new CompleteJobRequest(options.WorkerId, []), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
||||
{
|
||||
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
||||
|
||||
@@ -36,3 +36,19 @@ public interface IBeoordelingEscalatieClient
|
||||
/// <summary>Complete an acquired escalation job so its token reaches the escalation end event.</summary>
|
||||
Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The document-timeout side of the Workflow Client (S-10a): the <c>RegistratieVerlopen</c>
|
||||
/// external-worker jobs parked by the 30-day boundary timer on <c>WachtOpDocumenten</c>. Kept separate
|
||||
/// from the other worker ports (interface segregation) so neither the OpenZaak nor escalation worker
|
||||
/// sees expiry. Implemented by <see cref="FlowableWorkflowClient"/> — the only code that talks to
|
||||
/// Flowable (§8.2, ADR-0017).
|
||||
/// </summary>
|
||||
public interface IRegistratieVerlopenClient
|
||||
{
|
||||
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>RegistratieVerlopen</c> jobs.</summary>
|
||||
Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Complete an acquired expiry job so its token reaches the <c>endVerlopen</c> end event.</summary>
|
||||
Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Big.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// One poll tick of the document-timeout worker (S-10a, ADR-0017): acquire the parked
|
||||
/// <c>RegistratieVerlopen</c> jobs — the tokens the 30-day boundary timer on <c>WachtOpDocumenten</c>
|
||||
/// spawns — expire each correlated registration via the <see cref="ExpireRegistrationWorker"/>, and
|
||||
/// complete the job so its token reaches <c>endVerlopen</c>. A job that fails is logged and left
|
||||
/// un-completed so Flowable redelivers it (§8.6). Split out from the hosted pump so the
|
||||
/// acquire→expire→complete logic is unit-testable without a running host. Mirrors
|
||||
/// <see cref="OpenZaakJobProcessor"/> and <see cref="BeoordelingEscalatieProcessor"/>.
|
||||
/// </summary>
|
||||
public sealed class RegistratieVerlopenProcessor(
|
||||
IRegistratieVerlopenClient client,
|
||||
ExpireRegistrationWorker worker,
|
||||
ILogger<RegistratieVerlopenProcessor> logger)
|
||||
{
|
||||
/// <summary>Acquire and process up to <paramref name="maxJobs"/> jobs. Returns the number acquired.</summary>
|
||||
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(maxJobs, ct);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await worker.HandleAsync(job, ct);
|
||||
await client.CompleteRegistratieVerlopenJobAsync(job.JobId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
|
||||
logger.LogError(ex, "RegistratieVerlopen job {JobId} failed; leaving it for redelivery.", job.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
return jobs.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The hosted polling loop of the document-timeout worker (S-10a, ADR-0017): on an interval it
|
||||
/// resolves a scoped <see cref="RegistratieVerlopenProcessor"/> and asks it to drain the parked
|
||||
/// <c>RegistratieVerlopen</c> jobs. A deliberately thin shell — all acquire/expire/complete logic
|
||||
/// lives in the processor, which is unit-tested; this class only owns the timer, the per-tick scope,
|
||||
/// and loop resilience. Structurally identical to <see cref="BeoordelingEscalatiePump"/>.
|
||||
/// </summary>
|
||||
public sealed class RegistratieVerlopenPump(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
FlowableOptions options,
|
||||
ILogger<RegistratieVerlopenPump> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<RegistratieVerlopenProcessor>();
|
||||
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
|
||||
logger.LogError(ex, "RegistratieVerlopen job poll failed; retrying after the poll interval.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.PollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
// S-10a (#102): the application handler behind the RegistratieVerlopen external-worker job. The 30-day
|
||||
// document-wait timer fired, so the correlated registration is expired to VERLOPEN. Mirrors
|
||||
// OpenZaakWorker — pure application logic over ports, idempotent under at-least-once delivery (§8.6).
|
||||
public class ExpireRegistrationWorkerTests
|
||||
{
|
||||
private const string Bsn = "123456782";
|
||||
|
||||
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||
{
|
||||
var registration = Registration.Submit(Bsn);
|
||||
registration.RecordProcessStarted(processInstanceId);
|
||||
return registration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Expires_the_registration_the_job_correlates_to()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
|
||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Verlopen, saved!.Status);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_already_verlopen_registration_is_not_persisted_again()
|
||||
{
|
||||
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted();
|
||||
registration.Expire();
|
||||
store.Seed(registration);
|
||||
|
||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes()
|
||||
{
|
||||
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
|
||||
// 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
|
||||
// into a redelivery loop.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted();
|
||||
registration.Withdraw();
|
||||
store.Seed(registration);
|
||||
|
||||
await new ExpireRegistrationWorker(store).HandleAsync(
|
||||
new RegistratieVerlopenJob("job-7", registration.Id));
|
||||
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_unknown_registration_throws_so_the_job_is_redelivered()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
new ExpireRegistrationWorker(store).HandleAsync(
|
||||
new RegistratieVerlopenJob("job-7", RegistrationId.New())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_job()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!));
|
||||
}
|
||||
@@ -34,6 +34,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public DiplomaOrigin? StartedWithOrigin { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
public string? CompletedDocumentWaitFor { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(
|
||||
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||
@@ -49,6 +50,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
WithdrawnProcessInstanceId = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
CompletedDocumentWaitFor = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||
|
||||
@@ -426,4 +426,106 @@ public class FlowableWorkflowClientTests
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||
}
|
||||
|
||||
// ── S-10a (#102): document-wait timeout → RegistratieVerlopen (ADR-0017) ──────────────────────
|
||||
// A 30-day interrupting boundary timer on WachtOpDocumenten spawns a RegistratieVerlopen
|
||||
// external-worker job carrying the registration id; the worker expires the registration and
|
||||
// completes the job. Separately, "documents received" completes the WachtOpDocumenten user task.
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_verlopen_jobs_posts_the_topic_and_parses_jobs_with_their_registration_id()
|
||||
{
|
||||
var rid = RegistrationId.New();
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
$$"""[{"id":"job-9","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]"""));
|
||||
|
||||
var jobs = await client.AcquireRegistratieVerlopenJobsAsync(3);
|
||||
|
||||
var job = Assert.Single(jobs);
|
||||
Assert.Equal("job-9", job.JobId);
|
||||
Assert.Equal(rid, job.RegistrationId);
|
||||
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||
capture.Seen!.RequestUri!.ToString());
|
||||
Assert.Contains("\"topic\":\"RegistratieVerlopen\"", capture.Body);
|
||||
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("null")]
|
||||
public async Task Acquire_verlopen_jobs_returns_empty_when_none_are_parked(string body)
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||
|
||||
Assert.Empty(await client.AcquireRegistratieVerlopenJobsAsync(1));
|
||||
Assert.NotNull(capture.Seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_verlopen_job_posts_to_the_job_complete_endpoint()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||
|
||||
await client.CompleteRegistratieVerlopenJobAsync("job-9");
|
||||
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provide_documents_completes_the_wacht_op_documenten_task_in_the_instance()
|
||||
{
|
||||
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
|
||||
var client = Client(new StubHandler(async req =>
|
||||
{
|
||||
requests.Add((req.Method, req.RequestUri!.ToString(),
|
||||
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
|
||||
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
|
||||
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"data":[{"id":"task-3"}],"total":1}""",
|
||||
Encoding.UTF8, "application/json"),
|
||||
}
|
||||
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
await client.CompleteDocumentWaitAsync("pi-1");
|
||||
|
||||
// 1. Find the still-open WachtOpDocumenten task in this process instance.
|
||||
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
|
||||
Assert.Equal(HttpMethod.Post, query.Method);
|
||||
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
|
||||
Assert.Contains("\"taskDefinitionKey\":\"WachtOpDocumenten\"", query.Body);
|
||||
// 2. Complete that task so the process leaves the wait state.
|
||||
var complete = requests.Single(r => r.Url.EndsWith("service/runtime/tasks/task-3"));
|
||||
Assert.Equal(HttpMethod.Post, complete.Method);
|
||||
Assert.Contains("\"action\":\"complete\"", complete.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Provide_documents_is_a_no_op_when_the_wait_task_is_no_longer_open()
|
||||
{
|
||||
// The process already left WachtOpDocumenten (e.g. timed out): nothing to complete, no throw.
|
||||
var methods = new List<HttpMethod>();
|
||||
var client = Client(new StubHandler(req =>
|
||||
{
|
||||
methods.Add(req.Method);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}));
|
||||
|
||||
await client.CompleteDocumentWaitAsync("pi-1");
|
||||
|
||||
// Only the query ran; no task-completion POST followed.
|
||||
Assert.DoesNotContain(methods, m => m == HttpMethod.Put || m == HttpMethod.Delete);
|
||||
Assert.Single(methods);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
// S-10a (#102): the "documents received" use case. A zorgprofessional supplies the documents their
|
||||
// registration is waiting for; the handler completes the WachtOpDocumenten task via the Workflow Client
|
||||
// so the process leaves the 30-day wait and continues to beoordeling. Owner-scoped by the caller's bsn,
|
||||
// like WithdrawRegistration. (The real file upload + ZGW storage is S-10b; this is the trigger path.)
|
||||
public class ProvideDocumentsTests
|
||||
{
|
||||
private const string Bsn = "123456782";
|
||||
|
||||
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||
{
|
||||
var registration = Registration.Submit(Bsn);
|
||||
registration.RecordProcessStarted(processInstanceId);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private static ProvideDocumentsCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
|
||||
|
||||
[Fact]
|
||||
public async Task Providing_documents_completes_the_document_wait()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted("proc-42");
|
||||
store.Seed(registration);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new ProvideDocuments(store, workflow);
|
||||
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
||||
Assert.Equal("proc-42", workflow.CompletedDocumentWaitFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_different_bsn_cannot_provide_documents()
|
||||
{
|
||||
// Owner-scoping: only the registration's own bsn may supply its documents. Another bsn is told
|
||||
// NotFound (existence not revealed) and the wait is not completed.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new ProvideDocuments(store, workflow);
|
||||
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
||||
|
||||
Assert.Equal(ProvideDocumentsOutcome.NotFound, outcome);
|
||||
Assert.Null(workflow.CompletedDocumentWaitFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Providing_for_an_unknown_registration_is_not_found()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var handler = new ProvideDocuments(store, new FakeWorkflowClient());
|
||||
|
||||
Assert.Equal(ProvideDocumentsOutcome.NotFound, await handler.HandleAsync(Command(RegistrationId.New())));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Providing_before_a_process_started_is_accepted_without_calling_the_workflow()
|
||||
{
|
||||
// No process yet → no wait task to complete; the request still stands (best-effort, mirroring
|
||||
// WithdrawRegistration) and the Workflow Client is not called.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
|
||||
store.Seed(registration);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new ProvideDocuments(store, workflow);
|
||||
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
Assert.Equal(ProvideDocumentsOutcome.Accepted, outcome);
|
||||
Assert.Null(workflow.CompletedDocumentWaitFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_command()
|
||||
=> await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
new ProvideDocuments(new FakeRegistrationStore(), new FakeWorkflowClient()).HandleAsync(null!));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Big.Application;
|
||||
using Big.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
// S-10a (#102): the document-timeout drain loop. Mirrors BeoordelingEscalatieProcessor — acquire the
|
||||
// parked RegistratieVerlopen jobs (the tokens the 30-day boundary timer on WachtOpDocumenten spawns),
|
||||
// expire each correlated registration via the ExpireRegistrationWorker, then complete the job. A job
|
||||
// whose expiry fails is logged and left un-completed for Flowable to redeliver (§8.6).
|
||||
public class RegistratieVerlopenProcessorTests
|
||||
{
|
||||
/// <summary>A fake client scripting the jobs to acquire and recording completions.</summary>
|
||||
private sealed class FakeVerlopenClient(params RegistratieVerlopenJob[] jobs) : IRegistratieVerlopenClient
|
||||
{
|
||||
public int AcquireCount { get; private set; }
|
||||
public List<string> Completed { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
AcquireCount++;
|
||||
return Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(jobs.Take(maxJobs).ToList());
|
||||
}
|
||||
|
||||
public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||
{
|
||||
Completed.Add(jobId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store);
|
||||
|
||||
[Fact]
|
||||
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Domain.Registration.Submit("123456782");
|
||||
store.Seed(registration);
|
||||
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", registration.Id));
|
||||
|
||||
var acquired = await new RegistratieVerlopenProcessor(
|
||||
client, Worker(store), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(1, acquired);
|
||||
Assert.Equal(Domain.RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
|
||||
Assert.Equal("job-9", Assert.Single(client.Completed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_expiry_is_left_uncompleted_for_flowable_to_redeliver()
|
||||
{
|
||||
// Unknown registration → the worker throws → the job is left for redelivery, error logged.
|
||||
var store = new FakeRegistrationStore();
|
||||
var client = new FakeVerlopenClient(new RegistratieVerlopenJob("job-9", Domain.RegistrationId.New()));
|
||||
var logger = new CapturingLogger<RegistratieVerlopenProcessor>();
|
||||
|
||||
var acquired = await new RegistratieVerlopenProcessor(client, Worker(store), logger).PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(1, acquired);
|
||||
Assert.Empty(client.Completed);
|
||||
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Contains("job-9", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_nothing_but_poll_when_there_are_no_jobs()
|
||||
{
|
||||
var client = new FakeVerlopenClient();
|
||||
|
||||
var acquired = await new RegistratieVerlopenProcessor(
|
||||
client, Worker(new FakeRegistrationStore()), NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(0, acquired);
|
||||
Assert.Equal(1, client.AcquireCount);
|
||||
Assert.Empty(client.Completed);
|
||||
}
|
||||
}
|
||||
@@ -294,4 +294,63 @@ public class RegistrationTests
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiring_an_ingediend_registration_sets_it_verlopen()
|
||||
{
|
||||
// The 30-day document-wait timer fired before the documents arrived (S-10a): the case is
|
||||
// cancelled and the aggregate becomes terminal VERLOPEN.
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Expire();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiring_needs_no_zaak()
|
||||
{
|
||||
// The timer fires on a purely time-based boundary; expiry does not depend on the zaak.
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Expire();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||
Assert.Null(registration.ZaakUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Re_expiring_an_already_verlopen_registration_is_idempotent()
|
||||
{
|
||||
// The RegistratieVerlopen worker job may be redelivered (§8.6); re-expiring is a no-op.
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.Expire();
|
||||
|
||||
registration.Expire();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Verlopen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiring_an_approved_registration_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiring_a_withdrawn_registration_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.Withdraw();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"reporters": ["progress", "html"],
|
||||
"mutate": [
|
||||
"!**/OpenZaakJobPump.cs",
|
||||
"!**/BeoordelingEscalatiePump.cs"
|
||||
"!**/BeoordelingEscalatiePump.cs",
|
||||
"!**/RegistratieVerlopenPump.cs"
|
||||
],
|
||||
"thresholds": {
|
||||
"high": 95,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# language: en
|
||||
# Drives S-10a (#102). After the zaak is opened the process parks at WachtOpDocumenten with an
|
||||
# INTERRUPTING 30-day boundary timer. If the documents do not arrive in time the timer cancels the
|
||||
# task and parks a RegistratieVerlopen job (ADR-0017) which the timeout worker drains, expiring the
|
||||
# registration to VERLOPEN. Documents received before the timer fires close the wait, so no expiry
|
||||
# happens. This scenario exercises the timeout worker against an in-memory Flowable stand-in; the timer
|
||||
# firing live is verify-domain.
|
||||
Feature: Een documenttermijn laten verlopen
|
||||
Als registerbeheerder wil ik dat een aanvraag waarvoor de documenten niet binnen 30 dagen binnen zijn
|
||||
automatisch vervalt zodat onvolledige aanvragen niet blijven liggen.
|
||||
|
||||
Scenario: Zonder documenten binnen 30 dagen vervalt de registratie
|
||||
Given a registration parked at the WachtOpDocumenten task
|
||||
When the 30-day document timer fires
|
||||
And the document-timeout worker runs
|
||||
Then the registration is verlopen
|
||||
|
||||
Scenario: Tijdig aangeleverde documenten laten de registratie niet vervallen
|
||||
Given a registration parked at the WachtOpDocumenten task
|
||||
When the documents arrive before the timer fires
|
||||
And the 30-day document timer fires
|
||||
And the document-timeout worker runs
|
||||
Then the registration is not verlopen
|
||||
@@ -0,0 +1,52 @@
|
||||
using Acceptance.Support;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Reqnroll;
|
||||
using Xunit;
|
||||
|
||||
namespace Acceptance.Steps;
|
||||
|
||||
/// <summary>Bindings for <c>EenDocumentTermijnVerlopen.feature</c> (S-10a). Drives the timeout worker
|
||||
/// (<see cref="RegistratieVerlopenProcessor"/> over the <see cref="ExpireRegistrationWorker"/>) against
|
||||
/// an in-memory Flowable stand-in and a shared registration store; one instance per scenario. The
|
||||
/// interrupting 30-day timer either cancels the wait and expires the registration, or — if the
|
||||
/// documents arrived first — never fires; the scenario asserts on the aggregate's status.</summary>
|
||||
[Binding]
|
||||
[Scope(Feature = "Een documenttermijn laten verlopen")]
|
||||
public sealed class EenDocumentTermijnVerlopenSteps
|
||||
{
|
||||
private readonly InMemoryDocumentTimeoutClient _flowable = new();
|
||||
private readonly Support.InMemoryRegistrationStore _store = new();
|
||||
private Registration _registration = null!;
|
||||
private string _processInstanceId = "";
|
||||
|
||||
[Given("a registration parked at the WachtOpDocumenten task")]
|
||||
public async Task GivenARegistrationParkedAtWachtOpDocumenten()
|
||||
{
|
||||
_registration = Registration.Submit("123456782");
|
||||
await _store.SaveAsync(_registration);
|
||||
_processInstanceId = _flowable.ParkWaitingForDocuments(_registration.Id);
|
||||
}
|
||||
|
||||
[When("the 30-day document timer fires")]
|
||||
public void WhenTheDocumentTimerFires() => _flowable.FireDocumentTimer(_processInstanceId);
|
||||
|
||||
[When("the documents arrive before the timer fires")]
|
||||
public void WhenTheDocumentsArriveBeforeTheTimer() => _flowable.ReceiveDocuments(_processInstanceId);
|
||||
|
||||
[When("the document-timeout worker runs")]
|
||||
public async Task WhenTheTimeoutWorkerRuns()
|
||||
=> await new RegistratieVerlopenProcessor(
|
||||
_flowable, new ExpireRegistrationWorker(_store),
|
||||
NullLogger<RegistratieVerlopenProcessor>.Instance).PumpOnceAsync(5);
|
||||
|
||||
[Then("the registration is verlopen")]
|
||||
public async Task ThenTheRegistrationIsVerlopen()
|
||||
=> Assert.Equal(RegistrationStatus.Verlopen, (await _store.GetAsync(_registration.Id))!.Status);
|
||||
|
||||
[Then("the registration is not verlopen")]
|
||||
public async Task ThenTheRegistrationIsNotVerlopen()
|
||||
=> Assert.Equal(RegistrationStatus.Ingediend, (await _store.GetAsync(_registration.Id))!.Status);
|
||||
}
|
||||
@@ -72,6 +72,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<bool> ProvideDocumentsAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public DiplomaOrigin? StartedWithOrigin { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
public string? CompletedDocumentWaitFor { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(
|
||||
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
|
||||
@@ -28,6 +29,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
||||
WithdrawnProcessInstanceId = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteDocumentWaitAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
CompletedDocumentWaitFor = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
|
||||
@@ -130,6 +137,57 @@ public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory Flowable stand-in for the document-timeout scenario (S-10a): it models one
|
||||
/// WachtOpDocumenten wait per process instance — whether it is still open and the registration it
|
||||
/// correlates to — and the RegistratieVerlopen jobs the interrupting 30-day boundary timer parks. It
|
||||
/// drives the timeout worker's behaviour without a running Flowable; the timer firing live is the
|
||||
/// verify-domain check.</summary>
|
||||
public sealed class InMemoryDocumentTimeoutClient : IRegistratieVerlopenClient
|
||||
{
|
||||
private sealed class Wait
|
||||
{
|
||||
public required RegistrationId RegistrationId { get; init; }
|
||||
public bool IsWaiting { get; set; } = true;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, Wait> _waits = [];
|
||||
private readonly List<RegistratieVerlopenJob> _parked = [];
|
||||
private int _seq;
|
||||
|
||||
/// <summary>A registration parks at WachtOpDocumenten, waiting for the citizen's documents.</summary>
|
||||
public string ParkWaitingForDocuments(RegistrationId registrationId)
|
||||
{
|
||||
var pid = $"pi-{++_seq}";
|
||||
_waits[pid] = new Wait { RegistrationId = registrationId };
|
||||
return pid;
|
||||
}
|
||||
|
||||
/// <summary>The documents arrive before the timer fires: the wait task closes, so the interrupting
|
||||
/// timer no longer fires (mirrors the Workflow Client completing WachtOpDocumenten).</summary>
|
||||
public void ReceiveDocuments(string processInstanceId) => _waits[processInstanceId].IsWaiting = false;
|
||||
|
||||
/// <summary>The 30-day interrupting boundary timer fires: if still waiting, it cancels the wait and
|
||||
/// parks a RegistratieVerlopen job carrying the correlated registration id. A no-op if the documents
|
||||
/// already arrived (the wait/timer race, §8.6).</summary>
|
||||
public void FireDocumentTimer(string processInstanceId)
|
||||
{
|
||||
var wait = _waits[processInstanceId];
|
||||
if (!wait.IsWaiting)
|
||||
return;
|
||||
wait.IsWaiting = false;
|
||||
_parked.Add(new RegistratieVerlopenJob($"job-{++_seq}", wait.RegistrationId));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<RegistratieVerlopenJob>> AcquireRegistratieVerlopenJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<RegistratieVerlopenJob>>(_parked.Take(maxJobs).ToList());
|
||||
|
||||
public Task CompleteRegistratieVerlopenJobAsync(string jobId, CancellationToken ct = default)
|
||||
{
|
||||
_parked.RemoveAll(j => j.JobId == jobId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
{
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
|
||||
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
|
||||
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
|
||||
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
||||
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
|
||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
|
||||
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
|
||||
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for
|
||||
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak,
|
||||
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the
|
||||
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||
await page.goto('/');
|
||||
|
||||
@@ -26,42 +30,53 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
||||
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
||||
|
||||
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
||||
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
||||
// on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
|
||||
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
|
||||
// so a status-only poll would short-circuit on a stale row before our row is projected.
|
||||
await page.goto('http://openbaar/');
|
||||
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
||||
// catches up. We check it on a SEPARATE page so the self-service tab keeps its (in-memory) submitted
|
||||
// state — the "Documenten aanleveren" action below acts on that same session. The projection updates
|
||||
// asynchronously (NRC → event-subscriber), so reload until *this* submission's row appears. We poll
|
||||
// on the reference cell (not a generic INGEDIEND cell): the shared verify stack already holds
|
||||
// INGEDIEND rows from earlier checks, so a status-only poll would short-circuit on a stale row.
|
||||
const staff = await context.newPage();
|
||||
await staff.goto('http://openbaar/');
|
||||
await expect(staff.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
||||
|
||||
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
||||
// submit confirmation — no mismatch between the two portals.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: reference }).count();
|
||||
await staff.reload();
|
||||
return staff.getByRole('cell', { name: reference }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||
.toBeVisible();
|
||||
|
||||
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
|
||||
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
|
||||
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||
await page.goto('http://behandel/');
|
||||
await page.locator('#username').fill('merel-behandelaar');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
|
||||
// The process parks at WachtOpDocumenten only after the zaak is opened; the INGEDIEND row above proves
|
||||
// the zaak exists — so the OpenZaak worker has completed and the process is now at the wait — which is
|
||||
// why we supply the documents here rather than right after submit, when the trigger would race the
|
||||
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
|
||||
// beoordeling.)
|
||||
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
||||
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it (goedkeuren)
|
||||
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
||||
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||
await staff.goto('http://behandel/');
|
||||
await staff.locator('#username').fill('merel-behandelaar');
|
||||
await staff.locator('#password').fill('test123');
|
||||
await staff.locator('#kc-login').click();
|
||||
|
||||
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
|
||||
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||
|
||||
// The registration reaches the Beoordelen user task only after its documents are provided (above), so
|
||||
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
|
||||
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
|
||||
// tasks, so a positional match could act on someone else's registration.
|
||||
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
|
||||
const goedkeuren = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
await staff.reload();
|
||||
return goedkeuren.count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
@@ -69,7 +84,7 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
||||
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
||||
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
||||
const decided = page.waitForResponse(
|
||||
const decided = staff.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
|
||||
r.request().method() === 'POST',
|
||||
@@ -79,11 +94,11 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
||||
|
||||
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
||||
// its reference) now shows INGESCHREVEN.
|
||||
await page.goto('http://openbaar/');
|
||||
await staff.goto('http://openbaar/');
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
await staff.reload();
|
||||
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
+87
-32
@@ -22,10 +22,16 @@
|
||||
(BeoordelingEscaleren); the Workflow Client reassigns the still-open Beoordelen task from the
|
||||
behandelaar group to teamlead (ADR-0015). The Beoordelen task stays open throughout — the timer
|
||||
only changes who may claim it.
|
||||
S-13 adds diploma-eligibility routing: between OpenZaakAanmaken and Beoordelen a DMN service
|
||||
S-13 adds diploma-eligibility routing: between the document wait and Beoordelen a DMN service
|
||||
task (flowable:type="dmn") evaluates the `diploma-eligibility` decision on the diplomaOrigin
|
||||
start variable; an exclusive gateway routes a foreign diploma through the CBGV-advies user task
|
||||
before Beoordelen, a domestic one straight there (ADR-0016). -->
|
||||
before Beoordelen, a domestic one straight there (ADR-0016).
|
||||
S-10a adds the document wait: right after the zaak is opened the process parks at a
|
||||
WachtOpDocumenten user task with an INTERRUPTING P30D boundary timer. "Documents received"
|
||||
(the S-10b upload, via the Workflow Client) completes the task and the process continues to the
|
||||
diploma routing; if the 30 days lapse first the timer cancels the task and runs the
|
||||
RegistratieVerlopen external-worker task, whose worker expires the registration to VERLOPEN,
|
||||
ending the process as "verlopen" (ADR-0017). -->
|
||||
<message id="Message_Ingetrokken" name="RegistratieIngetrokken"/>
|
||||
|
||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||
@@ -38,7 +44,32 @@
|
||||
flowable:type="external-worker"
|
||||
flowable:topic="OpenZaakAanmaken"/>
|
||||
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="DiplomaEligibiliteit"/>
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="WachtOpDocumenten"/>
|
||||
|
||||
<!-- S-10a: wait for the citizen's documents (diploma). The process parks here; "documents received"
|
||||
(the S-10b upload path) completes the task via the Workflow Client. An INTERRUPTING P30D
|
||||
boundary timer cancels the case if the documents never arrive (ADR-0017). -->
|
||||
<userTask id="WachtOpDocumenten" name="Wacht op documenten"/>
|
||||
|
||||
<sequenceFlow id="flow2doc" sourceRef="WachtOpDocumenten" targetRef="DiplomaEligibiliteit"/>
|
||||
|
||||
<boundaryEvent id="DocumentenTimer" attachedToRef="WachtOpDocumenten" cancelActivity="true">
|
||||
<timerEventDefinition>
|
||||
<timeDuration>P30D</timeDuration>
|
||||
</timerEventDefinition>
|
||||
</boundaryEvent>
|
||||
|
||||
<sequenceFlow id="flow7" sourceRef="DocumentenTimer" targetRef="RegistratieVerlopen"/>
|
||||
|
||||
<!-- On timeout: an external-worker task the Workflow Client picks up to expire the registration to
|
||||
VERLOPEN (S-10a). Its topic mirrors OpenZaakAanmaken/BeoordelingEscaleren. -->
|
||||
<serviceTask id="RegistratieVerlopen" name="Registratie laten verlopen"
|
||||
flowable:type="external-worker"
|
||||
flowable:topic="RegistratieVerlopen"/>
|
||||
|
||||
<sequenceFlow id="flow8" sourceRef="RegistratieVerlopen" targetRef="endVerlopen"/>
|
||||
|
||||
<endEvent id="endVerlopen" name="Registratie verlopen"/>
|
||||
|
||||
<!-- S-13: evaluate the diploma-eligibility DMN inline (ADR-0016). A Flowable DMN service task
|
||||
(flowable:type="dmn" — NOT a businessRuleTask, whose default implementation is the legacy
|
||||
@@ -114,35 +145,47 @@
|
||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||
<omgdc:Bounds x="180" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_wacht" bpmnElement="WachtOpDocumenten">
|
||||
<omgdc:Bounds x="340" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_documentenTimer" bpmnElement="DocumentenTimer">
|
||||
<omgdc:Bounds x="385" y="130" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_verlopen" bpmnElement="RegistratieVerlopen">
|
||||
<omgdc:Bounds x="340" y="220" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_endVerlopen" bpmnElement="endVerlopen">
|
||||
<omgdc:Bounds x="510" y="235" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_dmn" bpmnElement="DiplomaEligibiliteit">
|
||||
<omgdc:Bounds x="350" y="85" width="120" height="60"/>
|
||||
<omgdc:Bounds x="510" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_route" bpmnElement="RouteOpDiploma" isMarkerVisible="true">
|
||||
<omgdc:Bounds x="520" y="90" width="40" height="40"/>
|
||||
<omgdc:Bounds x="680" y="90" width="40" height="40"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_cbgv" bpmnElement="CBGVAdvies">
|
||||
<omgdc:Bounds x="500" y="200" width="120" height="60"/>
|
||||
<omgdc:Bounds x="660" y="200" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
||||
<omgdc:Bounds x="630" y="85" width="120" height="60"/>
|
||||
<omgdc:Bounds x="790" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
||||
<omgdc:Bounds x="810" y="100" width="30" height="30"/>
|
||||
<omgdc:Bounds x="970" y="100" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_ingetrokken" bpmnElement="Ingetrokken">
|
||||
<omgdc:Bounds x="690" y="135" width="30" height="30"/>
|
||||
<omgdc:Bounds x="850" y="135" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_endIngetrokken" bpmnElement="endIngetrokken">
|
||||
<omgdc:Bounds x="690" y="250" width="30" height="30"/>
|
||||
<omgdc:Bounds x="850" y="250" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_escaleerTimer" bpmnElement="EscaleerTimer">
|
||||
<omgdc:Bounds x="700" y="70" width="30" height="30"/>
|
||||
<omgdc:Bounds x="860" y="70" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_escaleerBeoordeling" bpmnElement="EscaleerBeoordeling">
|
||||
<omgdc:Bounds x="800" y="20" width="120" height="60"/>
|
||||
<omgdc:Bounds x="960" y="20" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_endEscaleren" bpmnElement="endEscaleren">
|
||||
<omgdc:Bounds x="970" y="35" width="30" height="30"/>
|
||||
<omgdc:Bounds x="1130" y="35" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||
<omgdi:waypoint x="130" y="115"/>
|
||||
@@ -150,41 +193,53 @@
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||
<omgdi:waypoint x="300" y="115"/>
|
||||
<omgdi:waypoint x="350" y="115"/>
|
||||
<omgdi:waypoint x="340" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2doc" bpmnElement="flow2doc">
|
||||
<omgdi:waypoint x="460" y="115"/>
|
||||
<omgdi:waypoint x="510" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow7" bpmnElement="flow7">
|
||||
<omgdi:waypoint x="400" y="160"/>
|
||||
<omgdi:waypoint x="400" y="220"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow8" bpmnElement="flow8">
|
||||
<omgdi:waypoint x="460" y="250"/>
|
||||
<omgdi:waypoint x="510" y="250"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2a" bpmnElement="flow2a">
|
||||
<omgdi:waypoint x="470" y="115"/>
|
||||
<omgdi:waypoint x="520" y="110"/>
|
||||
<omgdi:waypoint x="630" y="115"/>
|
||||
<omgdi:waypoint x="680" y="110"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flowDirect" bpmnElement="flowDirect">
|
||||
<omgdi:waypoint x="560" y="110"/>
|
||||
<omgdi:waypoint x="630" y="115"/>
|
||||
<omgdi:waypoint x="720" y="110"/>
|
||||
<omgdi:waypoint x="790" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flowCbgv" bpmnElement="flowCbgv">
|
||||
<omgdi:waypoint x="540" y="130"/>
|
||||
<omgdi:waypoint x="540" y="200"/>
|
||||
<omgdi:waypoint x="700" y="130"/>
|
||||
<omgdi:waypoint x="700" y="200"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flowCbgvNaarBeoordelen" bpmnElement="flowCbgvNaarBeoordelen">
|
||||
<omgdi:waypoint x="620" y="230"/>
|
||||
<omgdi:waypoint x="660" y="230"/>
|
||||
<omgdi:waypoint x="660" y="145"/>
|
||||
<omgdi:waypoint x="780" y="230"/>
|
||||
<omgdi:waypoint x="820" y="230"/>
|
||||
<omgdi:waypoint x="820" y="145"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
||||
<omgdi:waypoint x="750" y="115"/>
|
||||
<omgdi:waypoint x="810" y="115"/>
|
||||
<omgdi:waypoint x="910" y="115"/>
|
||||
<omgdi:waypoint x="970" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow4" bpmnElement="flow4">
|
||||
<omgdi:waypoint x="705" y="165"/>
|
||||
<omgdi:waypoint x="705" y="250"/>
|
||||
<omgdi:waypoint x="865" y="165"/>
|
||||
<omgdi:waypoint x="865" y="250"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow5" bpmnElement="flow5">
|
||||
<omgdi:waypoint x="715" y="70"/>
|
||||
<omgdi:waypoint x="715" y="50"/>
|
||||
<omgdi:waypoint x="800" y="50"/>
|
||||
<omgdi:waypoint x="875" y="70"/>
|
||||
<omgdi:waypoint x="875" y="50"/>
|
||||
<omgdi:waypoint x="960" y="50"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow6" bpmnElement="flow6">
|
||||
<omgdi:waypoint x="920" y="50"/>
|
||||
<omgdi:waypoint x="970" y="50"/>
|
||||
<omgdi:waypoint x="1080" y="50"/>
|
||||
<omgdi:waypoint x="1130" y="50"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
Reference in New Issue
Block a user