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:
@@ -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 (
|
||||
<>
|
||||
<span data-testid="authed">{String(isAuthed)}</span>
|
||||
<button type="button" onClick={() => void signIn('a@b.c', 'pw')}>
|
||||
Inloggen
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
120
apps/worker/src/screens/Login.test.tsx
Normal file
120
apps/worker/src/screens/Login.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user