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')); });