Extract auth into hooks/useAuth; move form state into its pages
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
- useAuth owns authUser/authServerDown, the startup session check, and signIn/logout, so every sign-in path runs the same follow-up (switch local storage to the account, load reader ink). Previously reader ink only loaded on a page refresh, not right after logging in. - Login/2FA form state now lives in AuthPage, and 2FA setup, password change, and podcast name state in SettingsPage (23 fewer context values; typing in these forms no longer re-renders all of App). - Add tests for form sign-in, 2FA, session restore + logout, and the Settings podcast-name prefill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
009fa1e78f
commit
49ffefed54
5 files changed
+162
-113
No files matched your search
@@ -363,3 +363,77 @@ describe('Greek word lookup', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication (useAuth + AuthPage/SettingsPage local form state)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Authentication', () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
|
||||
// A tiny fake API: `session` decides what /api/auth/me returns; login can
|
||||
// optionally require a 2FA code first.
|
||||
function stubApi({ session = null, mfaRequired = false } = {}) {
|
||||
const fallback = buildFetchMock();
|
||||
const fetchMock = vi.fn((url, opts = {}) => {
|
||||
if (!url.startsWith('/api/')) return fallback(url, opts);
|
||||
const route = `${opts.method ?? 'GET'} ${url}`;
|
||||
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
|
||||
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
|
||||
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
|
||||
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
return json(404, { error: 'not mocked' });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
const calledWith = (fetchMock, method, url) =>
|
||||
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
|
||||
|
||||
async function signInWithForm(u) {
|
||||
await u.type(await screen.findByLabelText(/email/i), user.email);
|
||||
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
|
||||
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
|
||||
}
|
||||
|
||||
test('signing in with the form shows the app and loads reader ink', async () => {
|
||||
const fetchMock = stubApi();
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await signInWithForm(u);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
|
||||
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
|
||||
});
|
||||
|
||||
test('2FA: a correct code after the password signs the user in', async () => {
|
||||
const fetchMock = stubApi({ mfaRequired: true });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await signInWithForm(u);
|
||||
await u.type(await screen.findByPlaceholderText('123456'), '123456');
|
||||
await u.click(screen.getByRole('button', { name: /verify/i }));
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
|
||||
});
|
||||
|
||||
test('an existing session skips the login screen, and Log out returns to it', async () => {
|
||||
const fetchMock = stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||||
await u.click(screen.getByRole('button', { name: /log out/i }));
|
||||
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
|
||||
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
|
||||
});
|
||||
|
||||
test('Settings pre-fills the podcast name from the signed-in account', async () => {
|
||||
stubApi({ session: user });
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await u.click(await screen.findByRole('button', { name: /settings/i }));
|
||||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user