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:
Bas van Rossem
2026-07-23 14:44:32 +02:00
parent 77db308e41
commit e7eefe8f62
4 changed files with 388 additions and 1 deletions

View File

@@ -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 userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -195,4 +195,163 @@ describe('SessionForm', () => {
expect(await screen.findByText(/Controleer de ingevoerde gegevens/)).toBeInTheDocument(); 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('');
});
}); });

View File

@@ -32,6 +32,7 @@ function isoToLocal(iso: string | null): string {
function localToIso(local: string): string | null { function localToIso(local: string): string | null {
if (!local) return null; if (!local) return null;
const d = new Date(local); 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; if (Number.isNaN(d.getTime())) return null;
return d.toISOString(); return d.toISOString();
} }

View File

@@ -192,4 +192,188 @@ describe('Sessions', () => {
expect(within(list).queryByText('Piet')).not.toBeInTheDocument(); expect(within(list).queryByText('Piet')).not.toBeInTheDocument();
expect(within(list).queryByText('Klaas')).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();
});
}); });

View File

@@ -407,3 +407,46 @@ Guaranteed behaviors:
- Lifecycle (`src/screens/Live.test.tsx`): active-sessions data renders a running card and a paused - 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 card side by side, with exactly the paused session showing the `Gepauzeerd` badge and its
`Pauze 00:01:30` total. `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.