From 8a6bc9ea7d34f1fb1acfb50acc8b6e91562a042c Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 23 Jul 2026 15:07:12 +0200 Subject: [PATCH] =?UTF-8?q?test(admin):=20Users=20=E2=80=94=20fill=20gaps?= =?UTF-8?q?=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/UserForm.test.tsx | 50 +++++ apps/admin/src/screens/Users.test.tsx | 192 ++++++++++++++++++++ apps/admin/src/screens/Users.tsx | 2 + docs/reference/domain-test-checklist.md | 37 ++++ 4 files changed, 281 insertions(+) create mode 100644 apps/admin/src/components/UserForm.test.tsx diff --git a/apps/admin/src/components/UserForm.test.tsx b/apps/admin/src/components/UserForm.test.tsx new file mode 100644 index 0000000..795388e --- /dev/null +++ b/apps/admin/src/components/UserForm.test.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import UserForm from './UserForm'; + +function renderForm(over: Partial[0]> = {}) { + const props = { onSubmit: vi.fn(), onCancel: vi.fn(), pending: false, error: null, ...over }; + render(); + return props; +} + +describe('UserForm', () => { + it('submits trimmed name/email with the selected role', async () => { + const props = renderForm(); + await userEvent.type(screen.getByLabelText('Naam'), ' Nieuw '); + await userEvent.type(screen.getByLabelText('E-mail'), ' nieuw@x.nl '); + await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-1'); + await userEvent.selectOptions(screen.getByLabelText('Rol'), 'admin'); + await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' })); + expect(props.onSubmit).toHaveBeenCalledWith({ + name: 'Nieuw', + email: 'nieuw@x.nl', + password: 'wachtwoord-1', + role: 'admin', + }); + }); + + it('warns and blocks submit when the password is shorter than 8', async () => { + const props = renderForm(); + await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw'); + await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl'); + await userEvent.type(screen.getByLabelText('Wachtwoord'), 'kort'); + expect(screen.getByText('Minstens 8 tekens.')).toBeInTheDocument(); + // fireEvent.submit bypasses native constraint validation to exercise the JS guard. + fireEvent.submit(screen.getByTestId('user-form')); + expect(props.onSubmit).not.toHaveBeenCalled(); + }); + + it('renders the error message from the error prop', () => { + renderForm({ error: 'E-mailadres bestaat al of ongeldig.' }); + expect(screen.getByText('E-mailadres bestaat al of ongeldig.')).toBeInTheDocument(); + }); + + it('disables submit while pending and cancels via onCancel', async () => { + const props = renderForm({ pending: true }); + expect(screen.getByRole('button', { name: 'Aanmaken' })).toBeDisabled(); + await userEvent.click(screen.getByRole('button', { name: 'Annuleer' })); + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/apps/admin/src/screens/Users.test.tsx b/apps/admin/src/screens/Users.test.tsx index e3b600c..d9dc1d2 100644 --- a/apps/admin/src/screens/Users.test.tsx +++ b/apps/admin/src/screens/Users.test.tsx @@ -92,6 +92,16 @@ describe('Users', () => { ); }); + it('closes the create form when Annuleer is clicked', async () => { + mockEndpoints(); + renderUsers(); + await screen.findByText('Jan'); + await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' })); + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Annuleer' })); + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + it('Deactiveer on another user calls the deactivate endpoint', async () => { mockEndpoints(); renderUsers(); @@ -104,4 +114,186 @@ describe('Users', () => { ), ); }); + + it('shows an error state when the users query fails', async () => { + mockApiFetch.mockImplementation((path?: string) => { + if (path === '/api/admin/users') return Promise.reject(new Error('boom')); + return Promise.resolve({ user: USERS[0] } as never); + }); + renderUsers(); + expect(await screen.findByText('Kon gebruikers niet laden.')).toBeInTheDocument(); + }); + + it('toggles another user to admin via the role button', async () => { + mockEndpoints(); + renderUsers(); + const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement; + await userEvent.click(within(janRow).getByRole('button', { name: 'Maak admin' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users/u2/role', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ role: 'admin' }) }), + ), + ); + }); + + it('demotes another admin to worker via the role button', async () => { + const admin2: AdminUser = { + id: 'u4', + name: 'Bea', + email: 'bea@x', + role: 'admin', + status: 'active', + created_at: new Date('2026-06-04T00:00:00Z').toISOString(), + }; + mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => { + if (path === '/api/admin/users' && (!init || init.method === undefined)) + return Promise.resolve([USERS[0], admin2] as never); + if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never); + return Promise.resolve({} as never); + }); + renderUsers(); + const beaRow = (await screen.findByText('Bea')).closest('tr') as HTMLElement; + await userEvent.click(within(beaRow).getByRole('button', { name: 'Maak werker' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users/u4/role', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ role: 'worker' }) }), + ), + ); + }); + + it('reactivates an inactive user', async () => { + mockEndpoints(); + renderUsers(); + const anRow = (await screen.findByText('An')).closest('tr') as HTMLElement; + await userEvent.click(within(anRow).getByRole('button', { name: 'Heractiveer' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users/u3/reactivate', + expect.objectContaining({ method: 'POST' }), + ), + ); + }); + + it('opens the inline password reset and posts the new password', async () => { + mockEndpoints(); + renderUsers(); + const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement; + await userEvent.click(within(janRow).getByRole('button', { name: 'Reset wachtwoord' })); + await userEvent.type(within(janRow).getByLabelText('Nieuw wachtwoord voor Jan'), 'nieuw-1234'); + await userEvent.click(within(janRow).getByRole('button', { name: 'Opslaan' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + '/api/admin/users/u2/password', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ password: 'nieuw-1234' }), + }), + ), + ); + }); + + it('keeps the reset save disabled until 8 chars, then cancels the inline reset', async () => { + mockEndpoints(); + renderUsers(); + const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement; + await userEvent.click(within(janRow).getByRole('button', { name: 'Reset wachtwoord' })); + await userEvent.type(within(janRow).getByLabelText('Nieuw wachtwoord voor Jan'), 'short'); + expect(within(janRow).getByRole('button', { name: 'Opslaan' })).toBeDisabled(); + await userEvent.click(within(janRow).getByRole('button', { name: 'Annuleer' })); + expect(within(janRow).queryByLabelText('Nieuw wachtwoord voor Jan')).not.toBeInTheDocument(); + }); + + it('surfaces an error when creating a user fails', async () => { + mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => { + if (path === '/api/admin/users' && init?.method === 'POST') + return Promise.reject(new Error('dup')); + if (path === '/api/admin/users') return Promise.resolve(USERS as never); + if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never); + return Promise.resolve({} as never); + }); + renderUsers(); + await screen.findByText('Jan'); + await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' })); + await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw'); + await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl'); + await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-123'); + await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' })); + expect(await screen.findByText('E-mailadres bestaat al of ongeldig.')).toBeInTheDocument(); + }); + + // Lifecycle: create a worker via the form → appears in the list → promote to admin + // (reflected after refetch) → reset its password inline (field closes on success). + it('lifecycle: create → list → promote → reset password', async () => { + const list: AdminUser[] = [USERS[0]]; + let nextId = 100; + mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => { + const method = init?.method; + if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never); + if (path === '/api/admin/users' && !method) return Promise.resolve([...list] as never); + if (path === '/api/admin/users' && method === 'POST') { + const body = JSON.parse(init!.body as string); + const created: AdminUser = { + id: `u${nextId++}`, + name: body.name, + email: body.email, + role: body.role, + status: 'active', + created_at: new Date('2026-07-01T00:00:00Z').toISOString(), + }; + list.push(created); + return Promise.resolve(created as never); + } + const roleMatch = path?.match(/^\/api\/admin\/users\/(.+)\/role$/); + if (roleMatch && method === 'POST') { + const body = JSON.parse(init!.body as string); + const u = list.find((x) => x.id === roleMatch[1])!; + u.role = body.role; + return Promise.resolve(u as never); + } + if (path?.endsWith('/password') && method === 'POST') + return Promise.resolve({ success: true } as never); + return Promise.resolve({} as never); + }); + + renderUsers(); + await screen.findByRole('button', { name: '+ Nieuwe gebruiker' }); + + await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' })); + await userEvent.type(screen.getByLabelText('Naam'), 'Sanne'); + await userEvent.type(screen.getByLabelText('E-mail'), 'sanne@x.nl'); + await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-1'); + await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' })); + + const sanneRow = (await screen.findByText('Sanne')).closest('tr') as HTMLElement; + expect(within(sanneRow).getByText('Werker')).toBeInTheDocument(); + + await userEvent.click(within(sanneRow).getByRole('button', { name: 'Maak admin' })); + await waitFor(() => { + const row = screen.getByText('Sanne').closest('tr') as HTMLElement; + expect(within(row).getByText('Beheerder')).toBeInTheDocument(); + }); + + const promoted = screen.getByText('Sanne').closest('tr') as HTMLElement; + await userEvent.click(within(promoted).getByRole('button', { name: 'Reset wachtwoord' })); + await userEvent.type( + within(promoted).getByLabelText('Nieuw wachtwoord voor Sanne'), + 'geheim-1234', + ); + await userEvent.click(within(promoted).getByRole('button', { name: 'Opslaan' })); + await waitFor(() => + expect(mockApiFetch).toHaveBeenCalledWith( + expect.stringMatching(/\/password$/), + expect.objectContaining({ method: 'POST' }), + ), + ); + await waitFor(() => + expect( + within(screen.getByText('Sanne').closest('tr') as HTMLElement).queryByLabelText( + 'Nieuw wachtwoord voor Sanne', + ), + ).not.toBeInTheDocument(), + ); + }); }); diff --git a/apps/admin/src/screens/Users.tsx b/apps/admin/src/screens/Users.tsx index e03efc5..04ffd0a 100644 --- a/apps/admin/src/screens/Users.tsx +++ b/apps/admin/src/screens/Users.tsx @@ -43,6 +43,7 @@ export default function Users() { ); } + /* c8 ignore next -- defensive: isLoading/isError returned above, so data is always set here */ const users = usersQuery.data ?? []; return ( @@ -96,6 +97,7 @@ function UserRow({ user, isSelf }: { user: AdminUser; isSelf: boolean }) { setRole.isPending || deactivate.isPending || reactivate.isPending || resetPassword.isPending; function onReset() { + /* c8 ignore next -- defensive: the Opslaan button is disabled while pw<8, so this never fires */ if (pw.length < 8) return; resetPassword.mutate( { id: user.id, password: pw }, diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 7f6948c..bcdbf84 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -479,3 +479,40 @@ Guaranteed behaviors: `content-disposition` header, and falls back to `solelog-report.csv` when that header is absent. - Lifecycle (`src/screens/Reports.test.tsx`): pick `Deze maand` + a Medewerker filter → the report query reflects both (`from` = this-month start, `user_id` set) and the returned rows still render. + +## Users — admin client + +Src covered (admin `apps/admin`): `src/screens/Users.tsx`, `src/components/UserForm.tsx`, and +`src/api/users.ts` — `UserForm.tsx` and `users.ts` at 100% stmts/branch/funcs/lines; `Users.tsx` at +100% stmts/branch/funcs/lines. Two `c8 ignore`s with reasons in `Users.tsx`: the `usersQuery.data ?? []` +fallback (defensive — the isLoading/isError branches return above it, so `data` is always set) and the +`if (pw.length < 8) return;` guard in `onReset` (defensive — the Opslaan button is disabled while the +password is shorter than 8, so it never fires). + +Guaranteed behaviors: + +- The screen lists one row per user with a role pill (`Beheerder`/`Werker`) and a status pill + (`Actief`/`Inactief`), plus name, email, and a locale-formatted created date. +- The signed-in admin's own row (matched by `useMe().user.id`) shows a `jij` badge and hides the + role-toggle and deactivate/reactivate actions (a user can never demote or deactivate themselves via + the UI); other rows show those actions. +- The loading state renders `Laden…` and the query error state renders `Kon gebruikers niet laden.`. +- `+ Nieuwe gebruiker` toggles the create form; submitting it POSTs a `CreateUserInput` + (`{ name, email, password, role }`) to `/api/admin/users`; `Annuleer` closes the form without a POST. +- Create errors surface inline as `E-mailadres bestaat al of ongeldig.` (the `useCreateUser` onError + path), which renders in the `UserForm` error slot. +- `UserForm`: name/email are trimmed into the payload and the selected `Rol` is carried through; a + password shorter than 8 shows `Minstens 8 tekens.` and blocks submission (the `password.length < 8` + guard); the submit button is disabled while `pending`. +- The role button promotes a worker to admin (`{ role: 'admin' }`) or demotes an admin to worker + (`{ role: 'worker' }`) via `POST /api/admin/users/:id/role`. +- Inline password reset: `Reset wachtwoord` reveals a password field with `Opslaan`/`Annuleer`; + `Opslaan` stays disabled until the new password is at least 8 chars, then POSTs `{ password }` to + `/api/admin/users/:id/password` and closes the field on success; `Annuleer` closes it without a POST. +- `Deactiveer` POSTs to `/api/admin/users/:id/deactivate` (shown on active peers) and `Heractiveer` + POSTs to `/api/admin/users/:id/reactivate` (shown on inactive peers). +- Every mutation (`create`/`setRole`/`resetPassword`/`deactivate`/`reactivate`) invalidates the + `['admin','users']` query so the list refetches and reflects the change. +- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed + list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch → + reset its password inline (`Opslaan`) → the POST fires and the inline field closes.