test(admin): Users — fill gaps + lifecycle test
This commit is contained in:
50
apps/admin/src/components/UserForm.test.tsx
Normal file
50
apps/admin/src/components/UserForm.test.tsx
Normal file
@@ -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<Parameters<typeof UserForm>[0]> = {}) {
|
||||
const props = { onSubmit: vi.fn(), onCancel: vi.fn(), pending: false, error: null, ...over };
|
||||
render(<UserForm {...props} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user