test(admin): Reports + date-range — fill gaps + lifecycle test
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?<query>`; 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?<query>` 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.
|
||||
|
||||
Reference in New Issue
Block a user