test(worker): lib/api + app/nav — fill gaps + lifecycle test

This commit is contained in:
Bas van Rossem
2026-07-23 14:15:48 +02:00
parent 32bdaa3e31
commit e174c29a77
7 changed files with 233 additions and 0 deletions

View File

@@ -42,4 +42,20 @@ describe('App', () => {
expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument();
expect(tabbar.getByText('Account')).toBeInTheDocument();
});
it('lifecycle: unauthenticated renders Login (no nav); authenticated renders Stopwatch under the tab bar', () => {
// No token -> the Login screen, and no tab bar navigation.
clearToken();
const { unmount } = renderApp();
expect(screen.getByText('E-mailadres')).toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
unmount();
// Token present -> the Stopwatch screen is mounted at "/" beside the tab bar.
setToken('tok');
renderApp();
expect(screen.getByText('Type zool')).toBeInTheDocument();
expect(within(screen.getByRole('navigation')).getByText('Stopwatch')).toBeInTheDocument();
expect(screen.queryByText('E-mailadres')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import { useActivities } from './activities';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn().mockResolvedValue([]) }));
const mockedApiFetch = vi.mocked(apiFetch);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);
describe('useActivities', () => {
it('queries GET /api/activities', async () => {
const { result } = renderHook(() => useActivities(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/activities');
});
});

View File

@@ -0,0 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import { useMe } from './me';
vi.mock('../lib/api', () => ({
apiFetch: vi.fn().mockResolvedValue({ user: { id: 'u1', email: 'a@b.c', name: 'A' } }),
}));
const mockedApiFetch = vi.mocked(apiFetch);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
{children}
</QueryClientProvider>
);
describe('useMe', () => {
it('queries GET /api/me', async () => {
const { result } = renderHook(() => useMe(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/me');
});
});

View File

@@ -0,0 +1,78 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import { apiFetch } from '../lib/api';
import {
useActiveSessions,
useDiscardSession,
usePauseSession,
useResumeSession,
useSessions,
useStartSession,
useStopSession,
} from './sessions';
vi.mock('../lib/api', () => ({ apiFetch: vi.fn() }));
const mockedApiFetch = vi.mocked(apiFetch);
let queryClient: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
describe('sessions api hooks', () => {
beforeEach(() => {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
mockedApiFetch.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('useSessions queries GET /api/sessions', async () => {
const { result } = renderHook(() => useSessions(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions');
});
it('useActiveSessions queries GET /api/sessions/active', async () => {
const { result } = renderHook(() => useActiveSessions(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions/active');
});
it('useStartSession POSTs the serialized input and invalidates the sessions cache', async () => {
const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => useStartSession(), { wrapper });
await result.current.mutateAsync({ activity_id: 1, insole_type: 'Kurk', pair_count: 3 });
expect(mockedApiFetch).toHaveBeenCalledWith('/api/sessions/start', {
method: 'POST',
body: JSON.stringify({ activity_id: 1, insole_type: 'Kurk', pair_count: 3 }),
});
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions'] });
});
const idHooks: Array<[string, () => ReturnType<typeof useStopSession>, string]> = [
['useStopSession', useStopSession, 'stop'],
['useDiscardSession', useDiscardSession, 'discard'],
['usePauseSession', usePauseSession, 'pause'],
['useResumeSession', useResumeSession, 'resume'],
];
it.each(idHooks)(
'%s POSTs to /api/sessions/:id/%s and invalidates the sessions cache',
async (_name, hook, action) => {
const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
const { result } = renderHook(() => hook(), { wrapper });
await result.current.mutateAsync(42);
expect(mockedApiFetch).toHaveBeenCalledWith(`/api/sessions/42/${action}`, { method: 'POST' });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['sessions'] });
},
);
});

View File

@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import TabBar from './TabBar';
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<TabBar />
</MemoryRouter>,
);
}
describe('TabBar', () => {
it('renders the three tabs as links to their routes', () => {
renderAt('/');
expect(screen.getByRole('link', { name: 'Stopwatch' })).toHaveAttribute('href', '/');
expect(screen.getByRole('link', { name: 'Geschiedenis' })).toHaveAttribute('href', '/history');
expect(screen.getByRole('link', { name: 'Account' })).toHaveAttribute('href', '/account');
});
it('marks the tab of the current route active and leaves the others inactive', () => {
renderAt('/history');
expect(screen.getByRole('link', { name: 'Geschiedenis' })).toHaveClass('tab-active');
expect(screen.getByRole('link', { name: 'Stopwatch' })).not.toHaveClass('tab-active');
expect(screen.getByRole('link', { name: 'Account' })).not.toHaveClass('tab-active');
});
it('uses end matching so the Stopwatch tab is active only on the root path', () => {
renderAt('/');
expect(screen.getByRole('link', { name: 'Stopwatch' })).toHaveClass('tab-active');
});
});

View File

@@ -34,6 +34,36 @@ describe('api client', () => {
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
});
it('returns undefined when the response body is empty', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 200 })));
await expect(apiFetch('/api/sessions/1/stop', { method: 'POST' })).resolves.toBeUndefined();
});
it('sets Content-Type to application/json when a body is present and none is set', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/api/sessions/start', { method: 'POST', body: JSON.stringify({ a: 1 }) });
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('application/json');
});
it('does not override an explicitly provided Content-Type header', async () => {
const mock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', mock);
await apiFetch('/x', {
method: 'POST',
body: 'raw',
headers: { 'Content-Type': 'text/plain' },
});
const [, init] = mock.mock.calls[0];
expect(new Headers(init.headers).get('Content-Type')).toBe('text/plain');
});
it('signIn stores the token from the set-auth-token header', async () => {
vi.stubGlobal(
'fetch',