// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {act, cleanup, renderHook} from '@testing-library/react'; import useLibrarySync from './useLibrarySync.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt}); beforeEach(() => { vi.useFakeTimers(); localStorage.setItem('tbp_session', '1'); }); afterEach(() => { cleanup(); vi.useRealTimers(); localStorage.clear(); }); describe('useLibrarySync', () => { it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => { const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)}); const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}})); await act(() => vi.advanceTimersByTimeAsync(0)); expect(result.current.isRunning).toBe(true); await act(() => vi.advanceTimersByTimeAsync(125_000)); expect(result.current.isRunning).toBe(false); expect(result.current.stale).toBe(true); const calls = fetch.mock.calls.length; await act(() => vi.advanceTimersByTimeAsync(30_000)); expect(fetch.mock.calls.length).toBe(calls); }); it('after watch() reports the end of a sync once, also when it never saw it running', async () => { let answer = status('OK', '2026-09-30T08:00:00Z'); fetchRoutes({'GET /api/library/sync-status': () => answer}); const onComplete = vi.fn(); const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete})); await act(() => vi.advanceTimersByTimeAsync(0)); act(() => result.current.watch()); await act(() => vi.advanceTimersByTimeAsync(0)); // The sync was so fast that the next poll already sees it done. answer = status('OK', '2026-09-30T08:05:00Z'); await act(() => vi.advanceTimersByTimeAsync(3_000)); await act(() => vi.advanceTimersByTimeAsync(30_000)); expect(onComplete).toHaveBeenCalledTimes(1); }); it('reports the end of a running sync once', async () => { let answer = status('RUNNING', '2026-09-30T08:00:00Z'); fetchRoutes({'GET /api/library/sync-status': () => answer}); const onComplete = vi.fn(); const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete})); await act(() => vi.advanceTimersByTimeAsync(0)); answer = status('FAILED', '2026-09-30T08:01:00Z'); await act(() => vi.advanceTimersByTimeAsync(3_000)); await act(() => vi.advanceTimersByTimeAsync(30_000)); expect(onComplete).toHaveBeenCalledTimes(1); expect(result.current.status).toBe('FAILED'); }); it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => { let answer = status('OK', '2026-09-30T08:00:00Z'); fetchRoutes({'GET /api/library/sync-status': () => answer}); const onComplete = vi.fn(); const {result, rerender} = renderHook( ({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}), {initialProps: {expectInitialSync: false}}); await act(() => vi.advanceTimersByTimeAsync(0)); act(() => result.current.watch()); await act(() => vi.advanceTimersByTimeAsync(0)); answer = status('OK', '2026-09-30T08:05:00Z'); await act(() => vi.advanceTimersByTimeAsync(3_000)); expect(onComplete).toHaveBeenCalledTimes(1); // onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows. rerender({expectInitialSync: true}); await act(() => vi.advanceTimersByTimeAsync(0)); rerender({expectInitialSync: false}); await act(() => vi.advanceTimersByTimeAsync(0)); expect(onComplete).toHaveBeenCalledTimes(1); }); });