test(worker): History — fill gaps + lifecycle test
This commit is contained in:
51
apps/worker/src/lib/export.test.ts
Normal file
51
apps/worker/src/lib/export.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,4 +120,80 @@ describe('History', () => {
|
|||||||
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
|
await user.click(screen.getByRole('button', { name: 'Exporteer CSV' }));
|
||||||
expect(mockedDownloadExport).toHaveBeenCalledTimes(1);
|
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<never>(() => {}));
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -283,3 +283,31 @@ Guaranteed behaviors:
|
|||||||
> implemented** in the current code (there is no global 401 interceptor; `apiFetch` throws
|
> 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);
|
> `ApiError` and callers surface it via React Query). Left as-is (missing feature, not a defect);
|
||||||
> not tested and not filed as a bug.
|
> 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 <duration>` 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user