From e7eefe8f629f5277062c78b657b7910aeb6097a0 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 23 Jul 2026 14:44:32 +0200 Subject: [PATCH] =?UTF-8?q?test(admin):=20Sessions=20+=20SessionForm=20?= =?UTF-8?q?=E2=80=94=20fill=20gaps=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../admin/src/components/SessionForm.test.tsx | 161 ++++++++++++++- apps/admin/src/components/SessionForm.tsx | 1 + apps/admin/src/screens/Sessions.test.tsx | 184 ++++++++++++++++++ docs/reference/domain-test-checklist.md | 43 ++++ 4 files changed, 388 insertions(+), 1 deletion(-) diff --git a/apps/admin/src/components/SessionForm.test.tsx b/apps/admin/src/components/SessionForm.test.tsx index 6fe987c..e32c4e7 100644 --- a/apps/admin/src/components/SessionForm.test.tsx +++ b/apps/admin/src/components/SessionForm.test.tsx @@ -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(''); + }); }); diff --git a/apps/admin/src/components/SessionForm.tsx b/apps/admin/src/components/SessionForm.tsx index 03792c4..ff6b6f7 100644 --- a/apps/admin/src/components/SessionForm.tsx +++ b/apps/admin/src/components/SessionForm.tsx @@ -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(); } diff --git a/apps/admin/src/screens/Sessions.test.tsx b/apps/admin/src/screens/Sessions.test.tsx index 6cb0c83..a07470c 100644 --- a/apps/admin/src/screens/Sessions.test.tsx +++ b/apps/admin/src/screens/Sessions.test.tsx @@ -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(); + }); }); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 3c7ae69..81d5b76 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -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