Files
TREK/client/src/components/Settings/OfflineTab.test.tsx
T

488 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<number>) => ({
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<typeof import('../../db/offlineDb')>();
return { ...actual, offlineDb: h.fakeDb, clearAll: h.clearAll, clearTripData: h.clearTripData };
});
vi.mock('../../sync/tripSyncManager', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../sync/tripSyncManager')>();
return { ...actual, tripSyncManager: { prepareForOffline: h.prepareForOffline, syncAll: h.syncAll } };
});
vi.mock('../../sync/mutationQueue', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../sync/mutationQueue')>();
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<typeof import('../../sync/tilePrefetcher')>();
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> = {}): SyncMeta => ({
tripId,
lastSyncedAt: null,
status: 'idle',
tilesBbox: null,
filesCachedCount: 0,
...over,
});
const conflict = (over: Partial<QueuedMutation> = {}): 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<SyncMeta>; 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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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<void>(resolve => { release = resolve; });
cb({ phase: 'done', current: 4, total: 4 });
});
render(<OfflineTab />);
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<void>(resolve => { release = resolve; });
});
render(<OfflineTab />);
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<void>(resolve => { release = resolve; });
});
render(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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<void>(resolve => { release = resolve; }));
render(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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(<OfflineTab />);
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());
});
});