From 36d4754722ec68eb0b276e40d570128c58d0d520 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Thu, 23 Jul 2026 13:50:33 +0200 Subject: [PATCH] =?UTF-8?q?test(worker):=20Auth=20(context/storage/login)?= =?UTF-8?q?=20=E2=80=94=20fill=20gaps=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the worker Auth domain to 100% coverage: - AuthContext.tsx: 91.3/80 -> 100/100 (signIn body, mount-restore branches, useAuth guard) - Login.tsx: 78/50 -> 100/100 (handleSubmit success/error/pending, submit disable) - auth-storage.ts: already 100 (no change) New Login.test.tsx adds the domain lifecycle test: from the unauthenticated app, a successful login stores the token and flips the context to the authenticated tabbed shell. Extended AuthContext.test.tsx covers signIn delegation, mount restore (token present/absent), and the out-of-provider throw. No bugs found; code was already correct. Note: the design checklist's 'clears on 401' worker behavior is not implemented (no global 401 interceptor) and is left as-is (missing feature, not a defect). --- apps/worker/src/auth/AuthContext.test.tsx | 76 +++++++++++++- apps/worker/src/screens/Login.test.tsx | 120 ++++++++++++++++++++++ docs/reference/domain-test-checklist.md | 28 +++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 apps/worker/src/screens/Login.test.tsx diff --git a/apps/worker/src/auth/AuthContext.test.tsx b/apps/worker/src/auth/AuthContext.test.tsx index 5ac42f5..c0b246b 100644 --- a/apps/worker/src/auth/AuthContext.test.tsx +++ b/apps/worker/src/auth/AuthContext.test.tsx @@ -1,7 +1,8 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthProvider, useAuth } from './AuthContext'; +import { signIn as apiSignIn } from '../lib/api'; import { clearToken, setToken } from '../lib/auth-storage'; // The sign-in network call is irrelevant here; stub it so nothing hits the network. @@ -9,6 +10,8 @@ vi.mock('../lib/api', () => ({ signIn: vi.fn(), })); +const mockedSignIn = vi.mocked(apiSignIn); + function SignOutButton() { const { signOut } = useAuth(); return ( @@ -18,6 +21,18 @@ function SignOutButton() { ); } +function AuthProbe() { + const { isAuthed, signIn } = useAuth(); + return ( + <> + {String(isAuthed)} + + + ); +} + describe('AuthContext signOut', () => { beforeEach(() => { clearToken(); @@ -47,3 +62,62 @@ describe('AuthContext signOut', () => { expect(window.location.pathname).toBe('/'); }); }); + +describe('AuthContext signIn + mount', () => { + beforeEach(() => { + clearToken(); + mockedSignIn.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('starts unauthenticated when no token is stored', () => { + render( + + + , + ); + expect(screen.getByTestId('authed')).toHaveTextContent('false'); + }); + + it('restores the authenticated state from a stored token on mount', () => { + setToken('tok'); + render( + + + , + ); + expect(screen.getByTestId('authed')).toHaveTextContent('true'); + }); + + it('signIn delegates to the api and flips to authenticated', async () => { + mockedSignIn.mockResolvedValue(undefined); + const user = userEvent.setup(); + render( + + + , + ); + expect(screen.getByTestId('authed')).toHaveTextContent('false'); + + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + await waitFor(() => expect(screen.getByTestId('authed')).toHaveTextContent('true')); + expect(mockedSignIn).toHaveBeenCalledWith('a@b.c', 'pw'); + }); +}); + +describe('useAuth guard', () => { + it('throws when used outside an AuthProvider', () => { + function Bare() { + useAuth(); + return null; + } + // Silence React's expected error boundary logging for this render. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => render()).toThrow('useAuth must be used within an AuthProvider'); + spy.mockRestore(); + }); +}); diff --git a/apps/worker/src/screens/Login.test.tsx b/apps/worker/src/screens/Login.test.tsx new file mode 100644 index 0000000..6229df6 --- /dev/null +++ b/apps/worker/src/screens/Login.test.tsx @@ -0,0 +1,120 @@ +import { render, screen, waitFor, within } 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 Login from './Login'; +import App from '../App'; +import { AuthProvider } from '../auth/AuthContext'; +import { clearToken, getToken } from '../lib/auth-storage'; + +// Keep the real signIn/apiFetch flow (drive it through a stubbed global fetch); only silence the +// authed shell's data fetches so the lifecycle test's screens render without real requests. +vi.mock('../lib/api', async () => { + const actual = await vi.importActual('../lib/api'); + return { ...actual, apiFetch: vi.fn().mockResolvedValue([]) }; +}); + +// A 200 response carrying the bearer token the way better-auth returns it. +function tokenResponse() { + return new Response(null, { status: 200, headers: { 'set-auth-token': 'tok' } }); +} + +function renderLogin() { + return render( + + + , + ); +} + +describe('Login', () => { + beforeEach(() => { + clearToken(); + window.history.replaceState(null, '', '/'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearToken(); + }); + + it('renders the Dutch login form', () => { + renderLogin(); + expect(screen.getByText('E-mailadres')).toBeInTheDocument(); + expect(screen.getByText('Wachtwoord')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Inloggen' })).toBeInTheDocument(); + }); + + it('submits the typed credentials to the sign-in endpoint and stores the token', async () => { + const fetchMock = vi.fn().mockResolvedValue(tokenResponse()); + vi.stubGlobal('fetch', fetchMock); + const user = userEvent.setup(); + renderLogin(); + + await user.type(screen.getByLabelText('E-mailadres'), 'worker@example.com'); + await user.type(screen.getByLabelText('Wachtwoord'), 'geheim123'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/api/auth/sign-in/email'); + expect(JSON.parse(init.body as string)).toEqual({ + email: 'worker@example.com', + password: 'geheim123', + }); + expect(getToken()).toBe('tok'); + }); + + it('shows an error message and stores no token when sign-in fails', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + const user = userEvent.setup(); + renderLogin(); + + await user.type(screen.getByLabelText('E-mailadres'), 'x@y.z'); + await user.type(screen.getByLabelText('Wachtwoord'), 'wrong'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + expect(await screen.findByText('Inloggen mislukt')).toBeInTheDocument(); + expect(getToken()).toBeNull(); + }); + + it('disables the submit button while the request is pending', async () => { + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = () => resolve(tokenResponse()); + }); + vi.stubGlobal('fetch', vi.fn().mockReturnValue(pending)); + const user = userEvent.setup(); + renderLogin(); + + const button = screen.getByRole('button', { name: 'Inloggen' }); + await user.type(screen.getByLabelText('E-mailadres'), 'a@b.c'); + await user.type(screen.getByLabelText('Wachtwoord'), 'pw'); + await user.click(button); + + await waitFor(() => expect(button).toBeDisabled()); + release?.(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + it('lifecycle: a successful login flips the app to the authenticated shell', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(tokenResponse())); + const user = userEvent.setup(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + + // Unauthenticated: the login form is shown first. + await user.type(screen.getByLabelText('E-mailadres'), 'worker@example.com'); + await user.type(screen.getByLabelText('Wachtwoord'), 'geheim123'); + await user.click(screen.getByRole('button', { name: 'Inloggen' })); + + // Context flips to authenticated → the tabbed shell renders. + const nav = await screen.findByRole('navigation'); + expect(within(nav).getByText('Stopwatch')).toBeInTheDocument(); + expect(getToken()).toBe('tok'); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 1b75e52..8fa1e55 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -255,3 +255,31 @@ Guaranteed behaviors: and the bearer authorizes `/api/me` → admin deactivates → the session rows are deleted so the pre-issued token no longer authorizes `/api/me` (401) **and** a fresh sign-in is blocked → admin reactivates → the ban is cleared and sign-in works again. + +## Auth (context/storage/login) — worker client + +Src covered (worker `apps/worker`): `src/auth/AuthContext.tsx`, `src/lib/auth-storage.ts`, and +`src/screens/Login.tsx` — all at 100% stmts/branch/funcs/lines. No `c8 ignore`s needed. + +Guaranteed behaviors: + +- `auth-storage`: `setToken` persists the bearer, `getToken` reads it back, `clearToken` removes it + (`getToken` returns `null` when nothing is stored) — all via `localStorage`. +- `AuthContext` starts **unauthenticated** when no token is stored and **restores** the + authenticated state on mount when a token is already present (`getToken() !== null`). +- `AuthContext.signIn` delegates to the api `signIn(email, password)` and, on success, flips the + context to authenticated (`isAuthed` → true). +- `AuthContext.signOut` resets the router path to `/` (so the next authed mount lands on Stopwatch, + not Account), clears the stored token, and flips the context to unauthenticated. +- `useAuth` throws `"useAuth must be used within an AuthProvider"` when called outside the provider. +- `Login` renders the Dutch form (E-mailadres / Wachtwoord / Inloggen) and submits the typed + credentials to `POST /api/auth/sign-in/email`, storing the returned bearer token. +- `Login` shows the `"Inloggen mislukt"` error (and stores no token) when sign-in fails, and + disables the submit button while the request is pending, re-enabling it once it settles. +- Lifecycle (`src/screens/Login.test.tsx`): from the unauthenticated app, a successful login stores + the token and flips the context so the tabbed authenticated shell (Stopwatch + TabBar) renders. + +> Note: the design checklist mentions "clears on 401" for the worker auth context — this is **not +> 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); +> not tested and not filed as a bug.