Merge remote-tracking branch 'origin/main' into fix/161-e2e-bounded-and-diagnosable
CI / lint (pull_request) Successful in 2m19s
CI / build (pull_request) Successful in 1m9s
CI / unit (pull_request) Successful in 1m29s
CI / frontend (pull_request) Successful in 3m4s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Successful in 10m0s
CI / lint (pull_request) Successful in 2m19s
CI / build (pull_request) Successful in 1m9s
CI / unit (pull_request) Successful in 1m29s
CI / frontend (pull_request) Successful in 3m4s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Successful in 10m0s
This commit is contained in:
@@ -4,7 +4,7 @@ import { of, throwError } from 'rxjs';
|
|||||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { axe } from 'vitest-axe';
|
import { axe } from 'vitest-axe';
|
||||||
import { WerkbakPage } from './werkbak-page';
|
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
|
||||||
|
|
||||||
const sample: WerkbakItem[] = [
|
const sample: WerkbakItem[] = [
|
||||||
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
||||||
@@ -81,6 +81,94 @@ describe('WerkbakPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('picks up a newly submitted registration without a reload', async () => {
|
||||||
|
// S-26 (#162): a registration reaches Beoordelen asynchronously, after the citizen supplies
|
||||||
|
// documents — so the werkbak must refresh itself rather than wait for the behandelaar to reload.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const getBehandelWerkbak = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of([sample[0]]))
|
||||||
|
.mockReturnValue(of(sample));
|
||||||
|
const { providers } = setup({ getBehandelWerkbak });
|
||||||
|
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('reg-2')).toBeNull();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||||
|
detectChanges();
|
||||||
|
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.getByText('reg-2')).toBeTruthy();
|
||||||
|
// A background refresh must not flash the loading state over the rows the behandelaar is reading.
|
||||||
|
expect(screen.queryByText(/bezig met laden/i)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the rows on screen when a background refresh fails', async () => {
|
||||||
|
// A blip on a background poll must not replace the list with the load-failure alert; the next
|
||||||
|
// tick recovers. Only the first load speaks for whether the werkbak is readable at all.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const getBehandelWerkbak = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(of(sample))
|
||||||
|
.mockReturnValue(throwError(() => new Error('503')));
|
||||||
|
const { providers } = setup({ getBehandelWerkbak });
|
||||||
|
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||||
|
detectChanges();
|
||||||
|
|
||||||
|
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops refreshing once the page is destroyed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { getBehandelWerkbak, providers } = setup();
|
||||||
|
const { fixture } = await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
fixture.destroy();
|
||||||
|
vi.advanceTimersByTime(WERKBAK_REFRESH_MS * 3);
|
||||||
|
|
||||||
|
expect(getBehandelWerkbak).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a load failure once a refresh succeeds', async () => {
|
||||||
|
// Without this the werkbak stays stuck on the error until the behandelaar reloads — the very
|
||||||
|
// thing this slice removes. A recovered read must put the rows back.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const getBehandelWerkbak = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('503')))
|
||||||
|
.mockReturnValue(of(sample));
|
||||||
|
const { providers } = setup({ getBehandelWerkbak });
|
||||||
|
const { detectChanges } = await render(WerkbakPage, { providers });
|
||||||
|
|
||||||
|
expect(screen.getByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(WERKBAK_REFRESH_MS);
|
||||||
|
detectChanges();
|
||||||
|
|
||||||
|
expect(screen.queryByText(/kon de werkbak niet laden/i)).toBeNull();
|
||||||
|
expect(screen.getByText('reg-1')).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('shows an empty state when the werkbak has no items', async () => {
|
it('shows an empty state when the werkbak has no items', async () => {
|
||||||
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
||||||
await render(WerkbakPage, { providers });
|
await render(WerkbakPage, { providers });
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { interval } from 'rxjs';
|
||||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often an open werkbak re-reads itself (S-26/#162, ADR-0032). Exported so the spec advances the
|
||||||
|
* clock by exactly one interval instead of hard-coding the number.
|
||||||
|
*/
|
||||||
|
export const WERKBAK_REFRESH_MS = 5_000;
|
||||||
|
|
||||||
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
||||||
type Besluit = 'goedkeuren' | 'afwijzen';
|
type Besluit = 'goedkeuren' | 'afwijzen';
|
||||||
|
|
||||||
@@ -10,6 +18,11 @@ type Besluit = 'goedkeuren' | 'afwijzen';
|
|||||||
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
||||||
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
||||||
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
||||||
|
*
|
||||||
|
* The page also re-reads itself every {@link WERKBAK_REFRESH_MS} while it is open, so a registration
|
||||||
|
* that reaches beoordeling after the behandelaar opened the werkbak shows up on its own — no reload
|
||||||
|
* (S-26/#162). Polling rather than a pushed stream: nothing notifies the BFF either, so a stream
|
||||||
|
* would poll the domain in the BFF instead and add connection state for the same freshness (ADR-0032).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-werkbak-page',
|
selector: 'app-werkbak-page',
|
||||||
@@ -27,19 +40,37 @@ export class WerkbakPage {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.load();
|
this.load();
|
||||||
|
// ponytail: a fixed interval, polled while the page lives — it keeps refreshing in a background
|
||||||
|
// tab. Gate on `document.visibilityState` if the request volume ever matters.
|
||||||
|
interval(WERKBAK_REFRESH_MS)
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe(() => this.load({ background: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
load(): void {
|
/**
|
||||||
this.loading.set(true);
|
* Read the werkbak. A `background` read is the interval refresh: it leaves the rows and the states
|
||||||
this.failed.set(false);
|
* the behandelaar is looking at alone until it has an answer — no loading flash on every tick, and
|
||||||
|
* a blip does not swap the list for the failure alert (the next tick recovers). Only a foreground
|
||||||
|
* read — on open, or after a decision — speaks for whether the werkbak is readable at all.
|
||||||
|
*/
|
||||||
|
load(options: { background?: boolean } = {}): void {
|
||||||
|
const background = options.background ?? false;
|
||||||
|
if (!background) {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.failed.set(false);
|
||||||
|
}
|
||||||
this.bff.getBehandelWerkbak().subscribe({
|
this.bff.getBehandelWerkbak().subscribe({
|
||||||
next: (rows: WerkbakItem[]) => {
|
next: (rows: WerkbakItem[]) => {
|
||||||
this.items.set(rows);
|
this.items.set(rows);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loaded.set(true);
|
this.loaded.set(true);
|
||||||
|
// A read that came back is the answer, so a refresh also clears an earlier failure — the
|
||||||
|
// werkbak recovers on its own instead of showing the error until someone reloads.
|
||||||
|
this.failed.set(false);
|
||||||
},
|
},
|
||||||
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
||||||
error: () => {
|
error: () => {
|
||||||
|
if (background) return;
|
||||||
this.items.set([]);
|
this.items.set([]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.loaded.set(true);
|
this.loaded.set(true);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# ADR-0032: The werkbak refreshes itself by polling, not by a pushed stream
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-09-04
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Slice:** #162 (proposal #163). The issue titles it S-26; that id already belongs to
|
||||||
|
the self-service resume slice (#111), so #162 is the identifier that counts.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The werkbak (S-12) is a read of the open Flowable `Beoordelen` tasks: portal → BFF
|
||||||
|
`GET /behandel/werkbak` → domain `Werkbak` query → workflow engine, each task enriched
|
||||||
|
from its aggregate. A registration reaches `Beoordelen` **asynchronously**, only once the
|
||||||
|
citizen supplies its documents and the DMN routes it (S-10a) — so it appears in a werkbak
|
||||||
|
that is already open, and until now a behandelaar had to reload the page to see it.
|
||||||
|
|
||||||
|
Three forces shape the mechanism:
|
||||||
|
|
||||||
|
- **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish
|
||||||
|
task events, and there is no bus between the domain and the BFF.
|
||||||
|
- **The BFF is stateless** and sits behind each portal's nginx.
|
||||||
|
- **This is the repo's first live-updating view**, so the choice sets a precedent.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The werkbak page re-reads the existing BFF endpoint on a fixed interval
|
||||||
|
(`WERKBAK_REFRESH_MS`, 5 s) while it is open. No new endpoint, dependency or server-side
|
||||||
|
state.**
|
||||||
|
|
||||||
|
The refresh is a *background* read: it leaves the rows and the loading/failure states
|
||||||
|
untouched until it has an answer, so a tick never flashes a spinner over rows a
|
||||||
|
behandelaar is reading and a single failed poll never swaps the list for the error alert.
|
||||||
|
A read that comes back also clears an earlier failure, so the view recovers on its own —
|
||||||
|
the same reload this slice set out to remove would otherwise be needed to escape a
|
||||||
|
transient error. Only a foreground read (on open, after a decision) speaks for whether the
|
||||||
|
werkbak is readable at all.
|
||||||
|
|
||||||
|
### Why not SSE or WebSockets
|
||||||
|
|
||||||
|
Neither buys freshness here, because **nothing notifies the BFF either**:
|
||||||
|
|
||||||
|
- **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the
|
||||||
|
domain and forwards diffs — the same latency, plus connection lifecycle, nginx
|
||||||
|
buffering, and auth on a long-lived connection.
|
||||||
|
- **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and
|
||||||
|
sticky-session-bound. A genuine push path would *also* need the domain to publish task
|
||||||
|
events. Warranted by high-frequency, bidirectional or fan-out-heavy traffic; the werkbak
|
||||||
|
is none of those.
|
||||||
|
|
||||||
|
Polling meets the acceptance ("a registration can be seen in the werkbak once it is ready
|
||||||
|
for review") in a handful of lines inside one component.
|
||||||
|
|
||||||
|
- ponytail ceiling: a fixed 5 s interval, per open page, that keeps polling in a
|
||||||
|
background tab. Each tick costs one Flowable task query plus a store read per open task.
|
||||||
|
- Upgrade path: publish task events from the domain, then swap the component's `interval`
|
||||||
|
for a stream. The endpoint contract and the component's rendering stay as they are;
|
||||||
|
gate on `document.visibilityState` first if request volume is the concern.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The outcome is delivered with no new endpoint, dependency, or server-side state, and no
|
||||||
|
service boundary moves.
|
||||||
|
- Self-healing: a transient read failure no longer strands the view until a manual reload.
|
||||||
|
- The e2e got *simpler* — the happy path waits for the werkbak row without reloading the
|
||||||
|
page, which is itself the live-refresh assertion.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Staleness is bounded by one interval (≤5 s) rather than instant.
|
||||||
|
- One `GET /behandel/werkbak` per open werkbak per interval, including in hidden tabs.
|
||||||
|
- The precedent is polling; a future view with genuinely high-frequency updates will have
|
||||||
|
to revisit this (see the upgrade path above).
|
||||||
|
|
||||||
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
None. The poll reuses the existing portal → BFF → domain read path: §8.3 (portals talk
|
||||||
|
only to the BFF) and §8.2 (only the Workflow Client talks to Flowable) are unchanged.
|
||||||
@@ -5,6 +5,40 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-26/#162 — the werkbak refreshes itself (ADR-0032)
|
||||||
|
|
||||||
|
**Outcome:** a registration that reaches beoordeling while a behandelaar already has the werkbak open
|
||||||
|
**appears on its own** — no reload. The page re-reads `GET /behandel/werkbak` every 5 seconds; a
|
||||||
|
background refresh swaps the rows in without flashing the loading state, and a transient failure no
|
||||||
|
longer strands the view on its error message until someone reloads.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Two windows. Left: the behandel werkbak, already open and idle.
|
||||||
|
python3 infra/keycloak/check_realms.py otp # a code, valid right now
|
||||||
|
open http://localhost:8142 # merel-behandelaar / test123 + that code
|
||||||
|
#
|
||||||
|
# 2. Right: submit a registration and supply its documents (this is what routes it to Beoordelen).
|
||||||
|
open http://localhost:8140 # jan-burger / test123 → indienen → upload a PDF
|
||||||
|
#
|
||||||
|
# 3. Watch the left window. Within ~5 seconds the new reference appears in the werkbak — the page was
|
||||||
|
# never reloaded and never left the werkbak.
|
||||||
|
#
|
||||||
|
# 4. Automated, end to end: the happy path now waits for the werkbak row WITHOUT reloading, so the
|
||||||
|
# absence of the reload IS the assertion.
|
||||||
|
make verify-e2e # → registration.spec: "… → behandelaar goedkeurt → public INGESCHREVEN"
|
||||||
|
#
|
||||||
|
# 5. Component level (background refresh, failure recovery, teardown):
|
||||||
|
pnpm nx test behandel # → "picks up a newly submitted registration without a reload" (+3 guards)
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** unchanged — portal → BFF `GET /behandel/werkbak` → domain `Werkbak` → Flowable. Only the
|
||||||
|
page's cadence is new: `interval(WERKBAK_REFRESH_MS)` scoped to the page with `takeUntilDestroyed()`.
|
||||||
|
|
||||||
|
**Not push:** nothing notifies the BFF either, so SSE/WebSockets would poll the domain inside the BFF
|
||||||
|
for the same freshness plus connection state — see ADR-0032 for the trade-off and the upgrade path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
||||||
|
|
||||||
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
||||||
|
|||||||
@@ -57,12 +57,32 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
|
|
||||||
|
// A behandelaar opens the behandel-portal werkbak and approves the registration (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).
|
||||||
|
//
|
||||||
|
// The werkbak is opened BEFORE the citizen supplies the documents that route the registration to
|
||||||
|
// Beoordelen, so its row cannot be there at page load: the only thing that can deliver it to this
|
||||||
|
// already-open page is the werkbak refreshing itself (S-26/#162, ADR-0032). This spec used to
|
||||||
|
// `staff.reload()` in a poll loop here; the absence of that reload is the live-refresh assertion.
|
||||||
|
await staff.goto('http://behandel/');
|
||||||
|
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
|
||||||
|
await loginMedewerker(staff, 'merel-behandelaar');
|
||||||
|
|
||||||
|
await expect(staff.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||||
|
|
||||||
|
// 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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
|
||||||
|
await expect(goedkeuren, 'the registration is not awaiting beoordeling yet').toBeHidden();
|
||||||
|
|
||||||
// Provide the documents the registration is waiting for (S-10a), on the still-open self-service tab.
|
// 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 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
|
// 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
|
// 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
|
// wait and no-op. (S-10b turns this into a real file upload; here it is the trigger that unblocks
|
||||||
// beoordeling.)
|
// beoordeling.)
|
||||||
|
await page.bringToFront();
|
||||||
await page.setInputFiles('#diploma', {
|
await page.setInputFiles('#diploma', {
|
||||||
name: 'diploma.pdf',
|
name: 'diploma.pdf',
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
@@ -71,26 +91,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
await page.getByRole('button', { name: /documenten aanleveren/i }).click();
|
||||||
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
await expect(page.getByText(/documenten zijn aangeleverd/i)).toBeVisible();
|
||||||
|
|
||||||
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it (goedkeuren)
|
// Back to the werkbak — untouched since login, never reloaded. The row arrives on its own once the
|
||||||
// — the S-12 flow that replaces the temporary admin endpoint. The staff tab switches to the
|
// DMN routes the registration to Beoordelen. (Foregrounded so Chromium doesn't throttle the page's
|
||||||
// medewerker realm (a different Keycloak realm than the citizen's digid session).
|
// refresh timer as a hidden tab.)
|
||||||
await staff.goto('http://behandel/');
|
await staff.bringToFront();
|
||||||
// That realm enforces MFA (S-15c), so the behandelaar logs in with password + TOTP.
|
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
|
||||||
await loginMedewerker(staff, 'merel-behandelaar');
|
|
||||||
|
|
||||||
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 = staff.getByRole('button', { name: `Goedkeuren ${reference}` });
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
await staff.reload();
|
|
||||||
return goedkeuren.count();
|
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
// 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
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||||
|
|||||||
Reference in New Issue
Block a user