Files
solelog/apps/worker/src/screens/Login.test.tsx
Bas van Rossem 36d4754722 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).
2026-07-23 13:50:33 +02:00

121 lines
4.5 KiB
TypeScript

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');
});
});