test(worker): Auth (context/storage/login) — fill gaps + lifecycle test

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).
This commit is contained in:
Bas van Rossem
2026-07-23 13:50:33 +02:00
parent 9c99d56f99
commit 36d4754722
3 changed files with 223 additions and 1 deletions

View File

@@ -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 userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthProvider, useAuth } from './AuthContext'; import { AuthProvider, useAuth } from './AuthContext';
import { signIn as apiSignIn } from '../lib/api';
import { clearToken, setToken } from '../lib/auth-storage'; import { clearToken, setToken } from '../lib/auth-storage';
// The sign-in network call is irrelevant here; stub it so nothing hits the network. // 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(), signIn: vi.fn(),
})); }));
const mockedSignIn = vi.mocked(apiSignIn);
function SignOutButton() { function SignOutButton() {
const { signOut } = useAuth(); const { signOut } = useAuth();
return ( return (
@@ -18,6 +21,18 @@ function SignOutButton() {
); );
} }
function AuthProbe() {
const { isAuthed, signIn } = useAuth();
return (
<>
<span data-testid="authed">{String(isAuthed)}</span>
<button type="button" onClick={() => void signIn('a@b.c', 'pw')}>
Inloggen
</button>
</>
);
}
describe('AuthContext signOut', () => { describe('AuthContext signOut', () => {
beforeEach(() => { beforeEach(() => {
clearToken(); clearToken();
@@ -47,3 +62,62 @@ describe('AuthContext signOut', () => {
expect(window.location.pathname).toBe('/'); 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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
expect(screen.getByTestId('authed')).toHaveTextContent('false');
});
it('restores the authenticated state from a stored token on mount', () => {
setToken('tok');
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
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(
<AuthProvider>
<AuthProbe />
</AuthProvider>,
);
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(<Bare />)).toThrow('useAuth must be used within an AuthProvider');
spy.mockRestore();
});
});

View File

@@ -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<typeof import('../lib/api')>('../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(
<AuthProvider>
<Login />
</AuthProvider>,
);
}
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<Response>((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(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
// 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');
});
});

View File

@@ -255,3 +255,31 @@ Guaranteed behaviors:
and the bearer authorizes `/api/me` → admin deactivates → the session rows are deleted so the 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 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. 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.