## What & why
Finishes **S-12 · Behandel-portal — werkbak + beoordeling**. The backend sub-slices (S-12a/b/c-1/c-2) were merged, but the slice's stated outcome — a behandel *portal* with medewerker login, a werkbak, and decide — had no frontend. This adds it.
- **`libs/auth`**: `MedewerkerAuthService` + `provideMedewerkerAuth` (Keycloak `medewerker` realm), a `roles`/`hasRole` surface on the shared `AuthService`, and a realm-roles protocol mapper so the SPA can read `behandelaar`/`teamlead` from the token. The BFF remains the security boundary (ADR-0013).
- **`apps/behandel`**: a new Nx Angular app mirroring self-service — medewerker OIDC login and a **werkbak** page listing registrations awaiting beoordeling (`GET /behandel/werkbak`) with per-row **Goedkeuren/Afwijzen** actions (`POST /behandel/registrations/{id}/decide`) that refresh the list. NL DS/Utrecht, standalone + signals.
- **e2e**: the walking-skeleton happy path now approves through the real portal (behandelaar logs in, finds the row by reference, clicks Goedkeuren) instead of the temporary admin endpoint.
- **infra/docs**: behandel service in compose (`:8142`, depends on Keycloak); added to the smoke `WAIT_SVCS` + CI log dump; `frontend-decisions.md` and `demo-script.md` updated.
Closes #13
## 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 #13`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes. *(behandel image + container verified locally; full stack gated in CI.)*
- [x] Docs updated if behaviour, contracts, or operations changed.
- [x] ADR added — ADR-0013 (merged with the backend sub-slices) already covers the wiring; no new decision here.
- [x] Demo note in `docs/demo-script.md`.
## Notes for reviewers
- Verified locally: auth + behandel + all frontend projects pass lint & unit tests (incl. axe WCAG 2.1 AA); production build green; the behandel Docker image builds and serves with the correct baked `medewerker` config + SPA fallback.
- The full compose-up smoke, e2e, and mutation are CI-gated (known local full-stack verify limits).
- **Follow-ups (not in scope):** the `WerkbakItem` contract has no citizen name (werkbak shows the BSN) — adding one is a BFF+domain contract change; and the domain's temporary admin `approve` endpoint is now unused by the e2e and could be removed.
Reviewed-on: #87
111 lines
3.8 KiB
TypeScript
111 lines
3.8 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 { 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('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([]);
|
|
});
|
|
});
|