test(admin): Users — fill gaps + lifecycle test

This commit is contained in:
Bas van Rossem
2026-07-23 15:07:12 +02:00
parent 5aa6febd65
commit 8a6bc9ea7d
4 changed files with 281 additions and 0 deletions

View 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();
});
});

View File

@@ -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(),
);
});
});

View File

@@ -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 },