test(admin): Sessions + SessionForm — fill gaps + lifecycle test
Drives Sessions.tsx and SessionForm.tsx to 100% stmts/branch/funcs/lines. Added gap tests: sessions error state, in-filter empty message, row placeholders (missing worker/activity/type/date), frozen worked-time for a paused active session, edit-form open, and form cancel; SessionForm create/edit validation paths, non-400 error surfacing, null serialization (empty zooltype + notitie, status=active clears end_time), pair/pause floors, and the defensive no-session edit guard. Lifecycle test (Sessions.test.tsx): open create form -> fill a valid manual session -> it appears in the list -> edit the row -> the change persists, driven by a stateful apiFetch mock. Annotated one unreachable branch: localToIso's Number.isNaN guard is c8-ignored (a datetime-local input only yields a valid datetime or empty). No bugs found; no behavior changes.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
@@ -195,4 +195,163 @@ describe('SessionForm', () => {
|
||||
|
||||
expect(await screen.findByText(/Controleer de ingevoerde gegevens/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a generic error when the API fails with a non-400', async () => {
|
||||
mockApiFetch.mockImplementation((path: string) => {
|
||||
if (path === '/api/admin/users') return Promise.resolve(USERS as never);
|
||||
if (path === '/api/activities') return Promise.resolve(ACTIVITIES as never);
|
||||
return Promise.reject(new Error('network down'));
|
||||
});
|
||||
|
||||
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
|
||||
|
||||
await screen.findByLabelText('Handeling');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
expect(await screen.findByText(/Opslaan mislukt/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('create: blocks submit and warns when handeling or starttijd is missing', async () => {
|
||||
const onClose = vi.fn();
|
||||
renderForm({ mode: 'create', onClose });
|
||||
|
||||
await screen.findByLabelText('Handeling');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
expect(await screen.findByText(/Vul handeling en starttijd in/)).toBeInTheDocument();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(mockApiFetch).not.toHaveBeenCalledWith(
|
||||
'/api/admin/sessions',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('create: warns when medewerker, zooltype or eindtijd is missing', async () => {
|
||||
renderForm({ mode: 'create', onClose: vi.fn() });
|
||||
|
||||
await screen.findByRole('option', { name: 'Frezen' });
|
||||
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
|
||||
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
expect(await screen.findByText(/Vul medewerker, zooltype/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('create: trims the notitie into the payload', async () => {
|
||||
renderForm({ mode: 'create', onClose: vi.fn() });
|
||||
|
||||
const worker = await screen.findByLabelText('Medewerker');
|
||||
await screen.findByRole('option', { name: 'Jan' });
|
||||
await userEvent.selectOptions(worker, 'u1');
|
||||
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
|
||||
await userEvent.selectOptions(screen.getByLabelText('Zooltype'), 'Kurk');
|
||||
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
|
||||
await userEvent.type(screen.getByLabelText('Eindtijd'), '2026-06-17T09:00');
|
||||
await userEvent.type(screen.getByLabelText('Notitie'), ' let op ');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/sessions',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
),
|
||||
);
|
||||
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions');
|
||||
const body = JSON.parse((call![1] as RequestInit).body as string);
|
||||
expect(body.notes).toBe('let op');
|
||||
});
|
||||
|
||||
it('edit: clearing zooltype and notitie serializes them to null', async () => {
|
||||
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
|
||||
|
||||
await screen.findByRole('option', { name: 'Lijmen' });
|
||||
await userEvent.selectOptions(screen.getByLabelText('Zooltype'), '');
|
||||
await userEvent.clear(screen.getByLabelText('Notitie'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/sessions/7',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
),
|
||||
);
|
||||
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions/7');
|
||||
const body = JSON.parse((call![1] as RequestInit).body as string);
|
||||
expect(body.insole_type).toBeNull();
|
||||
expect(body.notes).toBeNull();
|
||||
});
|
||||
|
||||
it('edit: setting status to actief clears the end time', async () => {
|
||||
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
|
||||
|
||||
await screen.findByLabelText('Status');
|
||||
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||
'/api/admin/sessions/7',
|
||||
expect.objectContaining({ method: 'PUT' }),
|
||||
),
|
||||
);
|
||||
const call = mockApiFetch.mock.calls.find((c) => c[0] === '/api/admin/sessions/7');
|
||||
const body = JSON.parse((call![1] as RequestInit).body as string);
|
||||
expect(body.status).toBe('active');
|
||||
expect(body.end_time).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the handeling selection back to empty and re-blocks submit', async () => {
|
||||
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
|
||||
|
||||
await screen.findByRole('option', { name: 'Lijmen' });
|
||||
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
expect(await screen.findByText(/Vul handeling en starttijd in/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clamps aantal paren to 1 and pauze to 0 on invalid input', async () => {
|
||||
renderForm({ mode: 'edit', session: makeSession(), onClose: vi.fn() });
|
||||
|
||||
const pairs = (await screen.findByLabelText('Aantal paren')) as HTMLInputElement;
|
||||
const paused = screen.getByLabelText('Pauze (minuten)') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(pairs, { target: { value: '' } });
|
||||
expect(pairs.value).toBe('1');
|
||||
fireEvent.change(pairs, { target: { value: '5' } });
|
||||
expect(pairs.value).toBe('5');
|
||||
|
||||
fireEvent.change(paused, { target: { value: '' } });
|
||||
expect(paused.value).toBe('0');
|
||||
fireEvent.change(paused, { target: { value: '15' } });
|
||||
expect(paused.value).toBe('15');
|
||||
});
|
||||
|
||||
it('edit mode without a session is a no-op on submit (defensive guard)', async () => {
|
||||
renderForm({ mode: 'edit', onClose: vi.fn() });
|
||||
|
||||
await screen.findByRole('option', { name: 'Frezen' });
|
||||
await userEvent.selectOptions(screen.getByLabelText('Handeling'), '10');
|
||||
await userEvent.type(screen.getByLabelText('Starttijd'), '2026-06-17T08:00');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
// Passes validation but the missing session short-circuits before any PUT.
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
mockApiFetch.mock.calls.some((c) => (c[1] as RequestInit | undefined)?.method === 'PUT'),
|
||||
).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
it('edit: an unparseable prefilled starttijd falls back to an empty field', async () => {
|
||||
renderForm({
|
||||
mode: 'edit',
|
||||
session: makeSession({ start_time: 'nonsense', end_time: 'nonsense' }),
|
||||
onClose: vi.fn(),
|
||||
});
|
||||
|
||||
const start = (await screen.findByLabelText('Starttijd')) as HTMLInputElement;
|
||||
expect(start.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ function isoToLocal(iso: string | null): string {
|
||||
function localToIso(local: string): string | null {
|
||||
if (!local) return null;
|
||||
const d = new Date(local);
|
||||
/* c8 ignore next -- a datetime-local input only yields a valid datetime or '', never an unparseable value */
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
@@ -192,4 +192,188 @@ describe('Sessions', () => {
|
||||
expect(within(list).queryByText('Piet')).not.toBeInTheDocument();
|
||||
expect(within(list).queryByText('Klaas')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state when the sessions query fails', async () => {
|
||||
mockApiFetch.mockImplementation((path: string) => {
|
||||
if (path === '/api/admin/sessions') return Promise.reject(new Error('boom'));
|
||||
return Promise.resolve([] as never);
|
||||
});
|
||||
renderSessions();
|
||||
|
||||
expect(await screen.findByText('Kon sessies niet laden.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the in-filter empty message when the filter matches nothing', async () => {
|
||||
mockEndpoints([makeSession({ id: 1, status: 'completed', user_name: 'Jan' })]);
|
||||
renderSessions();
|
||||
|
||||
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||
await userEvent.selectOptions(screen.getByLabelText('Status'), 'active');
|
||||
|
||||
expect(screen.getByText('Geen sessies in dit filter.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to placeholders for a row missing worker, activity, type and date', async () => {
|
||||
const bare = makeSession({
|
||||
id: 9,
|
||||
user_name: undefined,
|
||||
activity_name: undefined,
|
||||
insole_type: null,
|
||||
paused_seconds: 0,
|
||||
status: 'completed',
|
||||
start_time: 'not-a-real-date',
|
||||
});
|
||||
mockEndpoints([bare]);
|
||||
renderSessions();
|
||||
|
||||
expect(await screen.findByText('Onbekend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Onbekende handeling')).toBeInTheDocument();
|
||||
// aria-label uses the lowercase 'onbekend' fallback when user_name is absent.
|
||||
expect(screen.getByRole('button', { name: 'Bewerk sessie van onbekend' })).toBeInTheDocument();
|
||||
// no pause line when paused_seconds is 0, and no insole-type chip.
|
||||
expect(screen.queryByText(/Pauze/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Kurk')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('freezes worked time at the pause moment for a paused active session', async () => {
|
||||
const pausedActive = makeSession({
|
||||
id: 8,
|
||||
status: 'active',
|
||||
duration_seconds: null,
|
||||
end_time: null,
|
||||
start_time: new Date(Date.now() - 600_000).toISOString(), // started 10 min ago
|
||||
paused_at: new Date(Date.now() - 300_000).toISOString(), // paused 5 min ago
|
||||
paused_seconds: 60,
|
||||
});
|
||||
mockEndpoints([pausedActive]);
|
||||
renderSessions();
|
||||
|
||||
// worked = (paused_at - start)/1000 - paused_seconds = 300 - 60 = 240s = 00:04:00
|
||||
expect(await screen.findByText('00:04:00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('✎ opens the edit form for that row', async () => {
|
||||
mockEndpoints(SESSIONS);
|
||||
renderSessions();
|
||||
|
||||
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bewerk sessie van Jan' }));
|
||||
|
||||
const form = await screen.findByTestId('session-form');
|
||||
// Edit mode renders a Status select and no worker picker (create mode is the inverse).
|
||||
expect(within(form).getByLabelText('Status')).toBeInTheDocument();
|
||||
expect(within(form).queryByLabelText('Medewerker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancelling the form returns to the list', async () => {
|
||||
mockEndpoints(SESSIONS);
|
||||
renderSessions();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
|
||||
expect(await screen.findByTestId('session-form')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Annuleren' }));
|
||||
expect(screen.queryByTestId('session-form')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Lifecycle: create a manual session through the form → it lands in the list →
|
||||
// edit it → the change persists. Uses a stateful mock that behaves like the backend.
|
||||
const LC_USERS = [{ id: 'u1', name: 'Jan', email: 'jan@example.com' }];
|
||||
const LC_ACTIVITIES = [
|
||||
{ id: 10, name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'], created_at: '', sort_order: 0 },
|
||||
{ id: 11, name: 'Lijmen', insole_types: ['Kurk', 'Berk', '3D'], created_at: '', sort_order: 1 },
|
||||
];
|
||||
|
||||
describe('Sessions + SessionForm lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
mockApiFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates a manual session via the form, then persists an edit', async () => {
|
||||
const store: WorkSession[] = [];
|
||||
let nextId = 100;
|
||||
|
||||
mockApiFetch.mockImplementation((path: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? 'GET';
|
||||
if (path === '/api/admin/users') return Promise.resolve(LC_USERS as never);
|
||||
if (path === '/api/activities') return Promise.resolve(LC_ACTIVITIES as never);
|
||||
if (path === '/api/admin/sessions' && method === 'GET') {
|
||||
return Promise.resolve([...store] as never);
|
||||
}
|
||||
if (path === '/api/admin/sessions' && method === 'POST') {
|
||||
const input = JSON.parse(init!.body as string);
|
||||
const created = makeSession({
|
||||
...input,
|
||||
id: nextId++,
|
||||
user_name: LC_USERS.find((u) => u.id === input.user_id)?.name,
|
||||
activity_name: LC_ACTIVITIES.find((a) => a.id === input.activity_id)?.name,
|
||||
duration_seconds:
|
||||
(Date.parse(input.end_time) - Date.parse(input.start_time)) / 1000 -
|
||||
input.paused_seconds,
|
||||
status: 'completed',
|
||||
});
|
||||
store.push(created);
|
||||
return Promise.resolve(created as never);
|
||||
}
|
||||
const put = path.match(/^\/api\/admin\/sessions\/(\d+)$/);
|
||||
if (put && method === 'PUT') {
|
||||
const id = Number(put[1]);
|
||||
const input = JSON.parse(init!.body as string);
|
||||
const idx = store.findIndex((s) => s.id === id);
|
||||
store[idx] = {
|
||||
...store[idx],
|
||||
...input,
|
||||
activity_name: LC_ACTIVITIES.find((a) => a.id === input.activity_id)?.name,
|
||||
};
|
||||
return Promise.resolve(store[idx] as never);
|
||||
}
|
||||
return Promise.resolve([] as never);
|
||||
});
|
||||
|
||||
renderSessions();
|
||||
|
||||
// Starts empty.
|
||||
expect(await screen.findByText('Nog geen sessies.')).toBeInTheDocument();
|
||||
|
||||
// Open create form and fill a valid manual session.
|
||||
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe registratie' }));
|
||||
const createForm = await screen.findByTestId('session-form');
|
||||
await within(createForm).findByRole('option', { name: 'Jan' });
|
||||
await within(createForm).findByRole('option', { name: 'Frezen' });
|
||||
await userEvent.selectOptions(within(createForm).getByLabelText('Medewerker'), 'u1');
|
||||
await userEvent.selectOptions(within(createForm).getByLabelText('Handeling'), '10');
|
||||
await userEvent.selectOptions(within(createForm).getByLabelText('Zooltype'), 'Kurk');
|
||||
await userEvent.type(within(createForm).getByLabelText('Starttijd'), '2026-06-17T08:00');
|
||||
await userEvent.type(within(createForm).getByLabelText('Eindtijd'), '2026-06-17T09:00');
|
||||
await userEvent.click(within(createForm).getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
// Form closes and the new row appears in the list.
|
||||
await waitFor(() => expect(screen.queryByTestId('session-form')).not.toBeInTheDocument());
|
||||
const list = await screen.findByRole('list', { name: 'Sessies' });
|
||||
expect(within(list).getByText('Jan')).toBeInTheDocument();
|
||||
expect(within(list).getByText('Frezen')).toBeInTheDocument();
|
||||
|
||||
// Edit the row: change the activity Frezen → Lijmen.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Bewerk sessie van Jan' }));
|
||||
const editForm = await screen.findByTestId('session-form');
|
||||
await within(editForm).findByRole('option', { name: 'Lijmen' });
|
||||
await userEvent.selectOptions(within(editForm).getByLabelText('Handeling'), '11');
|
||||
await userEvent.click(within(editForm).getByRole('button', { name: /Opslaan/ }));
|
||||
|
||||
// The change persists in the refreshed list.
|
||||
await waitFor(() => expect(screen.queryByTestId('session-form')).not.toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(screen.getByRole('list', { name: 'Sessies' })).getByText('Lijmen'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole('list', { name: 'Sessies' })).queryByText('Frezen'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,3 +407,46 @@ Guaranteed behaviors:
|
||||
- Lifecycle (`src/screens/Live.test.tsx`): active-sessions data renders a running card and a paused
|
||||
card side by side, with exactly the paused session showing the `Gepauzeerd` badge and its
|
||||
`Pauze 00:01:30` total.
|
||||
|
||||
## Sessions + SessionForm — admin client
|
||||
|
||||
Src covered (admin `apps/admin`): `src/screens/Sessions.tsx` and `src/components/SessionForm.tsx` —
|
||||
both at 100% stmts/branch/funcs/lines. One `c8 ignore` with a reason: `localToIso`'s
|
||||
`Number.isNaN` guard is unreachable because a `datetime-local` input only ever yields a valid
|
||||
datetime string or `''`.
|
||||
|
||||
Guaranteed behaviors:
|
||||
|
||||
- Sessions renders one `.sessions-row` per session (worker, activity + insole-type chip, worked
|
||||
time, start date, status badge). Row worked-time is server-authoritative: completed/discarded rows
|
||||
use `duration_seconds`; an active row counts `(base − start) − paused_seconds` where `base` is the
|
||||
frozen `paused_at` moment while paused (else `now`), floored at 0.
|
||||
- The `Status` filter narrows the list to `all`/`active`/`completed`/`discarded`; the empty message is
|
||||
`Nog geen sessies.` when there are truly none and `Geen sessies in dit filter.` when the filter
|
||||
hides every row.
|
||||
- The query's error state renders `Kon sessies niet laden.` (no list); the loading state renders
|
||||
`Laden…`; a non-array payload is treated as zero sessions.
|
||||
- Row placeholders: a missing `user_name` renders `Onbekend` (and the edit aria-label uses
|
||||
`onbekend`), a missing `activity_name` renders `Onbekende handeling`, a null `insole_type` omits the
|
||||
type chip, and an unparseable `start_time` renders an empty date cell; the `Pauze <time>` line shows
|
||||
only when `paused_seconds > 0`.
|
||||
- `Stop` and `Annuleer` appear only on `active` rows and call `POST /api/admin/sessions/:id/stop` and
|
||||
`.../discard`; the `✎` edit button appears on every row and opens the shared form in edit mode.
|
||||
- `+ Nieuwe registratie` opens the shared form in create mode; cancelling (`Annuleren`) closes it and
|
||||
returns to the list.
|
||||
- SessionForm create mode shows the medewerker picker (hidden in edit mode) and POSTs a
|
||||
`CreateManualSessionInput`; edit mode hides the picker, shows a `Status` select, prefills from the
|
||||
session, and PUTs an `AdminUpdateSessionInput`.
|
||||
- Create validation: an empty handeling/starttijd shows `Vul handeling en starttijd in.` and blocks the
|
||||
POST; a filled handeling+starttijd but missing medewerker/zooltype/eindtijd shows
|
||||
`Vul medewerker, zooltype, start- en eindtijd in.`.
|
||||
- Serialization: the notitie is trimmed (empty → `null`); in edit mode an empty zooltype serializes to
|
||||
`insole_type: null` and status `active` serializes `end_time: null`; `Aantal paren` floors at 1 and
|
||||
`Pauze (minuten)` floors at 0 on invalid/empty input.
|
||||
- The gewerkt preview renders `end − start − paused` (`—` when the span is indeterminate); a
|
||||
prefilled unparseable date falls back to an empty field; a 400 from the API surfaces
|
||||
`Controleer de ingevoerde gegevens …` while any other failure surfaces `Opslaan mislukt …`.
|
||||
- Edit mode without a session is a defensive no-op on submit (no PUT is issued).
|
||||
- Lifecycle (`src/screens/Sessions.test.tsx`): open the create form → fill a valid manual session →
|
||||
submit → it appears in the list; then edit that row (change the handeling) → the change persists in
|
||||
the refreshed list.
|
||||
|
||||
Reference in New Issue
Block a user