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'; 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); const REPORT: ReportResponse = { range: { from: '2026-06-15T00:00:00.000Z', to: '2026-06-17T21:59:59.999Z' }, totals: { worked_seconds: 7800, paused_seconds: 600, pairs: 84, sessions: 36 }, by_worker: [ { user_id: 'u1', user_name: 'Jan', worked_seconds: 4500, paused_seconds: 300, pairs: 48, sessions: 21, }, { user_id: 'u2', user_name: 'An', worked_seconds: 3300, paused_seconds: 300, pairs: 36, sessions: 15, }, ], by_activity: [ { activity_id: 1, activity_name: 'Frezen', worked_seconds: 4900, paused_seconds: 200, pairs: 40, sessions: 18, }, { activity_id: 2, activity_name: 'Lijmen', worked_seconds: 2900, paused_seconds: 400, pairs: 44, sessions: 18, }, ], by_type: [ { insole_type: 'Kurk', worked_seconds: 5000, paused_seconds: 300, pairs: 50, sessions: 20 }, { insole_type: 'Berk', worked_seconds: 2800, paused_seconds: 300, pairs: 34, sessions: 16 }, ], }; function mockEndpoints() { 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([] as never); return Promise.resolve([] as never); }); } function renderReports() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } describe('Reports', () => { beforeEach(() => mockApiFetch.mockReset()); afterEach(() => vi.clearAllMocks()); it('renders headline totals and the three breakdown tables', async () => { mockEndpoints(); renderReports(); // Headline: 7800s = 02:10:00 worked, 84 zolen, 36 sessies. const headline = await screen.findByText(/02:10:00/); const totals = headline.closest('.reports-totals') as HTMLElement; expect(totals).toHaveTextContent(/84/); expect(totals).toHaveTextContent(/36/); expect(screen.getByText('Per medewerker')).toBeInTheDocument(); expect(screen.getByText('Per handeling')).toBeInTheDocument(); expect(screen.getByText('Per type')).toBeInTheDocument(); // Names are scoped to table cells: 'Jan' / 'Kurk' also appear as dropdown options. expect(screen.getByRole('cell', { name: 'Jan' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'Frezen' })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: 'Kurk' })).toBeInTheDocument(); }); it('refetches with new params when a preset is clicked', async () => { mockEndpoints(); renderReports(); await screen.findByRole('cell', { name: 'Jan' }); const callsBefore = mockApiFetch.mock.calls.filter((c) => String(c[0]).startsWith('/api/admin/report'), ).length; await userEvent.click(screen.getByRole('button', { name: 'Deze maand' })); await waitFor(() => { const reportCalls = mockApiFetch.mock.calls.filter((c) => String(c[0]).startsWith('/api/admin/report'), ); expect(reportCalls.length).toBeGreaterThan(callsBefore); }); }); it('calls downloadExport with the current filters when Exporteer CSV is clicked', async () => { mockEndpoints(); const spy = vi.spyOn(reportsApi, 'downloadExport').mockResolvedValue(); renderReports(); await screen.findByRole('cell', { name: 'Jan' }); await userEvent.click(screen.getByRole('button', { name: 'Exporteer CSV' })); expect(spy).toHaveBeenCalledTimes(1); const arg = spy.mock.calls[0][0]; 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')); }); }); });