Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety - Autosave flushes on leaving a study or the tab; storage-full errors are reported - Unsynced studies, deletes and ink are queued and retried when back online - Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect - Server assigns study versions (no client-clock conflicts); "Combine both" merges a conflicted study chunk by chunk - Reading-plan progress made offline is merged, not overwritten; plans, ink and marks are cleared from the device on sign-out Security - Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and account; constant-time login; TOTP replay protection; input validation; SESSION_SECRET required in production; admin user delete fixed - Forgot password: one-time code sent to the account's ntfy topic Features - IQ Bible proxy (route allow-list, text-only caching per its terms): more commentaries, lexicons, study aids, daily verse and devotional, plan calendar export, narrations, podcast background - Study templates, reading-plan catch-up, account export, daily database snapshots Tests and tooling - ESLint config; App tests split into src/test; tests for sync queues, library hook, merge, templates, IQ Bible UI and reset form Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -0,0 +1,225 @@
|
||||
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, loadChapter, addChunk, findChunkCounter } from './appHarness.jsx';
|
||||
|
||||
installAppLifecycle();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every page has a way back to the dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Back to the dashboard', () => {
|
||||
const home = /back to home|bible study home/i;
|
||||
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
|
||||
test.each([
|
||||
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
|
||||
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
|
||||
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
|
||||
])('%s', async (_name, open) => {
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await open(u);
|
||||
// each page is lazy-loaded, so its app bar may take a moment to replace the loading skeleton
|
||||
await u.click(await screen.findByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
|
||||
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
|
||||
await screen.findByRole('navigation', { name: /study stages/i });
|
||||
await u.click(screen.getByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
|
||||
test('the Pronunciation guide', async () => {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'Paul, a servant');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /^script$/i }));
|
||||
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
|
||||
await screen.findByRole('heading', { name: /pronunciation guide/i });
|
||||
await u.click(screen.getByRole('button', { name: home }));
|
||||
await dashboard();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The home page the user chooses
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('Chosen home page', () => {
|
||||
const homeBtn = /back to home|bible study home/i;
|
||||
|
||||
test('the app opens on the chosen page', async () => {
|
||||
localStorage.setItem('start-page', 'reader');
|
||||
render(<App />);
|
||||
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('by default the app opens on My Studies', async () => {
|
||||
await renderApp();
|
||||
expect(screen.getByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
|
||||
localStorage.setItem('start-page', 'reader');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByText(/Genesis 1 · BSB/);
|
||||
// already on the chosen page: Home means My Studies
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
// from another page, Home is the chosen page
|
||||
await u.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
|
||||
localStorage.setItem('start-page', 'plans');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
|
||||
await screen.findByText('My Studies');
|
||||
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
await screen.findByRole('heading', { name: /import session list/i });
|
||||
await u.click(screen.getByRole('button', { name: /bible study home/i }));
|
||||
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
|
||||
await u.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
await u.click(screen.getByRole('button', { name: /^studies$/i }));
|
||||
expect(await screen.findByText('My Studies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('leaving a study with a different home page closes the study', async () => {
|
||||
localStorage.setItem('start-page', 'plans');
|
||||
const u = userEvent.setup();
|
||||
render(<App />);
|
||||
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
|
||||
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
|
||||
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||
await screen.findByText(/Scripture & Chunks/i);
|
||||
await u.click(screen.getByRole('button', { name: homeBtn }));
|
||||
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Settings changes it, and it is saved for next time', async () => {
|
||||
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
|
||||
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
|
||||
const fallback = buildFetchMock();
|
||||
vi.stubGlobal('fetch', 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, user);
|
||||
if (route === 'GET /api/projects') return json(200, []);
|
||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
|
||||
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
|
||||
return json(404, { error: 'not mocked' });
|
||||
}));
|
||||
const u = userEvent.setup();
|
||||
await renderApp();
|
||||
await u.click(await screen.findByRole('button', { name: /account menu/i }));
|
||||
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
|
||||
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
|
||||
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
|
||||
expect(localStorage.getItem('start-page')).toBe('plans');
|
||||
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
|
||||
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resizable study column', () => {
|
||||
async function openStudy() {
|
||||
const u = await loadChapter();
|
||||
addChunk('Paul, a servant', 'In hope of eternal');
|
||||
await screen.findByText('1 created');
|
||||
await u.click(screen.getByRole('button', { name: /begin studying/i }));
|
||||
await findChunkCounter(1, 1);
|
||||
return u;
|
||||
}
|
||||
const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement;
|
||||
|
||||
test('the handle widens and narrows the right column and the choice is remembered', async () => {
|
||||
const u = await openStudy();
|
||||
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||
expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width
|
||||
handle.focus();
|
||||
await u.keyboard('{ArrowLeft}');
|
||||
expect(columns().style.getPropertyValue('--study-right')).toBe('472px');
|
||||
expect(localStorage.getItem('study-right-width')).toBe('472');
|
||||
await u.keyboard('{Shift>}{ArrowRight}{/Shift}');
|
||||
expect(columns().style.getPropertyValue('--study-right')).toBe('376px');
|
||||
});
|
||||
|
||||
test('double-clicking the handle restores the default width', async () => {
|
||||
const u = await openStudy();
|
||||
const handle = screen.getByRole('separator', { name: /resize side panel/i });
|
||||
handle.focus();
|
||||
await u.keyboard('{ArrowLeft}');
|
||||
await u.dblClick(handle);
|
||||
expect(localStorage.getItem('study-right-width')).toBeNull();
|
||||
expect(columns().style.getPropertyValue('--study-right')).toBe('');
|
||||
});
|
||||
|
||||
test('a saved width is applied when the study opens', async () => {
|
||||
localStorage.setItem('study-right-width', '560');
|
||||
await openStudy();
|
||||
expect(columns().style.getPropertyValue('--study-right')).toBe('560px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Import template', () => {
|
||||
test('the Import page offers a Word template to download', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(screen.getByRole('button', { name: /import session list/i }));
|
||||
await user.click(await screen.findByRole('button', { name: /download template/i }));
|
||||
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled());
|
||||
const blob = URL.createObjectURL.mock.calls.at(-1)[0];
|
||||
expect(blob.type).toMatch(/wordprocessingml/);
|
||||
// and the button comes back to normal afterwards
|
||||
expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('System chrome follows the theme', () => {
|
||||
test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => {
|
||||
localStorage.setItem('reader-theme', 'dark');
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content');
|
||||
expect(themeColor()).toBe('#262421');
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
await user.click(screen.getByRole('button', { name: /account menu/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^paper$/i }));
|
||||
expect(themeColor()).toBe('#f1ece0');
|
||||
expect(document.documentElement.style.colorScheme).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('New pages start at the top', () => {
|
||||
test('moving to another page resets the scroll position', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
const scroller = document.scrollingElement ?? document.documentElement;
|
||||
scroller.scrollTop = 480; // scrolled down on the dashboard
|
||||
await user.click(screen.getByRole('button', { name: /reading plans/i }));
|
||||
await screen.findByRole('heading', { name: /^reading plans$/i });
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
scroller.scrollTop = 300;
|
||||
await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
|
||||
await screen.findByText('My Studies');
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user