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.