test(worker): lib/api + app/nav — fill gaps + lifecycle test
This commit is contained in:
@@ -42,4 +42,20 @@ describe('App', () => {
|
|||||||
expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument();
|
expect(tabbar.getByText('Geschiedenis')).toBeInTheDocument();
|
||||||
expect(tabbar.getByText('Account')).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
23
apps/worker/src/api/activities.test.tsx
Normal file
23
apps/worker/src/api/activities.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
apps/worker/src/api/me.test.tsx
Normal file
25
apps/worker/src/api/me.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/worker/src/api/sessions.test.tsx
Normal file
78
apps/worker/src/api/sessions.test.tsx
Normal 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'] });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
33
apps/worker/src/components/TabBar.test.tsx
Normal file
33
apps/worker/src/components/TabBar.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,36 @@ describe('api client', () => {
|
|||||||
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
|
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 () => {
|
it('signIn stores the token from the set-auth-token header', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|||||||
@@ -327,3 +327,31 @@ Guaranteed behaviors:
|
|||||||
- Lifecycle (`src/screens/Account.test.tsx`): from the authenticated app on the Account screen,
|
- Lifecycle (`src/screens/Account.test.tsx`): from the authenticated app on the Account screen,
|
||||||
clicking `Uitloggen` flips the auth context to unauthenticated so the login form
|
clicking `Uitloggen` flips the auth context to unauthenticated so the login form
|
||||||
(`E-mailadres`) renders and the `Uitloggen` button is gone (token cleared).
|
(`E-mailadres`) renders and the `Uitloggen` button is gone (token cleared).
|
||||||
|
|
||||||
|
## lib/api + app/nav — worker client
|
||||||
|
|
||||||
|
Src covered (worker `apps/worker`): `src/lib/api.ts`, `src/App.tsx`, `src/components/TabBar.tsx`,
|
||||||
|
`src/api/sessions.ts`, `src/api/activities.ts`, `src/api/me.ts` — all 100% stmts/branch/funcs/lines.
|
||||||
|
No `c8 ignore`s needed.
|
||||||
|
|
||||||
|
Guaranteed behaviors:
|
||||||
|
|
||||||
|
- `apiFetch` attaches `Authorization: Bearer <token>` when a token is stored and omits it otherwise.
|
||||||
|
- `apiFetch` sets `Content-Type: application/json` when a request has a body and no explicit
|
||||||
|
Content-Type, and does not override a Content-Type the caller supplied.
|
||||||
|
- `apiFetch` parses a JSON response body, and returns `undefined` when the body is empty.
|
||||||
|
- `apiFetch` throws `ApiError` (carrying the HTTP status) on any non-2xx response.
|
||||||
|
- `signIn` POSTs to `/api/auth/sign-in/email`, stores the token from the `set-auth-token` header,
|
||||||
|
and throws `ApiError` when the response is not ok or the header is missing.
|
||||||
|
- `App` gates on auth state: no token renders `Login` (no tab bar); a token renders the authed shell
|
||||||
|
with the Stopwatch screen mounted at `/` beside the `TabBar`.
|
||||||
|
- `TabBar` renders the three tabs (`Stopwatch` → `/`, `Geschiedenis` → `/history`,
|
||||||
|
`Account` → `/account`) as links, marks the current route's tab `tab-active`, and uses `end`
|
||||||
|
matching so the Stopwatch tab is active only on the root path.
|
||||||
|
- Each `api/*` hook hits the right path/method: `useSessions` → GET `/api/sessions`;
|
||||||
|
`useActiveSessions` → GET `/api/sessions/active`; `useStartSession` → POST `/api/sessions/start`
|
||||||
|
with the serialized input; `useStop/Discard/Pause/ResumeSession` → POST `/api/sessions/:id/<action>`;
|
||||||
|
`useActivities` → GET `/api/activities`; `useMe` → GET `/api/me`. Every mutation invalidates the
|
||||||
|
`['sessions']` query cache on success.
|
||||||
|
- Lifecycle (`src/App.test.tsx`): unauthenticated renders `Login` with no navigation; authenticated
|
||||||
|
renders the Stopwatch screen under the tab bar and no login form.
|
||||||
|
|||||||
Reference in New Issue
Block a user