diff --git a/apps/admin/src/components/Sidebar.test.tsx b/apps/admin/src/components/Sidebar.test.tsx new file mode 100644 index 0000000..c174780 --- /dev/null +++ b/apps/admin/src/components/Sidebar.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import Sidebar from './Sidebar'; + +const signOut = vi.hoisted(() => vi.fn()); +const meState = vi.hoisted(() => ({ email: 'admin@solelog.local' as string | undefined })); + +vi.mock('../auth/AuthContext', () => ({ + useAuth: () => ({ isAuthed: true, signIn: vi.fn(), signOut }), +})); +vi.mock('../api/me', () => ({ + useMe: () => ({ data: meState.email ? { user: { email: meState.email } } : undefined }), +})); + +// Render the Sidebar next to a stub route table so navigation can be observed. +function renderSidebar(initial = '/') { + return render( + + + + SCREEN Live} /> + SCREEN Handelingen} /> + SCREEN Sessies} /> + SCREEN Rapporten} /> + SCREEN Gebruikers} /> + + , + ); +} + +describe('Sidebar', () => { + afterEach(() => { + signOut.mockReset(); + meState.email = 'admin@solelog.local'; + vi.restoreAllMocks(); + }); + + it('renders the brand, every nav link, and the signed-in email', () => { + renderSidebar(); + expect(screen.getByText('SoleLog Admin')).toBeInTheDocument(); + const nav = within(screen.getByRole('navigation')); + for (const label of ['Live', 'Handelingen', 'Sessies', 'Rapporten', 'Gebruikers']) { + expect(nav.getByRole('link', { name: label })).toBeInTheDocument(); + } + expect(screen.getByText('admin@solelog.local')).toBeInTheDocument(); + }); + + it('highlights the active route and only that route', () => { + renderSidebar('/'); + expect(screen.getByText('SCREEN Live')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Live' }).className).toContain('nav-link-active'); + expect(screen.getByRole('link', { name: 'Handelingen' }).className).not.toContain( + 'nav-link-active', + ); + }); + + it('keeps Live inactive when a non-root route is active (end match on /)', () => { + renderSidebar('/handelingen'); + expect(screen.getByText('SCREEN Handelingen')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Handelingen' }).className).toContain( + 'nav-link-active', + ); + expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active'); + }); + + it('clicking a nav link switches the rendered screen and moves the highlight', async () => { + const user = userEvent.setup(); + renderSidebar('/'); + expect(screen.getByText('SCREEN Live')).toBeInTheDocument(); + + await user.click(screen.getByRole('link', { name: 'Rapporten' })); + + expect(screen.getByText('SCREEN Rapporten')).toBeInTheDocument(); + expect(screen.queryByText('SCREEN Live')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Rapporten' }).className).toContain('nav-link-active'); + expect(screen.getByRole('link', { name: 'Live' }).className).not.toContain('nav-link-active'); + }); + + it('clicking Uitloggen calls signOut', async () => { + const user = userEvent.setup(); + renderSidebar(); + await user.click(screen.getByRole('button', { name: 'Uitloggen' })); + expect(signOut).toHaveBeenCalledTimes(1); + }); + + it('hides the email span when useMe has no data', () => { + meState.email = undefined; + renderSidebar(); + expect(screen.queryByText('admin@solelog.local')).not.toBeInTheDocument(); + // The logout control is still present. + expect(screen.getByRole('button', { name: 'Uitloggen' })).toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/screens/Activities.test.tsx b/apps/admin/src/screens/Activities.test.tsx index 264138d..4388ada 100644 --- a/apps/admin/src/screens/Activities.test.tsx +++ b/apps/admin/src/screens/Activities.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } 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'; @@ -163,4 +163,232 @@ describe('Activities', () => { expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).toBeEnabled(); }); + + it('type toggles deselect and reselect insole types (add form)', async () => { + const user = userEvent.setup(); + renderActivities(); + await screen.findByText('Frezen'); + + const kurk = screen.getByRole('button', { name: 'Kurk' }); + expect(kurk).toHaveAttribute('aria-pressed', 'true'); + + // Remove branch of toggle(). + await user.click(kurk); + expect(kurk).toHaveAttribute('aria-pressed', 'false'); + // Re-select — add branch of toggle(). + await user.click(kurk); + expect(kurk).toHaveAttribute('aria-pressed', 'true'); + }); + + it('adds a handeling with a reduced insole-type subset', async () => { + const user = userEvent.setup(); + renderActivities(); + await screen.findByText('Frezen'); + + await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes'); + // Default is all three; drop Berk so the POST carries only the remaining subset. + await user.click(screen.getByRole('button', { name: 'Berk' })); + await user.click(screen.getByRole('button', { name: 'Stap toevoegen' })); + + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledWith('/api/activities', { + method: 'POST', + body: JSON.stringify({ name: 'Randjes', insole_types: ['Kurk', '3D'] }), + }); + }); + }); + + it('disables Stap toevoegen when every insole type is deselected', async () => { + const user = userEvent.setup(); + renderActivities(); + await screen.findByText('Frezen'); + + await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Randjes'); + const add = screen.getByRole('button', { name: 'Stap toevoegen' }); + expect(add).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Kurk' })); + await user.click(screen.getByRole('button', { name: 'Berk' })); + await user.click(screen.getByRole('button', { name: '3D' })); + expect(add).toBeDisabled(); + }); + + it('editing insole types PUTs the updated subset', async () => { + const user = userEvent.setup(); + renderActivities(); + await screen.findByText('Frezen'); // Frezen has ['Kurk', 'Berk'] + + await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' })); + const li = screen.getByDisplayValue('Frezen').closest('li')!; + // Add 3D via the edit-form toggles. + await user.click(within(li).getByRole('button', { name: '3D' })); + await user.click(within(li).getByRole('button', { name: 'Opslaan' })); + + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/1', { + method: 'PUT', + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk', 'Berk', '3D'] }), + }); + }); + }); + + it('cancel exits edit mode without saving', async () => { + const user = userEvent.setup(); + renderActivities(); + await screen.findByText('Frezen'); + + await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' })); + expect(screen.getByDisplayValue('Frezen')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Annuleren' })); + + expect(screen.queryByDisplayValue('Frezen')).not.toBeInTheDocument(); + expect(mockApiFetch).not.toHaveBeenCalledWith( + '/api/activities/1', + expect.objectContaining({ method: 'PUT' }), + ); + }); + + it('editing an activity with no insole types pre-selects all three', async () => { + const user = userEvent.setup(); + mockApiFetch.mockResolvedValue([ + { + id: 9, + name: 'Blanco', + insole_types: [], + created_at: '2026-06-17T00:00:00.000Z', + sort_order: 0, + }, + ]); + renderActivities(); + await screen.findByText('Blanco'); + + await user.click(screen.getByRole('button', { name: 'Bewerk Blanco' })); + const li = screen.getByDisplayValue('Blanco').closest('li')!; + for (const type of ['Kurk', 'Berk', '3D']) { + expect(within(li).getByRole('button', { name: type })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + } + }); + + it('shows the empty state when there are no activities', async () => { + mockApiFetch.mockResolvedValue([]); + renderActivities(); + + expect( + await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'), + ).toBeInTheDocument(); + expect(screen.getByText('Huidige stappen (0)')).toBeInTheDocument(); + }); + + it('falls back to the empty state when the query errors (no dedicated error UI)', async () => { + mockApiFetch.mockRejectedValue(new Error('boom')); + renderActivities(); + + expect( + await screen.findByText('Nog geen stappen. Voeg er een toe hierboven.'), + ).toBeInTheDocument(); + }); + + // Lifecycle: create → edit insole types → reorder → delete, each step reflected in the list. + it('lifecycle: create → edit insole types → reorder → delete', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); + + let list: Activity[] = [ + { + id: 1, + name: 'Lijmen', + insole_types: ['3D'], + created_at: '2026-06-17T00:00:00.000Z', + sort_order: 0, + }, + ]; + let nextId = 2; + mockApiFetch.mockImplementation((path: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + if (path === '/api/activities' && method === 'GET') return Promise.resolve(list); + if (path === '/api/activities' && method === 'POST') { + const body = JSON.parse(init!.body as string); + const created: Activity = { + id: nextId++, + name: body.name, + insole_types: body.insole_types, + created_at: '2026-07-23T00:00:00.000Z', + sort_order: list.length, + }; + list = [...list, created]; + return Promise.resolve(created); + } + if (path === '/api/activities/reorder' && method === 'PUT') { + const { ids } = JSON.parse(init!.body as string) as { ids: number[] }; + list = ids.map((id, i) => ({ ...list.find((a) => a.id === id)!, sort_order: i })); + return Promise.resolve(list); + } + const idMatch = path.match(/^\/api\/activities\/(\d+)$/); + if (idMatch && method === 'PUT') { + const id = Number(idMatch[1]); + const body = JSON.parse(init!.body as string); + list = list.map((a) => + a.id === id ? { ...a, name: body.name, insole_types: body.insole_types } : a, + ); + return Promise.resolve(list.find((a) => a.id === id)!); + } + if (idMatch && method === 'DELETE') { + const id = Number(idMatch[1]); + list = list.filter((a) => a.id !== id); + return Promise.resolve({ success: true }); + } + return Promise.reject(new Error(`unexpected ${method} ${path}`)); + }); + + const { container } = renderActivities(); + + // 1. Create Frezen (defaults to all three types). + await screen.findByText('Lijmen'); + await user.type(screen.getByPlaceholderText('Naam van de stap, bijv. Leerrand'), 'Frezen'); + await user.click(screen.getByRole('button', { name: 'Stap toevoegen' })); + expect(await screen.findByText('Frezen')).toBeInTheDocument(); + expect(screen.getByText('Huidige stappen (2)')).toBeInTheDocument(); + + // 2. Edit its insole types down to just Kurk. + await user.click(screen.getByRole('button', { name: 'Bewerk Frezen' })); + const editLi = screen.getByDisplayValue('Frezen').closest('li')!; + await user.click(within(editLi).getByRole('button', { name: 'Berk' })); + await user.click(within(editLi).getByRole('button', { name: '3D' })); + await user.click(within(editLi).getByRole('button', { name: 'Opslaan' })); + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', { + method: 'PUT', + body: JSON.stringify({ name: 'Frezen', insole_types: ['Kurk'] }), + }); + }); + + // 3. Reorder: move Frezen above Lijmen. + await user.click(await screen.findByRole('button', { name: 'Verplaats Frezen omhoog' })); + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/reorder', { + method: 'PUT', + body: JSON.stringify({ ids: [2, 1] }), + }); + }); + await waitFor(() => { + const names = Array.from(container.querySelectorAll('.activity-name')).map( + (n) => n.textContent, + ); + expect(names).toEqual(['Frezen', 'Lijmen']); + }); + + // 4. Delete Frezen. + await user.click(screen.getByRole('button', { name: 'Verwijder Frezen' })); + await waitFor(() => { + expect(mockApiFetch).toHaveBeenCalledWith('/api/activities/2', { method: 'DELETE' }); + }); + await waitFor(() => { + expect(screen.queryByText('Frezen')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Huidige stappen (1)')).toBeInTheDocument(); + }); }); diff --git a/apps/admin/src/screens/Activities.tsx b/apps/admin/src/screens/Activities.tsx index 36c44f1..02f67bb 100644 --- a/apps/admin/src/screens/Activities.tsx +++ b/apps/admin/src/screens/Activities.tsx @@ -92,6 +92,7 @@ export default function Activities() { const addEnabled = newName.trim().length > 0 && newTypes.length > 0; function handleAdd() { + /* c8 ignore next -- defensive: the Stap toevoegen button is disabled when !addEnabled */ if (!addEnabled) return; createActivity.mutate( { name: newName.trim(), insole_types: newTypes }, @@ -115,6 +116,7 @@ export default function Activities() { } function handleSave(id: number) { + /* c8 ignore next -- defensive: the Opslaan button is disabled when name/types are empty */ if (editName.trim().length === 0 || editTypes.length === 0) return; updateActivity.mutate( { id, input: { name: editName.trim(), insole_types: editTypes } }, @@ -131,6 +133,7 @@ export default function Activities() { function handleMove(index: number, direction: -1 | 1) { const target = index + direction; + /* c8 ignore next -- defensive: up/down buttons are disabled at the list bounds */ if (target < 0 || target >= activities.length) return; const ids = activities.map((a) => a.id); [ids[index], ids[target]] = [ids[target], ids[index]]; diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index bcdbf84..968ae71 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -516,3 +516,45 @@ Guaranteed behaviors: - Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed list as `Werker` → promote it via `Maak admin` → the pill flips to `Beheerder` after the refetch → reset its password inline (`Opslaan`) → the POST fires and the inline field closes. + +## Activities + app/nav — admin client + +Src covered (admin `apps/admin`): `src/screens/Activities.tsx`, `src/api/activities.ts`, +`src/App.tsx`, and `src/components/Sidebar.tsx` — all at 100% stmts/branch/funcs/lines. Three +`c8 ignore`s with reasons in `Activities.tsx`: the early-return guards in `handleAdd`, `handleSave`, +and `handleMove` are unreachable via the UI because the button that triggers each is disabled in +exactly that state (`Stap toevoegen` when `!addEnabled`, `Opslaan` when the name/types are empty, +the ↑/↓ move buttons at the list bounds). + +Guaranteed behaviors: + +- Activities renders the Dutch header (`Handelingen` / `Beheer handelingen per zooltype`), the + add-handeling card, and the current-steps list with a live count (`Huidige stappen ()`). +- The insole-type toggles select/deselect a type (both the remove and re-add branches), reflected via + `aria-pressed`; a new handeling defaults to all three types (`Kurk`, `Berk`, `3D`). +- Creating a handeling POSTs `/api/activities` with the trimmed name and the selected `insole_types` + subset (a deselected type is dropped from the payload); `Stap toevoegen` is disabled while the name + is blank or every type is deselected. +- Editing a handeling opens an inline editor prefilled from the row and PUTs `/api/activities/:id` with + the updated name + `insole_types`; an activity stored with no insole types pre-selects all three in the + editor; `Annuleren` exits edit mode without issuing a PUT. +- Deleting a handeling confirms first (`window.confirm`); DELETE `/api/activities/:id` fires only when + confirmed, and is skipped when cancelled. +- Reorder: ↑/↓ move a row and PUT `/api/activities/reorder` with the swapped id order; ↑ is disabled on + the first row and ↓ on the last. +- The list shows the loading placeholder (`Laden...`) while the query is pending and the empty state + (`Nog geen stappen. Voeg er een toe hierboven.`) when there are none; a query error falls back to the + same empty state (there is no dedicated error UI). +- `api/activities.ts` hooks hit the right path/method — `useActivities` → GET `/api/activities`; + `useCreateActivity` → POST; `useUpdateActivity` → PUT `/:id`; `useDeleteActivity` → DELETE `/:id`; + `useReorderActivities` → PUT `/reorder` — and every mutation invalidates the `['activities']` query on + success. +- App/nav: `App` gates on auth (no token → `Login`; a token → the authed shell with `Sidebar` + routed + screens). `Sidebar` renders the brand, the five nav links, and the signed-in email (hidden when + `useMe` has no data), and `Uitloggen` calls `signOut`. +- `Sidebar` highlights the active route via `nav-link-active` and, with `end` matching on `/`, keeps the + Live link inactive on any non-root route; clicking a nav link switches the rendered screen and moves + the highlight. +- Lifecycle (`src/screens/Activities.test.tsx`): create a handeling → edit its insole types down to a + single type → reorder it above another → delete it, with each step reflected in the refetched list; + the Sidebar test (`src/components/Sidebar.test.tsx`) covers navigation switching screens.