267 lines
9.9 KiB
TypeScript
267 lines
9.9 KiB
TypeScript
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Reports />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
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'));
|
|
});
|
|
});
|
|
});
|