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 },
|
||||
|
||||
Reference in New Issue
Block a user