test(behandel): werkbak lists cases, decides them, and secures the BFF calls (refs #13)
Drives the behandel-portal frontend: the werkbak lists registrations awaiting beoordeling, a decision (goedkeuren/afwijzen) posts to the BFF and refreshes, an empty werkbak and a load failure are surfaced, the page is WCAG 2.1 AA clean, and the medewerker token attaches to the relative /behandel/ calls (and not to the anonymous openbaar register). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
73
apps/behandel/src/app/app.config.spec.ts
Normal file
73
apps/behandel/src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { BffApiV1Service } from 'api-client';
|
||||||
|
import { authInterceptor } from 'auth';
|
||||||
|
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
||||||
|
import { SECURE_API_ROUTES } from './app.config';
|
||||||
|
|
||||||
|
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
||||||
|
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
||||||
|
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
||||||
|
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
||||||
|
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
||||||
|
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
||||||
|
describe('behandel medewerker token wiring', () => {
|
||||||
|
let http: HttpTestingController;
|
||||||
|
let bff: BffApiV1Service;
|
||||||
|
const token = 'medewerker-access-token';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([authInterceptor()])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: ConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
hasAtLeastOneConfig: () => true,
|
||||||
|
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A signed-in session: the storage the interceptor's token lookup reads from.
|
||||||
|
provide: AbstractSecurityStorage,
|
||||||
|
useValue: {
|
||||||
|
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
||||||
|
write: () => undefined,
|
||||||
|
remove: () => undefined,
|
||||||
|
clear: () => undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
http = TestBed.inject(HttpTestingController);
|
||||||
|
bff = TestBed.inject(BffApiV1Service);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => http.verify());
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative werkbak call', () => {
|
||||||
|
bff.getBehandelWerkbak().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/werkbak');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attaches the bearer token to the relative decide call', () => {
|
||||||
|
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne('/behandel/registrations/reg-1/decide');
|
||||||
|
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
||||||
|
req.flush(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
||||||
|
bff.getOpenbaarRegister().subscribe();
|
||||||
|
|
||||||
|
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
||||||
|
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/behandel/src/app/app.spec.ts
Normal file
15
apps/behandel/src/app/app.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
expect(screen).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user