Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names, My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates - Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
218 lines
11 KiB
React
218 lines
11 KiB
React
import { render, screen, waitFor, within } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import App from '../App';
|
||
import { vi, describe, test, expect } from 'vitest';
|
||
import { installAppLifecycle, buildFetchMock, renderApp, openAccountMenu } from './appHarness.jsx';
|
||
|
||
installAppLifecycle();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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);
|
||
await openAccountMenu(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 }));
|
||
await openAccountMenu(u);
|
||
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 />);
|
||
await openAccountMenu(u);
|
||
expect(await screen.findByText(user.email)).toBeInTheDocument();
|
||
await u.click(screen.getByRole('menuitem', { 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 openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
|
||
});
|
||
|
||
test('every reading and display preference can be set from Settings', async () => {
|
||
stubApi({ session: user });
|
||
const u = userEvent.setup();
|
||
render(<App />);
|
||
await openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
|
||
for (const name of ['Text size', 'Typeface', 'Line spacing', 'Verse numbers', 'Reading width', 'Verse layout', 'Theme', 'Home page']) {
|
||
expect(screen.getByRole('group', { name })).toBeInTheDocument();
|
||
}
|
||
await u.click(within(screen.getByRole('group', { name: 'Typeface' })).getByRole('button', { name: 'Sans' }));
|
||
expect(localStorage.getItem('reader-font')).toBe('sans');
|
||
await u.click(within(screen.getByRole('group', { name: 'Verse numbers' })).getByRole('button', { name: 'Hide' }));
|
||
expect(localStorage.getItem('reader-verse-numbers')).toBe('off');
|
||
// highlight colour names
|
||
await u.type(screen.getByLabelText('Name for yellow highlights'), 'Promise');
|
||
expect(JSON.parse(localStorage.getItem('reader-color-labels'))['#fef08a']).toBe('Promise');
|
||
// the My Studies page
|
||
await u.click(within(screen.getByRole('group', { name: 'Show studies as' })).getByRole('button', { name: 'List' }));
|
||
expect(localStorage.getItem('homeView')).toBe('list');
|
||
await u.selectOptions(screen.getByLabelText('Sort studies by'), 'title');
|
||
expect(localStorage.getItem('homeSort')).toBe('title');
|
||
expect(screen.getByRole('heading', { name: 'Study templates' })).toBeInTheDocument();
|
||
});
|
||
|
||
test('Settings is split into tabs, one group at a time', async () => {
|
||
stubApi({ session: user });
|
||
const u = userEvent.setup();
|
||
render(<App />);
|
||
await openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||
const tabs = await screen.findAllByRole('tab');
|
||
expect(tabs.map((t) => t.textContent)).toEqual(['Account', 'Preferences', 'Notifications', 'Security', 'Your data']);
|
||
expect(screen.queryByRole('heading', { name: 'Two-factor authentication' })).not.toBeInTheDocument();
|
||
await u.click(screen.getByRole('tab', { name: 'Security' }));
|
||
expect(screen.getByRole('tab', { name: 'Security' })).toHaveAttribute('aria-selected', 'true');
|
||
expect(await screen.findByRole('heading', { name: 'Two-factor authentication' })).toBeInTheDocument();
|
||
expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeInTheDocument();
|
||
expect(screen.getByRole('heading', { name: 'Where you’re signed in' })).toBeInTheDocument();
|
||
expect(screen.queryByDisplayValue('My Show')).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Admin page (state is local to AdminPage)
|
||
// ---------------------------------------------------------------------------
|
||
describe('Admin page', () => {
|
||
const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
|
||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||
|
||
function stubAdminApi(sessionUser) {
|
||
let users = [
|
||
{ id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
|
||
{ id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
|
||
];
|
||
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 json(200, sessionUser);
|
||
if (route === 'GET /api/admin/users') return json(200, users);
|
||
if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
|
||
if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); 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;
|
||
}
|
||
|
||
test('an admin can open the page and see users and projects', async () => {
|
||
stubAdminApi(admin);
|
||
const u = userEvent.setup();
|
||
render(<App />);
|
||
await openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
|
||
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
|
||
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
|
||
});
|
||
|
||
test('deleting a user removes them after the list reloads', async () => {
|
||
const fetchMock = stubAdminApi(admin);
|
||
window.confirm.mockReturnValue(true);
|
||
const u = userEvent.setup();
|
||
render(<App />);
|
||
await openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||
const row = (await screen.findByText('student@example.com')).closest('tr');
|
||
await u.click(within(row).getByRole('button', { name: /delete/i }));
|
||
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
|
||
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
|
||
});
|
||
|
||
test('the Admin page leads back to the dashboard', async () => {
|
||
stubAdminApi(admin);
|
||
const u = userEvent.setup();
|
||
render(<App />);
|
||
await openAccountMenu(u);
|
||
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
|
||
await screen.findByText('student@example.com');
|
||
await u.click(screen.getByRole('button', { name: /^back$/i }));
|
||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||
});
|
||
|
||
test('non-admins get no Admin button', async () => {
|
||
stubAdminApi({ ...admin, isAdmin: false });
|
||
render(<App />);
|
||
await openAccountMenu();
|
||
expect(await screen.findByText(admin.email)).toBeInTheDocument();
|
||
expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
|
||
expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Import page (state is local to ImportPage)
|
||
// ---------------------------------------------------------------------------
|
||
describe('Import page', () => {
|
||
test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
|
||
const user = userEvent.setup();
|
||
await renderApp();
|
||
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
||
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
|
||
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: /^back$/i }));
|
||
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
||
});
|
||
});
|
||
|