// FE-COMP-OFFLINETAB-001 to FE-COMP-OFFLINETAB-024 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { act, render, screen, waitFor, within } from '../../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildTrip, buildUser } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { _resetNetworkMode } from '../../sync/networkMode'; import { _resetOfflinePrefs, getOfflinePrefs } from '../../sync/offlinePrefs'; import type { QueuedMutation, SyncMeta } from '../../db/offlineDb'; import type { PrepareProgress } from '../../sync/tripSyncManager'; import type { Trip } from '../../types'; import OfflineTab from './OfflineTab'; // Dexie, the sync manager, the mutation queue and the tile cache are the four // side-effecting neighbours of this tab; network mode, offline prefs and the // trips API run for real. const h = vi.hoisted(() => { const syncMetaToArray = vi.fn(); const tripsGet = vi.fn(); const tripsToArray = vi.fn(); const placeCount = vi.fn(); const fileCount = vi.fn(); const counted = (fn: (id: number) => Promise) => ({ where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }), }); return { syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount, fakeDb: { syncMeta: { toArray: syncMetaToArray }, trips: { get: tripsGet, toArray: tripsToArray }, places: counted(placeCount), tripFiles: counted(fileCount), }, clearAll: vi.fn(), clearTripData: vi.fn(), prepareForOffline: vi.fn(), syncAll: vi.fn(), pendingCount: vi.fn(), failedCount: vi.fn(), conflicts: vi.fn(), resolveKeepMine: vi.fn(), resolveKeepServer: vi.fn(), clearTileCache: vi.fn(), }; }); vi.mock('../../db/offlineDb', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, offlineDb: h.fakeDb, clearAll: h.clearAll, clearTripData: h.clearTripData }; }); vi.mock('../../sync/tripSyncManager', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, tripSyncManager: { prepareForOffline: h.prepareForOffline, syncAll: h.syncAll } }; }); vi.mock('../../sync/mutationQueue', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mutationQueue: { pendingCount: h.pendingCount, failedCount: h.failedCount, conflicts: h.conflicts, resolveKeepMine: h.resolveKeepMine, resolveKeepServer: h.resolveKeepServer, }, }; }); vi.mock('../../sync/tilePrefetcher', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, clearTileCache: h.clearTileCache }; }); const paris = buildTrip({ id: 1, title: 'Paris', start_date: '2025-06-01', end_date: '2025-06-05' }); const tokyo = buildTrip({ id: 2, title: 'Tokyo', start_date: '2025-09-01', end_date: '2025-09-15' }); const meta = (tripId: number, over: Partial = {}): SyncMeta => ({ tripId, lastSyncedAt: null, status: 'idle', tilesBbox: null, filesCachedCount: 0, ...over, }); const conflict = (over: Partial = {}): QueuedMutation => ({ id: 'c1', tripId: 1, method: 'PUT', url: '/trips/1/places/9', body: { name: 'Louvre' }, createdAt: 0, status: 'conflict', attempts: 1, lastError: null, ...over, }); /** Seed the Dexie cache with the given (trip, meta) pairs. */ function cache(rows: { trip: Trip; meta?: Partial; places?: number; files?: number }[]): void { h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta))); h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip); h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0); h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0); } function setOnLine(value: boolean): void { Object.defineProperty(window.navigator, 'onLine', { value, configurable: true }); } /** The Section wrapper card that carries the given heading. */ const card = (title: string) => screen.getByText(title).closest('div.rounded-xl') as HTMLElement; /** The Stat tile that carries the given label. */ const stat = (label: string) => screen.getByText(label).parentElement as HTMLElement; beforeEach(() => { vi.clearAllMocks(); resetAllStores(); _resetNetworkMode(); _resetOfflinePrefs(); setOnLine(true); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); h.syncMetaToArray.mockResolvedValue([]); h.tripsGet.mockResolvedValue(undefined); h.tripsToArray.mockResolvedValue([]); h.placeCount.mockResolvedValue(0); h.fileCount.mockResolvedValue(0); h.pendingCount.mockResolvedValue(0); h.failedCount.mockResolvedValue(0); h.conflicts.mockResolvedValue([]); h.clearAll.mockResolvedValue(undefined); h.clearTripData.mockResolvedValue(undefined); h.clearTileCache.mockResolvedValue(undefined); h.prepareForOffline.mockResolvedValue(undefined); h.syncAll.mockResolvedValue(undefined); h.resolveKeepMine.mockResolvedValue(undefined); h.resolveKeepServer.mockResolvedValue(undefined); server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [paris, tokyo] }))); }); afterEach(() => { setOnLine(true); vi.restoreAllMocks(); }); describe('OfflineTab', () => { it('FE-COMP-OFFLINETAB-001: shows the loading line while the cache is being read', () => { h.syncMetaToArray.mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText('Loading…')).toBeInTheDocument(); expect(screen.getByText('Offline mode')).toBeInTheDocument(); expect(screen.queryByText('No trips cached yet. Connect to the internet to sync.')).not.toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-002: an empty cache reports zero stats and disables Clear cache', async () => { render(); expect(await screen.findByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument(); expect(within(stat('Cached trips')).getByText('0')).toBeInTheDocument(); expect(within(stat('Pending changes')).getByText('0')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Clear cache' })).toBeDisabled(); expect(screen.queryByText('Failed changes')).not.toBeInTheDocument(); expect(screen.queryByText('Conflicts')).not.toBeInTheDocument(); expect(screen.queryByText('Sync conflicts')).not.toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-003: cached trips are listed oldest first with their place and file counts', async () => { cache([ { trip: tokyo, places: 3, files: 1 }, { trip: paris, places: 7, files: 2 }, ]); render(); const cacheCard = card('Offline cache'); await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); const titles = within(cacheCard).getAllByText(/^(Paris|Tokyo)$/).map(el => el.textContent); expect(titles).toEqual(['Paris', 'Tokyo']); expect(within(cacheCard).getByText(/Jun 1, 2025 – Jun 5, 2025/)).toHaveTextContent('7'); expect(within(cacheCard).getByText(/Sep 1, 2025 – Sep 15, 2025/)).toHaveTextContent('3'); expect(within(stat('Cached trips')).getByText('2')).toBeInTheDocument(); // Never synced → the time column shows a dash. expect(within(cacheCard).getAllByText('—')).toHaveLength(2); }); it('FE-COMP-OFFLINETAB-004: a trip without dates renders dashes instead of a range', async () => { cache([{ trip: buildTrip({ id: 3, title: 'Someday', start_date: null, end_date: null }) }]); render(); const cacheCard = card('Offline cache'); await waitFor(() => expect(within(cacheCard).getByText('Someday')).toBeInTheDocument()); expect(within(cacheCard).getByText(/— – —/)).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-005: a sync-meta row without its trip in Dexie is dropped', async () => { h.syncMetaToArray.mockResolvedValue([meta(1), meta(99)]); h.tripsGet.mockImplementation(async (id: number) => (id === 1 ? paris : undefined)); render(); const cacheCard = card('Offline cache'); await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); expect(within(stat('Cached trips')).getByText('1')).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-006: a synced trip shows its last sync time instead of a dash', async () => { const at = new Date('2025-06-02T09:30:00Z').getTime(); cache([{ trip: paris, meta: { lastSyncedAt: at } }]); render(); const cacheCard = card('Offline cache'); await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); const expected = new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); expect(within(cacheCard).getByText(expected)).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-007: pending, failed and conflict counters only appear when they are non-zero', async () => { h.pendingCount.mockResolvedValue(4); h.failedCount.mockResolvedValue(2); h.conflicts.mockResolvedValue([conflict()]); render(); await waitFor(() => expect(screen.getByText('Failed changes')).toBeInTheDocument()); expect(within(stat('Pending changes')).getByText('4')).toBeInTheDocument(); expect(within(stat('Failed changes')).getByText('2')).toBeInTheDocument(); expect(within(stat('Conflicts')).getByText('1')).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-008: conflicts are named from the queued body, the server copy or the entity id', async () => { h.conflicts.mockResolvedValue([ conflict({ id: 'a', body: { name: 'Louvre' } }), conflict({ id: 'b', body: {}, conflictServer: { name: 'Server name' } }), conflict({ id: 'c', body: {}, entityId: 42 }), ]); render(); await screen.findByText('Sync conflicts'); expect(screen.getByText(/Louvre/)).toBeInTheDocument(); expect(screen.getByText(/Server name/)).toBeInTheDocument(); expect(screen.getByText(/#42/)).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-009: keep-mine and keep-theirs resolve the right conflict and reload', async () => { const user = userEvent.setup(); h.conflicts.mockResolvedValue([conflict({ id: 'a' }), conflict({ id: 'b', body: { name: 'Eiffel' } })]); render(); const row = (await screen.findByText(/Eiffel/)).parentElement as HTMLElement; await user.click(within(row).getByRole('button', { name: 'Keep mine' })); expect(h.resolveKeepMine).toHaveBeenCalledWith('b'); expect(h.resolveKeepServer).not.toHaveBeenCalled(); await user.click(within(row).getByRole('button', { name: 'Keep theirs' })); expect(h.resolveKeepServer).toHaveBeenCalledWith('b'); // Both resolutions re-read the cache (once on mount + once per resolution). await waitFor(() => expect(h.conflicts).toHaveBeenCalledTimes(3)); }); it('FE-COMP-OFFLINETAB-010: the conflict strategy select persists the choice', async () => { const user = userEvent.setup(); h.conflicts.mockResolvedValue([conflict()]); render(); await screen.findByText('When a conflict happens'); const select = screen.getByRole('combobox') as HTMLSelectElement; expect(select.value).toBe('ask'); await user.selectOptions(select, 'server'); expect(getOfflinePrefs().conflictStrategy).toBe('server'); await waitFor(() => expect(select.value).toBe('server')); }); it('FE-COMP-OFFLINETAB-011: Prepare renders live progress and the done marker', async () => { const user = userEvent.setup(); let release!: () => void; h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { cb({ phase: 'files', current: 1, total: 4, label: 'Paris' }); await new Promise(resolve => { release = resolve; }); cb({ phase: 'done', current: 4, total: 4 }); }); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Download for offline use' })); expect(await screen.findByText('Documents · 1/4 · Paris')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled(); await act(async () => { release(); }); expect(await screen.findByText('Ready for offline use')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled(); }); it('FE-COMP-OFFLINETAB-012: a progress report without a total renders a full bar', async () => { const user = userEvent.setup(); let release!: () => void; h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { cb({ phase: 'tiles', current: 0, total: 0 }); await new Promise(resolve => { release = resolve; }); }); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Download for offline use' })); const label = await screen.findByText('Map tiles · 0/0'); const bar = label.previousElementSibling?.firstElementChild as HTMLElement; expect(bar).toHaveStyle({ width: '100%' }); await act(async () => { release(); }); }); it('FE-COMP-OFFLINETAB-013: a half-finished trips phase renders a proportional bar', async () => { const user = userEvent.setup(); let release!: () => void; h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { cb({ phase: 'trips', current: 1, total: 4 }); await new Promise(resolve => { release = resolve; }); }); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Download for offline use' })); const label = await screen.findByText('Trip data · 1/4'); const bar = label.previousElementSibling?.firstElementChild as HTMLElement; expect(bar).toHaveStyle({ width: '25%' }); await act(async () => { release(); }); }); it('FE-COMP-OFFLINETAB-014: forcing offline downloads first, then engages and locks the actions', async () => { const user = userEvent.setup(); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Force offline mode' })); expect(h.prepareForOffline).toHaveBeenCalledTimes(1); expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Force offline mode' })); await waitFor(() => expect(screen.queryByText(/Offline mode is on/)).not.toBeInTheDocument()); expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeEnabled(); }); it('FE-COMP-OFFLINETAB-015: forcing offline while already disconnected skips the download', async () => { const user = userEvent.setup(); setOnLine(false); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Force offline mode' })); expect(h.prepareForOffline).not.toHaveBeenCalled(); expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument(); // Offline, the trip list comes out of Dexie instead of the API. expect(h.tripsToArray).toHaveBeenCalled(); }); it('FE-COMP-OFFLINETAB-016: Re-sync runs syncAll and re-reads the cache', async () => { const user = userEvent.setup(); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Re-sync now' })); expect(h.syncAll).toHaveBeenCalledTimes(1); await waitFor(() => expect(h.syncMetaToArray).toHaveBeenCalledTimes(2)); }); it('FE-COMP-OFFLINETAB-017: Re-sync shows the syncing label while it runs', async () => { const user = userEvent.setup(); let release!: () => void; h.syncAll.mockImplementation(() => new Promise(resolve => { release = resolve; })); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await user.click(screen.getByRole('button', { name: 'Re-sync now' })); expect(await screen.findByRole('button', { name: 'Syncing…' })).toBeDisabled(); await act(async () => { release(); }); expect(await screen.findByRole('button', { name: 'Re-sync now' })).toBeEnabled(); }); it('FE-COMP-OFFLINETAB-018: turning map tiles off drops the tile cache, turning them back on does not', async () => { const user = userEvent.setup(); render(); const toggle = await screen.findByRole('button', { name: 'Store map tiles offline' }); expect(toggle).toHaveAttribute('aria-pressed', 'true'); await user.click(toggle); expect(getOfflinePrefs().cacheTiles).toBe(false); expect(h.clearTileCache).toHaveBeenCalledTimes(1); await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false')); await user.click(toggle); expect(getOfflinePrefs().cacheTiles).toBe(true); expect(h.clearTileCache).toHaveBeenCalledTimes(1); }); it('FE-COMP-OFFLINETAB-019: switching a trip off evicts it, switching it back on re-syncs', async () => { const user = userEvent.setup(); render(); const toggle = await screen.findByRole('button', { name: 'Tokyo' }); expect(toggle).toHaveAttribute('aria-pressed', 'true'); expect(screen.getAllByText('Stored offline')).toHaveLength(2); await user.click(toggle); expect(h.clearTripData).toHaveBeenCalledWith(2); await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument()); await user.click(toggle); await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1)); expect(h.clearTripData).toHaveBeenCalledTimes(1); }); it('FE-COMP-OFFLINETAB-020: the per-trip section disappears when there are no trips at all', async () => { server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] }))); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); expect(screen.queryByText('Trips')).not.toBeInTheDocument(); expect(screen.getByText('Store map tiles offline')).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-021: a failing trips API falls back to the Dexie copy', async () => { server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); h.tripsToArray.mockResolvedValue([tokyo, paris]); render(); await screen.findByRole('button', { name: 'Paris' }); // Dexie rows are sorted by start date too. const labels = screen.getAllByRole('button') .map(el => el.getAttribute('aria-label')) .filter((l): l is string => l !== null); expect(labels).toEqual(['Force offline mode', 'Store map tiles offline', 'Paris', 'Tokyo']); }); it('FE-COMP-OFFLINETAB-022: when both the API and Dexie fail the trip list is simply empty', async () => { server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); h.tripsToArray.mockRejectedValue(new Error('dexie is gone')); render(); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); expect(screen.queryByText('Trips')).not.toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-023: Clear cache asks first and a declined confirm keeps the data', async () => { const user = userEvent.setup(); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); cache([{ trip: paris }]); render(); await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled()); await user.click(screen.getByRole('button', { name: 'Clear cache' })); expect(confirmSpy).toHaveBeenCalledWith('Clear all offline trip data? You can re-sync anytime while online.'); expect(h.clearAll).not.toHaveBeenCalled(); expect(within(card('Offline cache')).getByText('Paris')).toBeInTheDocument(); }); it('FE-COMP-OFFLINETAB-024: confirming Clear cache wipes the database and reloads', async () => { const user = userEvent.setup(); vi.spyOn(window, 'confirm').mockReturnValue(true); cache([{ trip: paris }]); render(); await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeEnabled()); h.syncMetaToArray.mockResolvedValue([]); await user.click(screen.getByRole('button', { name: 'Clear cache' })); expect(h.clearAll).toHaveBeenCalledTimes(1); await screen.findByText('No trips cached yet. Connect to the internet to sync.'); await waitFor(() => expect(screen.getByRole('button', { name: 'Clear cache' })).toBeDisabled()); }); });