diff --git a/apps/admin/src/api/reports.test.ts b/apps/admin/src/api/reports.test.ts index d7abf8d..acc91f1 100644 --- a/apps/admin/src/api/reports.test.ts +++ b/apps/admin/src/api/reports.test.ts @@ -1,7 +1,9 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { filtersToQuery, downloadExport, type ReportFilters } from './reports'; -vi.mock('../lib/auth-storage', () => ({ getToken: () => 'tok-123' })); +// Mutable so a single test can exercise the no-token branch. +const authState = vi.hoisted(() => ({ token: 'tok-123' as string | null })); +vi.mock('../lib/auth-storage', () => ({ getToken: () => authState.token })); const FILTERS: ReportFilters = { from: '2026-06-15T00:00:00.000Z', @@ -30,6 +32,9 @@ describe('filtersToQuery', () => { }); describe('downloadExport', () => { + beforeEach(() => { + authState.token = 'tok-123'; + }); afterEach(() => vi.restoreAllMocks()); it('fetches with the bearer token and triggers a download', async () => { @@ -60,6 +65,50 @@ describe('downloadExport', () => { expect(revokeUrl).toHaveBeenCalledWith('blob:x'); }); + it('sends no Authorization header when there is no token', async () => { + authState.token = null; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['csv'])), + headers: new Headers({ 'content-disposition': 'attachment; filename="x.csv"' }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:x'), + revokeObjectURL: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(vi.fn()); + + await downloadExport(FILTERS); + + expect(fetchMock.mock.calls[0][1].headers).toEqual({}); + }); + + it('falls back to a default filename when no content-disposition header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['csv'])), + headers: new Headers({}), + }); + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:x'), + revokeObjectURL: vi.fn(), + }); + let downloaded = ''; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function (this: HTMLAnchorElement) { + downloaded = this.download; + }, + ); + + await downloadExport(FILTERS); + + expect(downloaded).toBe('solelog-report.csv'); + }); + it('throws on a non-ok response', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })); await expect(downloadExport(FILTERS)).rejects.toThrow(); diff --git a/apps/admin/src/lib/date-range.test.ts b/apps/admin/src/lib/date-range.test.ts index 695eb1a..d11aa23 100644 --- a/apps/admin/src/lib/date-range.test.ts +++ b/apps/admin/src/lib/date-range.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dayStartISO, dayEndISO, thisWeek, thisMonth } from './date-range'; +import { allTime, dayStartISO, dayEndISO, thisWeek, thisMonth, toDateStr } from './date-range'; describe('date-range helpers', () => { it('dayStartISO/dayEndISO bound a local day', () => { @@ -25,4 +25,15 @@ describe('date-range helpers', () => { expect(from).toBe('2026-06-01'); expect(to).toBe('2026-06-17'); }); + + it('allTime spans a fixed floor to today', () => { + const ref = new Date('2026-06-17T12:00:00'); + const { from, to } = allTime(ref); + expect(from).toBe('2000-01-01'); + expect(to).toBe('2026-06-17'); + }); + + it('toDateStr zero-pads month and day', () => { + expect(toDateStr(new Date(2026, 0, 5, 12, 0, 0))).toBe('2026-01-05'); + }); }); diff --git a/apps/admin/src/screens/Reports.test.tsx b/apps/admin/src/screens/Reports.test.tsx index 290bed1..92b70cd 100644 --- a/apps/admin/src/screens/Reports.test.tsx +++ b/apps/admin/src/screens/Reports.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'; @@ -6,6 +6,7 @@ import type { ReportResponse } from '@solelog/shared'; import Reports from './Reports'; import { apiFetch } from '../lib/api'; import * as reportsApi from '../api/reports'; +import { allTime, dayEndISO, dayStartISO, thisMonth, thisWeek } from '../lib/date-range'; vi.mock('../lib/api', () => ({ apiFetch: vi.fn(), API_URL: 'http://test' })); const mockApiFetch = vi.mocked(apiFetch); @@ -127,4 +128,139 @@ describe('Reports', () => { expect(arg.from).toBeTruthy(); expect(arg.to).toBeTruthy(); }); + + it('shows a loading state while the report is pending', async () => { + mockApiFetch.mockImplementation((path?: string) => { + if (path?.startsWith('/api/admin/report')) return new Promise(() => {}) as never; // never resolves + return Promise.resolve([] as never); + }); + renderReports(); + expect(await screen.findByText(/Laden/)).toBeInTheDocument(); + }); + + it('shows an error message when the report fails to load', async () => { + mockApiFetch.mockImplementation((path?: string) => { + if (path?.startsWith('/api/admin/report')) return Promise.reject(new Error('boom')); + return Promise.resolve([] as never); + }); + renderReports(); + expect(await screen.findByText('Kon rapport niet laden.')).toBeInTheDocument(); + }); + + it('renders zero totals and empty tables when the report has no data', async () => { + // An empty object exercises every `?? 0` / `?? []` defensive fallback. + mockApiFetch.mockImplementation((path?: string) => { + if (path?.startsWith('/api/admin/report')) return Promise.resolve({} as never); + return Promise.resolve([] as never); + }); + renderReports(); + + const empties = await screen.findAllByText('Geen gegevens.'); + expect(empties).toHaveLength(3); + const totals = document.querySelector('.reports-totals') as HTMLElement; + expect(totals).toHaveTextContent('00:00:00'); + expect(totals).toHaveTextContent('0 zolen'); + }); + + it('shows an export error message when the download fails', async () => { + mockEndpoints(); + vi.spyOn(reportsApi, 'downloadExport').mockRejectedValue(new Error('nope')); + renderReports(); + await screen.findByRole('cell', { name: 'Jan' }); + + await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' })); + expect(await screen.findByText('Export mislukt. Probeer opnieuw.')).toBeInTheDocument(); + }); + + it('lists activity options and includes the chosen activity in the query', async () => { + mockApiFetch.mockImplementation((path?: string) => { + if (path?.startsWith('/api/admin/report')) return Promise.resolve(REPORT as never); + if (path === '/api/admin/users') + return Promise.resolve([{ id: 'u1', name: 'Jan', email: 'jan@x' }] as never); + if (path === '/api/activities') + return Promise.resolve([ + { id: 7, name: 'Frezen', insole_types: ['Kurk'], sort_order: 0 }, + ] as never); + return Promise.resolve([] as never); + }); + renderReports(); + await screen.findByRole('cell', { name: 'Jan' }); + + await userEvent.selectOptions(screen.getByLabelText('Handeling'), '7'); + + await waitFor(() => { + const reportCalls = mockApiFetch.mock.calls.filter((c) => + String(c[0]).startsWith('/api/admin/report'), + ); + expect(String(reportCalls[reportCalls.length - 1][0])).toContain('activity_id=7'); + }); + }); + + it('lifecycle: "Deze maand" + a worker filter both reflect in the query and rows render', async () => { + mockEndpoints(); + renderReports(); + await screen.findByRole('cell', { name: 'Jan' }); + + await userEvent.click(screen.getByRole('button', { name: 'Deze maand' })); + await userEvent.selectOptions(screen.getByLabelText('Medewerker'), 'u1'); + + const expectedFrom = dayStartISO(thisMonth(new Date()).from); + await waitFor(() => { + const reportCalls = mockApiFetch.mock.calls.filter((c) => + String(c[0]).startsWith('/api/admin/report'), + ); + const last = String(reportCalls[reportCalls.length - 1][0]); + const params = new URLSearchParams(last.split('?')[1]); + expect(params.get('user_id')).toBe('u1'); + expect(params.get('from')).toBe(expectedFrom); + }); + + // Returned rows still render after the filters change. + expect(screen.getByRole('cell', { name: 'Jan' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Frezen' })).toBeInTheDocument(); + }); + + it('applies the week/all presets and the type filter to the query', async () => { + mockEndpoints(); + renderReports(); + await screen.findByRole('cell', { name: 'Jan' }); + + const lastReportParams = () => { + const calls = mockApiFetch.mock.calls.filter((c) => + String(c[0]).startsWith('/api/admin/report'), + ); + return new URLSearchParams(String(calls[calls.length - 1][0]).split('?')[1]); + }; + + await userEvent.click(screen.getByRole('button', { name: 'Alles' })); + await waitFor(() => + expect(lastReportParams().get('from')).toBe(dayStartISO(allTime(new Date()).from)), + ); + + await userEvent.click(screen.getByRole('button', { name: 'Deze week' })); + await waitFor(() => + expect(lastReportParams().get('from')).toBe(dayStartISO(thisWeek(new Date()).from)), + ); + + await userEvent.selectOptions(screen.getByLabelText('Type'), 'Berk'); + await waitFor(() => expect(lastReportParams().get('insole_type')).toBe('Berk')); + }); + + it('updates the query when the Van/Tot dates change', async () => { + mockEndpoints(); + renderReports(); + await screen.findByRole('cell', { name: 'Jan' }); + + fireEvent.change(screen.getByLabelText('Van'), { target: { value: '2026-06-10' } }); + fireEvent.change(screen.getByLabelText('Tot'), { target: { value: '2026-06-12' } }); + + await waitFor(() => { + const calls = mockApiFetch.mock.calls.filter((c) => + String(c[0]).startsWith('/api/admin/report'), + ); + const params = new URLSearchParams(String(calls[calls.length - 1][0]).split('?')[1]); + expect(params.get('from')).toBe(dayStartISO('2026-06-10')); + expect(params.get('to')).toBe(dayEndISO('2026-06-12')); + }); + }); }); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 81d5b76..7f6948c 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -450,3 +450,32 @@ Guaranteed behaviors: - 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. + +## Reports + date-range — admin client + +Src covered (admin `apps/admin`): `src/screens/Reports.tsx`, `src/api/reports.ts`, and +`src/lib/date-range.ts` — all three at 100% stmts/branch/funcs/lines. No `c8 ignore` needed. + +Guaranteed behaviors: + +- Date-range helpers derive ISO instants in the admin's local timezone: `dayStartISO` → local + 00:00:00.000 of the picked day, `dayEndISO` → local 23:59:59.999; `toDateStr` zero-pads month/day. +- Presets map to the correct `YYYY-MM-DD` windows: `thisWeek` = Monday..today, `thisMonth` = + 1st..today, `allTime` = fixed floor `2000-01-01`..today. +- `filtersToQuery` always serializes `from`/`to` and only emits `user_id`/`insole_type`/`activity_id` + when set (empty optionals are omitted). +- `useReport` calls `GET /api/admin/report?`; clicking a preset (`Deze week`/`Deze maand`/ + `Alles`), changing the Van/Tot date inputs, or picking a Medewerker/Type/Handeling filter re-issues + the report query with the new params (verified against the emitted querystring). +- The screen renders headline totals (`worked`/`pairs`/`sessions`/`paused`) and the three breakdown + tables (`Per medewerker`/`Per handeling`/`Per type`) from the response rows. +- Loading state renders `Laden…`; the query error state renders `Kon rapport niet laden.`; a report + with no data falls back to zero totals and shows `Geen gegevens.` in every table (the `?? 0` / `?? []` + defensive fallbacks). +- `Exporteer CSV` calls `downloadExport` with the current filters; a rejected download surfaces + `Export mislukt. Probeer opnieuw.`. +- `downloadExport` fetches `/api/admin/export?` with a `Bearer` header when a token exists (and + no `Authorization` header when it doesn't), throws on a non-ok response, names the file from the + `content-disposition` header, and falls back to `solelog-report.csv` when that header is absent. +- Lifecycle (`src/screens/Reports.test.tsx`): pick `Deze maand` + a Medewerker filter → the report + query reflects both (`from` = this-month start, `user_id` set) and the returned rows still render.