test(admin): Activities + app/nav — fill gaps + lifecycle test

Fill coverage gaps and lock in behavior for the Activities screen and
app/nav (Sidebar) domain (plan task B-D6). All four domain src files
(screens/Activities.tsx, api/activities.ts, App.tsx, components/Sidebar.tsx)
now at 100% stmts/branch/funcs/lines.

- Activities.test.tsx: insole-type toggle select/deselect (both branches),
  reduced-subset create, add disabled when no types, edit insole types,
  cancel-without-save, empty-types-defaults-all editor, empty state,
  error-falls-back-to-empty, and the create->edit types->reorder->delete
  lifecycle test.
- Sidebar.test.tsx (new): brand + nav links + email, active-route highlight,
  end-match keeps Live inactive off-root, nav click switches screen + moves
  highlight, Uitloggen calls signOut, email hidden without useMe data.
- Activities.tsx: three /* c8 ignore next */ annotations on the defensive
  early-return guards in handleAdd/handleSave/handleMove (each mirrored by a
  disabled button, so unreachable via the UI). Comment-only, no behavior change.

No bugs found — the domain code was already correct; the tests lock it in.
This commit is contained in:
Bas van Rossem
2026-07-23 15:18:59 +02:00
parent 8a6bc9ea7d
commit 75c5a9f350
4 changed files with 369 additions and 1 deletions

View File

@@ -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(
<MemoryRouter initialEntries={[initial]}>
<Sidebar />
<Routes>
<Route path="/" element={<div>SCREEN Live</div>} />
<Route path="/handelingen" element={<div>SCREEN Handelingen</div>} />
<Route path="/sessies" element={<div>SCREEN Sessies</div>} />
<Route path="/rapporten" element={<div>SCREEN Rapporten</div>} />
<Route path="/gebruikers" element={<div>SCREEN Gebruikers</div>} />
</Routes>
</MemoryRouter>,
);
}
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();
});
});

View File

@@ -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 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';
@@ -163,4 +163,232 @@ describe('Activities', () => {
expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Verplaats Frezen omlaag' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'Verplaats Polijsten omhoog' })).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();
});
}); });

View File

@@ -92,6 +92,7 @@ export default function Activities() {
const addEnabled = newName.trim().length > 0 && newTypes.length > 0; const addEnabled = newName.trim().length > 0 && newTypes.length > 0;
function handleAdd() { function handleAdd() {
/* c8 ignore next -- defensive: the Stap toevoegen button is disabled when !addEnabled */
if (!addEnabled) return; if (!addEnabled) return;
createActivity.mutate( createActivity.mutate(
{ name: newName.trim(), insole_types: newTypes }, { name: newName.trim(), insole_types: newTypes },
@@ -115,6 +116,7 @@ export default function Activities() {
} }
function handleSave(id: number) { 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; if (editName.trim().length === 0 || editTypes.length === 0) return;
updateActivity.mutate( updateActivity.mutate(
{ id, input: { name: editName.trim(), insole_types: editTypes } }, { id, input: { name: editName.trim(), insole_types: editTypes } },
@@ -131,6 +133,7 @@ export default function Activities() {
function handleMove(index: number, direction: -1 | 1) { function handleMove(index: number, direction: -1 | 1) {
const target = index + direction; const target = index + direction;
/* c8 ignore next -- defensive: up/down buttons are disabled at the list bounds */
if (target < 0 || target >= activities.length) return; if (target < 0 || target >= activities.length) return;
const ids = activities.map((a) => a.id); const ids = activities.map((a) => a.id);
[ids[index], ids[target]] = [ids[target], ids[index]]; [ids[index], ids[target]] = [ids[target], ids[index]];

View File

@@ -516,3 +516,45 @@ Guaranteed behaviors:
- Lifecycle (`src/screens/Users.test.tsx`): create a worker via the form → it appears in the refreshed - 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 → 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. 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 (<n>)`).
- 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.