Files
study-bible-project/src/hooks/useProjectLibrary.test.js
T
nmemmertandClaude Sonnet 5.5 f274bc9c8a
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Harden sync and security; add IQ Bible, templates, password reset, backups
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>
2026-10-06 08:13:36 -04:00

108 lines
4.9 KiB
JavaScript

import { renderHook, act, waitFor } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
const toasts = [];
vi.mock('../components/Toasts.jsx', () => ({ useToast: () => ({ toast: (msg, opts) => toasts.push({ msg, opts }) }) }));
vi.mock('../syncService.js', () => ({
listRemoteProjects: vi.fn(),
loadRemoteProject: vi.fn(),
saveRemoteProject: vi.fn(),
deleteRemoteProject: vi.fn(),
}));
import { useProjectLibrary } from './useProjectLibrary.js';
import * as sync from '../syncService.js';
import { saveProjectToStorage, loadProjectById } from '../utils/projectStorage.js';
import { loadProjectIndex, switchStorageUser } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes, unsyncedStudies } from '../utils/syncQueue.js';
const study = (id, title = 'A study') => ({ id, title, lastEdited: 100, translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [] }] });
const setup = (authUser = { id: 'u1' }) => renderHook(() => useProjectLibrary({
authUser, project: null, setProject: vi.fn(), setProjectIndex: vi.fn(), setCurrentPage: vi.fn(),
}));
beforeEach(() => {
localStorage.clear();
switchStorageUser('u1');
toasts.length = 0;
vi.clearAllMocks();
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
sync.deleteRemoteProject.mockResolvedValue({ ok: true });
sync.saveRemoteProject.mockResolvedValue({ ok: true, data: { lastEdited: 500 } });
});
afterEach(() => { vi.useRealTimers(); switchStorageUser(null); });
test('deleting removes the study here at once and asks the server only after the undo window', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
expect(loadProjectById('s1')).toBeNull();
expect(pendingDeletes.list()).toEqual(['s1']);
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(sync.deleteRemoteProject).toHaveBeenCalledWith('s1');
expect(pendingDeletes.list()).toEqual([]); // the server confirmed
});
test('a delete the server did not confirm stays queued to be retried', async () => {
vi.useFakeTimers();
sync.deleteRemoteProject.mockResolvedValue({ ok: false, status: null, error: 'Network error' });
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(pendingDeletes.list()).toEqual(['s1']);
});
test('undo puts the study back and cancels the server delete', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1', 'Keep me'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
act(() => toasts.at(-1).opts.action.onClick());
expect(loadProjectById('s1').title).toBe('Keep me');
expect(pendingDeletes.list()).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(9000); });
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
});
test('a study deleted on the server but still queued to delete is not pulled back down at sign-in', async () => {
pendingDeletes.add('gone');
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'gone', title: 'Gone', lastEdited: 5 }] });
setup();
await waitFor(() => expect(sync.deleteRemoteProject).toHaveBeenCalledWith('gone'));
expect(sync.loadRemoteProject).not.toHaveBeenCalled();
expect(loadProjectById('gone')).toBeNull();
});
test('a study another device deleted is removed here, with a way to keep it', async () => {
saveProjectToStorage(study('s2', 'Old study'));
knownOnServer.add('s2'); // the server had it when we last looked
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
setup();
await waitFor(() => expect(loadProjectById('s2')).toBeNull());
const note = toasts.find((t) => t.msg.includes('Old study'));
expect(note.msg).toMatch(/deleted on another device/);
act(() => note.opts.action.onClick());
expect(loadProjectById('s2').title).toBe('Old study');
expect(unsyncedStudies.list()).toContain('s2');
});
test('a study never seen on the server (made offline) is sent up, not deleted', async () => {
saveProjectToStorage(study('s3', 'Made offline'));
unsyncedStudies.add('s3');
setup();
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalled());
expect(loadProjectById('s3').title).toBe('Made offline');
await waitFor(() => expect(unsyncedStudies.list()).toEqual([]));
expect(loadProjectIndex()).toHaveLength(1);
});
test('studies only on the server are pulled down', async () => {
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'far', title: 'From elsewhere', lastEdited: 9 }] });
sync.loadRemoteProject.mockResolvedValue({ ok: true, data: study('far', 'From elsewhere') });
setup();
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
});