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,107 @@
|
||||
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'));
|
||||
});
|
||||
Reference in new issue
Block a user