diff --git a/apps/worker/src/App.test.tsx b/apps/worker/src/App.test.tsx index 3cbcae5..fd8bcd1 100644 --- a/apps/worker/src/App.test.tsx +++ b/apps/worker/src/App.test.tsx @@ -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(); + }); }); diff --git a/apps/worker/src/api/activities.test.tsx b/apps/worker/src/api/activities.test.tsx new file mode 100644 index 0000000..f0e6c29 --- /dev/null +++ b/apps/worker/src/api/activities.test.tsx @@ -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 }) => ( + + {children} + +); + +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'); + }); +}); diff --git a/apps/worker/src/api/me.test.tsx b/apps/worker/src/api/me.test.tsx new file mode 100644 index 0000000..c358028 --- /dev/null +++ b/apps/worker/src/api/me.test.tsx @@ -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 }) => ( + + {children} + +); + +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'); + }); +}); diff --git a/apps/worker/src/api/sessions.test.tsx b/apps/worker/src/api/sessions.test.tsx new file mode 100644 index 0000000..0ec1997 --- /dev/null +++ b/apps/worker/src/api/sessions.test.tsx @@ -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 }) => ( + {children} +); + +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, 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'] }); + }, + ); +}); diff --git a/apps/worker/src/components/TabBar.test.tsx b/apps/worker/src/components/TabBar.test.tsx new file mode 100644 index 0000000..03b2ac0 --- /dev/null +++ b/apps/worker/src/components/TabBar.test.tsx @@ -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( + + + , + ); +} + +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'); + }); +}); diff --git a/apps/worker/src/lib/api.test.ts b/apps/worker/src/lib/api.test.ts index 8e8bb39..3a455fb 100644 --- a/apps/worker/src/lib/api.test.ts +++ b/apps/worker/src/lib/api.test.ts @@ -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', diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 9a997d6..c59eef5 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -327,3 +327,31 @@ Guaranteed behaviors: - 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 (`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 ` 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/`; + `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.