## What & why The behandel werkbak now **refreshes itself** while it is open, so a registration that reaches beoordeling after the behandelaar opened the page shows up on its own — no reload. `interval(WERKBAK_REFRESH_MS)` (5 s) re-reads the existing BFF endpoint, scoped to the page with `takeUntilDestroyed()`. A *background* read leaves the rows and states on screen alone until it has an answer, so a tick never flashes the loading state over rows being read and one 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 rather than needing the very reload this slice removes. No new endpoint, dependency or server-side state, and no service boundary moves — rxjs and `GET /behandel/werkbak` are both already here. **ADR-0032** records why polling rather than a pushed stream: nothing notifies the BFF either, so SSE/WebSockets would poll the domain *inside* the BFF for the same freshness, plus connection lifecycle, nginx buffering and a stateful BFF. Proposal: #163. Closes #162 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation. - [x] Implementation makes the test pass; refactor commit if structure improved. - [x] Conventional Commits referencing the issue (`refs #162`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (unchanged; only the behandel bundle differs). - [x] Docs updated if behaviour, contracts, or operations changed. - [x] ADR added in `docs/architecture/` (ADR-0032). - [x] Demo note in `docs/demo-script.md` (user-visible). ## Notes for reviewers **The e2e is the real acceptance test, and it took two goes to make it one.** Simply dropping the `staff.reload()` from the happy path proved nothing: the werkbak was visited *after* the documents were supplied, so the row was already there at page load. The spec now logs the behandelaar in **first**, asserts the row is not there yet, and only then has the citizen supply the documents that route it to Beoordelen — so the row can only reach that already-open, never-reloaded page via the refresh. Verified both ways against a live stack: with the interval stubbed out it fails at `Goedkeuren <ref> … element(s) not found` after 30 s; with it, the behandel nginx logs the poll that delivers the row. The page is foregrounded before the assertion because Chromium throttles timers in a hidden tab. **Ceiling (named in the ADR):** 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 `interval` for a stream — the endpoint contract and the rendering stay put. Gate on `document.visibilityState` first if request volume is the concern. **Two housekeeping notes, neither blocking:** - #162 is on **no milestone** (DoD item 1). It is portal UX, so it fits neither *Data Governance* nor *Production Posture* cleanly — your call where it lands. - The issue titles itself **S-26**, which already belongs to the self-service resume slice (#111, `BACKLOG.md`). Everything here references **#162**; worth renumbering the title if the S-ids are meant to stay unique. `BACKLOG.md` is untouched for the same reason (it mirrors the active milestone, and this slice is on none).Reviewed-on: #164
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
import { signal } from '@angular/core';
|
|
import { fireEvent, render, screen } from '@testing-library/angular';
|
|
import { of, throwError } from 'rxjs';
|
|
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
|
import { AuthService } from 'auth';
|
|
import { axe } from 'vitest-axe';
|
|
import { WERKBAK_REFRESH_MS, WerkbakPage } from './werkbak-page';
|
|
|
|
const sample: WerkbakItem[] = [
|
|
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
|
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
|
|
];
|
|
|
|
class FakeAuth extends AuthService {
|
|
readonly isAuthenticated = signal(true);
|
|
readonly bsn = signal<string | undefined>(undefined);
|
|
override readonly roles = signal<readonly string[]>(['behandelaar']);
|
|
login(): void {
|
|
/* not exercised here */
|
|
}
|
|
logout(): void {
|
|
/* spied in tests */
|
|
}
|
|
}
|
|
|
|
function setup(
|
|
overrides: {
|
|
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
|
|
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
|
|
} = {},
|
|
) {
|
|
const getBehandelWerkbak =
|
|
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
|
|
const postBehandelRegistrationsIdDecide =
|
|
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
|
|
return {
|
|
getBehandelWerkbak,
|
|
postBehandelRegistrationsIdDecide,
|
|
providers: [
|
|
{
|
|
provide: BffApiV1Service,
|
|
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
|
|
},
|
|
{ provide: AuthService, useClass: FakeAuth },
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('WerkbakPage', () => {
|
|
it('lists the registrations awaiting beoordeling on open', async () => {
|
|
const { getBehandelWerkbak, providers } = setup();
|
|
await render(WerkbakPage, { providers });
|
|
|
|
expect(getBehandelWerkbak).toHaveBeenCalled();
|
|
expect(await screen.findByText('reg-1')).toBeTruthy();
|
|
expect(screen.getByText('123456782')).toBeTruthy();
|
|
expect(screen.getByText('reg-2')).toBeTruthy();
|
|
});
|
|
|
|
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
|
|
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
|
|
await render(WerkbakPage, { providers });
|
|
|
|
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
|
|
|
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
|
besluit: 'goedkeuren',
|
|
});
|
|
// Reloaded after the decision: once on open, once after deciding.
|
|
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
|
|
const { postBehandelRegistrationsIdDecide, providers } = setup();
|
|
await render(WerkbakPage, { providers });
|
|
|
|
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
|
|
|
|
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
|
besluit: 'afwijzen',
|
|
});
|
|
});
|
|
|
|
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 () => {
|
|
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
|
await render(WerkbakPage, { providers });
|
|
|
|
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
|
|
});
|
|
|
|
it('surfaces a load failure instead of swallowing it', async () => {
|
|
const { providers } = setup({
|
|
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
|
});
|
|
await render(WerkbakPage, { providers });
|
|
|
|
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
|
});
|
|
|
|
it('has no WCAG 2.1 AA violations', async () => {
|
|
document.documentElement.lang = 'nl';
|
|
const { container } = await render(WerkbakPage, { providers: setup().providers });
|
|
|
|
const results = await axe(container, {
|
|
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
|
});
|
|
|
|
expect(results.violations).toEqual([]);
|
|
});
|
|
});
|