diff --git a/apps/worker/src/lib/export.test.ts b/apps/worker/src/lib/export.test.ts new file mode 100644 index 0000000..3fdbd13 --- /dev/null +++ b/apps/worker/src/lib/export.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { downloadExport } from './export'; +import { setToken } from './auth-storage'; + +describe('downloadExport', () => { + beforeEach(() => { + localStorage.clear(); + // jsdom does not implement the object-URL API; stub it so the download path runs. + URL.createObjectURL = vi.fn(() => 'blob:mock-url'); + URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('fetches the CSV with the bearer token and triggers an object-URL download', async () => { + setToken('tok'); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const fetchMock = vi.fn().mockResolvedValue(new Response('a,b\n1,2\n', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await downloadExport(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://localhost:3000/api/export'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok'); + expect(URL.createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('sends no Authorization header when there is no token', async () => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const fetchMock = vi.fn().mockResolvedValue(new Response('x', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await downloadExport(); + + const [, init] = fetchMock.mock.calls[0]; + expect(new Headers(init.headers).has('Authorization')).toBe(false); + }); + + it('throws when the response is not ok', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + + await expect(downloadExport()).rejects.toThrow('Kan de export niet openen'); + }); +}); diff --git a/apps/worker/src/screens/History.test.tsx b/apps/worker/src/screens/History.test.tsx index 9a2567c..d9e7139 100644 --- a/apps/worker/src/screens/History.test.tsx +++ b/apps/worker/src/screens/History.test.tsx @@ -120,4 +120,80 @@ describe('History', () => { await user.click(screen.getByRole('button', { name: 'Exporteer CSV' })); expect(mockedDownloadExport).toHaveBeenCalledTimes(1); }); + + it('shows an error message when the export fails', async () => { + const user = userEvent.setup(); + mockedDownloadExport.mockRejectedValueOnce(new Error('boom')); + renderHistory(); + + await user.click(screen.getByRole('button', { name: 'Exporteer CSV' })); + expect(await screen.findByText(/Kan de export niet openen/)).toBeInTheDocument(); + }); + + it('formats a sub-minute duration as just seconds', async () => { + mockedApiFetch.mockResolvedValue([session({ duration_seconds: 45 })]); + renderHistory(); + + const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement; + expect(card.textContent).toContain('45s'); + expect(card.textContent).not.toContain('m 45s'); + }); + + it('renders 0s when duration_seconds is null', async () => { + mockedApiFetch.mockResolvedValue([session({ duration_seconds: null })]); + renderHistory(); + + const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement; + expect(card.textContent).toContain('0s'); + }); + + it('falls back to "Onbekende handeling" when the activity name is null', async () => { + mockedApiFetch.mockResolvedValue([session({ activity_name: undefined })]); + renderHistory(); + + expect(await screen.findByText('Onbekende handeling')).toBeInTheDocument(); + }); + + it('omits the type pill when insole_type is null', async () => { + mockedApiFetch.mockResolvedValue([session({ insole_type: null })]); + renderHistory(); + + const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement; + expect(card.textContent).not.toContain('Kurk'); + }); + + it('renders sessions in the order the API returns them (newest-first)', async () => { + mockedApiFetch.mockResolvedValue([ + session({ id: 2, activity_name: 'Frezen' }), + session({ id: 1, activity_name: 'Zagen' }), + ]); + const { container } = renderHistory(); + + await screen.findByText('Frezen'); + const names = [...container.querySelectorAll('.session-name')].map((el) => el.textContent); + expect(names).toEqual(['Frezen', 'Zagen']); + }); + + it('shows neither the empty state nor cards while loading', () => { + mockedApiFetch.mockReturnValue(new Promise(() => {})); + const { container } = renderHistory(); + + expect(screen.queryByText('Nog geen opgeslagen sessies.')).not.toBeInTheDocument(); + expect(container.querySelectorAll('.session-card')).toHaveLength(0); + }); + + it('lifecycle: renders a completed paused session, then export calls the download path', async () => { + const user = userEvent.setup(); + mockedApiFetch.mockResolvedValue([ + session({ status: 'completed', duration_seconds: 3661, paused_seconds: 125 }), + ]); + renderHistory(); + + const card = (await screen.findByText('Frezen')).closest('.session-card') as HTMLElement; + expect(card.textContent).toContain('1h 1m'); + expect(card.textContent).toContain('Pauze 2m 5s'); + + await user.click(screen.getByRole('button', { name: 'Exporteer CSV' })); + expect(mockedDownloadExport).toHaveBeenCalledTimes(1); + }); }); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 8fa1e55..634f17c 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -283,3 +283,31 @@ Guaranteed behaviors: > implemented** in the current code (there is no global 401 interceptor; `apiFetch` throws > `ApiError` and callers surface it via React Query). Left as-is (missing feature, not a defect); > not tested and not filed as a bug. + +## History — worker client + +Src covered (worker `apps/worker`): `src/screens/History.tsx` and `src/lib/export.ts` — both at +100% stmts/branch/funcs/lines. No `c8 ignore`s needed. + +Guaranteed behaviors: + +- History renders the Dutch header (`Geschiedenis`) and the `Exporteer CSV` button. +- Sessions render in the order the API returns them (`GET /api/sessions` is newest-first), one + `.session-card` per session, showing the activity name and its formatted duration. +- `formatDuration` renders the legacy format: `Hh Mm` when hours are present (`3661 → 1h 1m`), + `Mm Ss` when only minutes (`125 → 2m 5s`), and just seconds under a minute (`45 → 45s`); a null + `duration_seconds` renders `0s`. +- A card falls back to `"Onbekende handeling"` when `activity_name` is absent, omits the insole-type + pill when `insole_type` is null, and uses the singular noun (`inlegzool`) only for a pair count of + 1 (`inlegzolen` otherwise). +- A `Pauze ` line shows only when `paused_seconds > 0` (absent when it is 0). +- The empty state (`Nog geen opgeslagen sessies.`) shows only once loading has finished and there are + no sessions; while the query is still loading, neither the empty state nor any cards render. +- Clicking `Exporteer CSV` calls `downloadExport`; when it rejects, the card surfaces the + `Kan de export niet openen` error message instead of throwing. +- `downloadExport` (`src/lib/export.ts`): fetches `GET /api/export` with the bearer token when one is + stored (no `Authorization` header when there is none), then triggers a client-side download via an + object URL (`createObjectURL` → anchor `click` → `revokeObjectURL`); it throws + `"Kan de export niet openen"` when the response is not ok. +- Lifecycle (`src/screens/History.test.tsx`): the list renders a completed session showing both its + worked duration and its `Pauze` line, then the export action invokes the download path.