mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips: - Dexie IndexedDB schema (trips, places, packing, todo, budget, reservations, files, mutationQueue, syncMeta, blobCache) - Repo layer for all domains: offline reads from Dexie, writes optimistically to Dexie and enqueue mutations for later replay - Mutation queue with UUID idempotency keys (X-Idempotency-Key), FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx - Trip sync manager: caches all trips with end_date >= today or null, auto-evicts 7d after end_date, fetches bundle endpoint in one request - Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap, warms SW cache via fetch - Sync triggers: network online → flush + syncAll; WS reconnect → flush only (rate-limiter safe); visibilitychange/30s → flush only - WS remoteEventHandler writes through to Dexie on every event - Server idempotency middleware + idempotency_keys table (migration 100, 24h TTL nightly cleanup) - GET /api/trips/:id/bundle endpoint for efficient single-request sync - OfflineBanner component: amber (offline) / blue (syncing) / hidden - OfflineTab in Settings: cached trip list, re-sync and clear actions - usePendingMutations hook for per-item pending indicators Closes #505 #541
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildTrip, buildDay, buildUser } from '../../factories';
|
||||
import { buildTrip, buildDay, buildUser, buildPlace, buildPackingItem, buildTodoItem, buildBudgetItem, buildReservation, buildTripFile } from '../../factories';
|
||||
|
||||
export const tripsHandlers = [
|
||||
// List all trips (active or archived)
|
||||
@@ -47,6 +47,22 @@ export const tripsHandlers = [
|
||||
return HttpResponse.json({ accommodations: [] });
|
||||
}),
|
||||
|
||||
http.get('/api/trips/:id/bundle', ({ params }) => {
|
||||
const tripId = Number(params.id);
|
||||
const trip = buildTrip({ id: tripId });
|
||||
const day = buildDay({ trip_id: tripId, assignments: [], notes_items: [] });
|
||||
return HttpResponse.json({
|
||||
trip,
|
||||
days: [day],
|
||||
places: [buildPlace({ trip_id: tripId })],
|
||||
packingItems: [buildPackingItem({ trip_id: tripId })],
|
||||
todoItems: [buildTodoItem({ trip_id: tripId })],
|
||||
budgetItems: [buildBudgetItem({ trip_id: tripId })],
|
||||
reservations: [buildReservation({ trip_id: tripId })],
|
||||
files: [buildTripFile({ trip_id: tripId })],
|
||||
});
|
||||
}),
|
||||
|
||||
http.delete('/api/trips/:id', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => 'mock-socket-id'),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
joinTrip: vi.fn(),
|
||||
leaveTrip: vi.fn(),
|
||||
addListener: vi.fn((fn) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked module AFTER vi.mock
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
|
||||
import { server } from './helpers/msw/server';
|
||||
@@ -9,6 +10,7 @@ vi.mock('../src/api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
// MSW lifecycle
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* offlineDb unit tests.
|
||||
*
|
||||
* Uses fake-indexeddb so no real browser IDB is needed.
|
||||
* Each test gets a fresh database by using `use-fake-indexeddb` with Dexie.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import Dexie from 'dexie';
|
||||
|
||||
// Re-import after fake-indexeddb is set up so Dexie picks up the shim.
|
||||
// We re-open a clean db in each test to isolate state.
|
||||
import {
|
||||
offlineDb,
|
||||
clearTripData,
|
||||
clearAll,
|
||||
upsertTrip,
|
||||
upsertDays,
|
||||
upsertPlaces,
|
||||
upsertPackingItems,
|
||||
upsertTodoItems,
|
||||
upsertBudgetItems,
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
type QueuedMutation,
|
||||
type SyncMeta,
|
||||
type BlobCacheEntry,
|
||||
} from '../../../src/db/offlineDb';
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile } from '../../../src/types';
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeTrip = (id = 1): Trip => ({
|
||||
id,
|
||||
name: `Trip ${id}`,
|
||||
description: null,
|
||||
start_date: '2026-07-01',
|
||||
end_date: '2026-07-05',
|
||||
cover_url: null,
|
||||
is_archived: false,
|
||||
reminder_days: 3,
|
||||
owner_id: 42,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const makeDay = (id: number, tripId = 1): Day => ({
|
||||
id,
|
||||
trip_id: tripId,
|
||||
date: '2026-07-01',
|
||||
title: null,
|
||||
notes: null,
|
||||
assignments: [],
|
||||
notes_items: [],
|
||||
});
|
||||
|
||||
const makePlace = (id: number, tripId = 1): Place => ({
|
||||
id,
|
||||
trip_id: tripId,
|
||||
name: `Place ${id}`,
|
||||
description: null,
|
||||
notes: null,
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: null,
|
||||
category_id: null,
|
||||
icon: null,
|
||||
price: null,
|
||||
currency: null,
|
||||
image_url: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
route_geometry: null,
|
||||
place_time: null,
|
||||
end_time: null,
|
||||
duration_minutes: null,
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ensure DB is open (fake-indexeddb resets between test files but not between tests).
|
||||
if (!offlineDb.isOpen()) await offlineDb.open();
|
||||
// Clear all tables before each test.
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (!offlineDb.isOpen()) await offlineDb.open();
|
||||
});
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('offlineDb — trips', () => {
|
||||
it('stores and retrieves a trip via upsertTrip', async () => {
|
||||
const trip = makeTrip(10);
|
||||
await upsertTrip(trip);
|
||||
const stored = await offlineDb.trips.get(10);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.name).toBe('Trip 10');
|
||||
});
|
||||
|
||||
it('upsertTrip overwrites an existing trip (put semantics)', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertTrip({ ...makeTrip(1), name: 'Updated' });
|
||||
const stored = await offlineDb.trips.get(1);
|
||||
expect(stored!.name).toBe('Updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — days', () => {
|
||||
it('stores days and retrieves by trip_id index', async () => {
|
||||
await upsertDays([makeDay(1, 5), makeDay(2, 5), makeDay(3, 9)]);
|
||||
const trip5Days = await offlineDb.days.where('trip_id').equals(5).toArray();
|
||||
expect(trip5Days).toHaveLength(2);
|
||||
expect(trip5Days.map(d => d.id)).toContain(1);
|
||||
expect(trip5Days.map(d => d.id)).toContain(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — places', () => {
|
||||
it('stores places and retrieves by trip_id', async () => {
|
||||
await upsertPlaces([makePlace(10, 1), makePlace(11, 1), makePlace(12, 2)]);
|
||||
const places = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(places).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — packing / todo / budget / reservations / files', () => {
|
||||
it('upserts packing items', async () => {
|
||||
const item: PackingItem = { id: 1, trip_id: 1, name: 'Passport', category: null, checked: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
expect(await offlineDb.packingItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts todo items', async () => {
|
||||
const item: TodoItem = {
|
||||
id: 1, trip_id: 1, name: 'Book hotel', category: null, checked: 0,
|
||||
sort_order: 0, due_date: null, description: null, assigned_user_id: null, priority: 0,
|
||||
};
|
||||
await upsertTodoItems([item]);
|
||||
expect(await offlineDb.todoItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts budget items', async () => {
|
||||
const item: BudgetItem = {
|
||||
id: 1, trip_id: 1, name: 'Flight', amount: 500, currency: 'EUR',
|
||||
category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null,
|
||||
};
|
||||
await upsertBudgetItems([item]);
|
||||
expect(await offlineDb.budgetItems.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts reservations', async () => {
|
||||
const item: Reservation = {
|
||||
id: 1, trip_id: 1, name: 'Hotel', type: 'hotel', status: 'confirmed',
|
||||
date: null, time: null, confirmation_number: null, notes: null, url: null, created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertReservations([item]);
|
||||
expect(await offlineDb.reservations.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('upserts trip files', async () => {
|
||||
const file: TripFile = {
|
||||
id: 1, trip_id: 1, filename: 'ticket.pdf', original_name: 'Ticket.pdf',
|
||||
mime_type: 'application/pdf', created_at: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
await upsertTripFiles([file]);
|
||||
expect(await offlineDb.tripFiles.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — syncMeta', () => {
|
||||
it('stores and retrieves syncMeta by tripId', async () => {
|
||||
const meta: SyncMeta = {
|
||||
tripId: 7,
|
||||
lastSyncedAt: Date.now(),
|
||||
status: 'idle',
|
||||
tilesBbox: null,
|
||||
filesCachedCount: 0,
|
||||
};
|
||||
await upsertSyncMeta(meta);
|
||||
const stored = await offlineDb.syncMeta.get(7);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — mutationQueue', () => {
|
||||
it('stores queued mutations queryable by status', async () => {
|
||||
const pending: QueuedMutation = {
|
||||
id: 'uuid-1', tripId: 1, method: 'POST', url: '/api/trips/1/places',
|
||||
body: { name: 'Eiffel Tower' }, createdAt: Date.now(),
|
||||
status: 'pending', attempts: 0, lastError: null,
|
||||
};
|
||||
const failed: QueuedMutation = {
|
||||
id: 'uuid-2', tripId: 1, method: 'PUT', url: '/api/trips/1/places/5',
|
||||
body: { name: 'Updated' }, createdAt: Date.now(),
|
||||
status: 'failed', attempts: 3, lastError: 'Network error',
|
||||
};
|
||||
await offlineDb.mutationQueue.bulkPut([pending, failed]);
|
||||
|
||||
const pendingRows = await offlineDb.mutationQueue.where('status').equals('pending').toArray();
|
||||
expect(pendingRows).toHaveLength(1);
|
||||
expect(pendingRows[0].id).toBe('uuid-1');
|
||||
|
||||
const failedRows = await offlineDb.mutationQueue.where('status').equals('failed').toArray();
|
||||
expect(failedRows).toHaveLength(1);
|
||||
expect(failedRows[0].lastError).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — blobCache', () => {
|
||||
it('stores and retrieves a Blob entry', async () => {
|
||||
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
||||
const entry: BlobCacheEntry = {
|
||||
url: '/api/files/99/download',
|
||||
blob,
|
||||
mime: 'application/pdf',
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
await offlineDb.blobCache.put(entry);
|
||||
|
||||
const stored = await offlineDb.blobCache.get('/api/files/99/download');
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.mime).toBe('application/pdf');
|
||||
expect(stored!.blob).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearTripData', () => {
|
||||
it('removes all data for the given trip across all tables', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
|
||||
// Also add data for a different trip — should NOT be removed
|
||||
await upsertTrip(makeTrip(2));
|
||||
await upsertDays([makeDay(99, 2)]);
|
||||
|
||||
await clearTripData(1);
|
||||
|
||||
expect(await offlineDb.trips.get(1)).toBeUndefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
||||
|
||||
// Trip 2 intact
|
||||
expect(await offlineDb.trips.get(2)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearAll', () => {
|
||||
it('empties all tables', async () => {
|
||||
await upsertTrip(makeTrip(1));
|
||||
await upsertDays([makeDay(1, 1), makeDay(2, 1)]);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
|
||||
await clearAll();
|
||||
|
||||
expect(await offlineDb.trips.count()).toBe(0);
|
||||
expect(await offlineDb.days.count()).toBe(0);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* packingRepo unit tests.
|
||||
*
|
||||
* Online path: calls REST via MSW, writes result to Dexie.
|
||||
* Offline path: returns Dexie cache, skips REST.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { packingRepo } from '../../../src/repo/packingRepo';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('packingRepo.list', () => {
|
||||
it('online — fetches from REST and caches in Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [item] })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.list(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(item.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
|
||||
expect(cached).toHaveLength(1);
|
||||
expect(cached[0].id).toBe(item.id);
|
||||
});
|
||||
|
||||
it('offline — returns Dexie cache without REST call', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
await offlineDb.packingItems.put(item);
|
||||
|
||||
let restCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/packing', () => {
|
||||
restCalled = true;
|
||||
return HttpResponse.json({ items: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await packingRepo.list(1);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].id).toBe(item.id);
|
||||
expect(restCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('offline — returns empty array when nothing cached', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const result = await packingRepo.list(99);
|
||||
expect(result.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.create', () => {
|
||||
it('calls REST and caches created item in Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
|
||||
server.use(
|
||||
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.create(1, { name: 'Sunscreen' });
|
||||
expect(result.item.name).toBe('Sunscreen');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(item.id);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe('Sunscreen');
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.update', () => {
|
||||
it('calls REST and updates Dexie cache', async () => {
|
||||
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
|
||||
await offlineDb.packingItems.put(original);
|
||||
|
||||
const updated = { ...original, checked: 1 };
|
||||
server.use(
|
||||
http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })),
|
||||
);
|
||||
|
||||
const result = await packingRepo.update(1, original.id, { checked: true });
|
||||
expect(result.item.checked).toBe(1);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(original.id);
|
||||
expect(cached!.checked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingRepo.delete', () => {
|
||||
it('calls REST and removes from Dexie', async () => {
|
||||
const item = buildPackingItem({ trip_id: 1 });
|
||||
await offlineDb.packingItems.put(item);
|
||||
|
||||
server.use(
|
||||
http.delete(`/api/trips/1/packing/${item.id}`, () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await packingRepo.delete(1, item.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.packingItems.get(item.id);
|
||||
expect(cached).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* placeRepo unit tests.
|
||||
*
|
||||
* Online path: calls REST via MSW, writes result to Dexie.
|
||||
* Offline path: returns Dexie cache, skips REST.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('placeRepo.list', () => {
|
||||
it('online — fetches from REST and caches in Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
|
||||
// Give fire-and-forget a tick to flush
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(cached).toHaveLength(1);
|
||||
expect(cached[0].id).toBe(place.id);
|
||||
});
|
||||
|
||||
it('offline — returns Dexie cache without REST call', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
let restCalled = false;
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => {
|
||||
restCalled = true;
|
||||
return HttpResponse.json({ places: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
expect(restCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('offline — returns empty array when nothing cached', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const result = await placeRepo.list(99);
|
||||
expect(result.places).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.create', () => {
|
||||
it('calls REST and caches created place in Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
|
||||
expect(result.place.name).toBe('Eiffel Tower');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(place.id);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe('Eiffel Tower');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.update', () => {
|
||||
it('calls REST and updates Dexie cache', async () => {
|
||||
const original = buildPlace({ trip_id: 1, name: 'Old Name' });
|
||||
await offlineDb.places.put(original);
|
||||
|
||||
const updated = { ...original, name: 'New Name' };
|
||||
server.use(
|
||||
http.put(`/api/trips/1/places/${original.id}`, () => HttpResponse.json({ place: updated })),
|
||||
);
|
||||
|
||||
const result = await placeRepo.update(1, original.id, { name: 'New Name' });
|
||||
expect(result.place.name).toBe('New Name');
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(original.id);
|
||||
expect(cached!.name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.delete', () => {
|
||||
it('calls REST and removes from Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
server.use(
|
||||
http.delete(`/api/trips/1/places/${place.id}`, () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await placeRepo.delete(1, place.id);
|
||||
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
const cached = await offlineDb.places.get(place.id);
|
||||
expect(cached).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* mutationQueue unit tests.
|
||||
*
|
||||
* Covers: enqueue, flush (2xx success, 4xx fail, network error), idempotency header,
|
||||
* pending count, create temp-id reconciliation, delete Dexie cleanup.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMutation(overrides: Partial<Parameters<typeof mutationQueue.enqueue>[0]> = {}) {
|
||||
return {
|
||||
id: generateUUID(),
|
||||
tripId: 1,
|
||||
method: 'POST' as const,
|
||||
url: '/trips/1/places',
|
||||
body: { name: 'Eiffel Tower' },
|
||||
resource: 'places',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── enqueue ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.enqueue', () => {
|
||||
it('stores mutation with pending status', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
const stored = await offlineDb.mutationQueue.get(id);
|
||||
expect(stored).toBeDefined();
|
||||
expect(stored!.status).toBe('pending');
|
||||
expect(stored!.attempts).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the mutation id', async () => {
|
||||
const id = generateUUID();
|
||||
const returned = await mutationQueue.enqueue(makeMutation({ id }));
|
||||
expect(returned).toBe(id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — success path ──────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — 2xx success', () => {
|
||||
it('removes mutation from queue and writes canonical entity to Dexie', async () => {
|
||||
const place = buildPlace({ trip_id: 1, id: 42 });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const queued = await offlineDb.mutationQueue.get(id);
|
||||
expect(queued).toBeUndefined();
|
||||
|
||||
const cached = await offlineDb.places.get(42);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.name).toBe(place.name);
|
||||
});
|
||||
|
||||
it('attaches X-Idempotency-Key header matching the mutation id', async () => {
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let capturedKey: string | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', ({ request }) => {
|
||||
capturedKey = request.headers.get('X-Idempotency-Key');
|
||||
return HttpResponse.json({ place });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(capturedKey).toBe(id);
|
||||
});
|
||||
|
||||
it('removes temp entry and adds canonical entry on CREATE flush', async () => {
|
||||
const tempId = -12345;
|
||||
const place = buildPlace({ trip_id: 1, id: 99 });
|
||||
const id = generateUUID();
|
||||
|
||||
// Optimistic temp entry in Dexie
|
||||
await offlineDb.places.put({ ...place, id: tempId });
|
||||
|
||||
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
||||
expect(await offlineDb.places.get(99)).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles DELETE: removes entity from Dexie after flush', async () => {
|
||||
const place = buildPlace({ trip_id: 1, id: 55 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id,
|
||||
tripId: 1,
|
||||
method: 'DELETE',
|
||||
url: '/trips/1/places/55',
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: 55,
|
||||
});
|
||||
|
||||
server.use(
|
||||
http.delete('/api/trips/1/places/55', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.mutationQueue.get(id)).toBeUndefined();
|
||||
expect(await offlineDb.places.get(55)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — error paths ───────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — 4xx client error', () => {
|
||||
it('marks mutation as failed and continues to next mutation', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
|
||||
// Enqueue in order
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
|
||||
let callCount = 0;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return HttpResponse.json({ error: 'Bad request' }, { status: 400 });
|
||||
}
|
||||
return HttpResponse.json({ place });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m1 = await offlineDb.mutationQueue.get(id1);
|
||||
expect(m1).toBeDefined();
|
||||
expect(m1!.status).toBe('failed');
|
||||
|
||||
// Second mutation succeeded and was removed
|
||||
expect(await offlineDb.mutationQueue.get(id2)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.flush — network error', () => {
|
||||
it('resets to pending and stops flush without marking failed', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m).toBeDefined();
|
||||
expect(m!.status).toBe('pending');
|
||||
expect(m!.attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── flush — offline guard ─────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — offline guard', () => {
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(called).toBe(false);
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.pending', () => {
|
||||
it('returns pending mutations for a trip', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1, tripId: 1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2, tripId: 2 }));
|
||||
|
||||
const trip1 = await mutationQueue.pending(1);
|
||||
expect(trip1).toHaveLength(1);
|
||||
expect(trip1[0].id).toBe(id1);
|
||||
});
|
||||
|
||||
it('returns all pending when no tripId given', async () => {
|
||||
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: generateUUID(), tripId: 2 }));
|
||||
|
||||
const all = await mutationQueue.pending();
|
||||
expect(all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('excludes failed mutations', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
await offlineDb.mutationQueue.update(id, { status: 'failed' });
|
||||
|
||||
const pending = await mutationQueue.pending(1);
|
||||
expect(pending).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.pendingCount', () => {
|
||||
it('returns zero for empty queue', async () => {
|
||||
expect(await mutationQueue.pendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('counts pending and syncing, excludes failed', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
const id3 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id3 }));
|
||||
await offlineDb.mutationQueue.update(id3, { status: 'failed' });
|
||||
|
||||
expect(await mutationQueue.pendingCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* tilePrefetcher unit tests.
|
||||
*
|
||||
* Covers: bbox computation, tile math, URL building, size guard,
|
||||
* offline/no-SW guard, syncMeta update.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import {
|
||||
computeBbox,
|
||||
lngToTileX,
|
||||
latToTileY,
|
||||
buildTileUrl,
|
||||
countTiles,
|
||||
prefetchTiles,
|
||||
prefetchTilesForTrip,
|
||||
MAX_TILES,
|
||||
type TileBbox,
|
||||
} from '../../../src/sync/tilePrefetcher';
|
||||
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
|
||||
import { buildPlace } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch + serviceWorker so prefetch path is exercised
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
value: { controller: {} },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── bbox computation ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeBbox', () => {
|
||||
it('returns null when no places have coordinates', () => {
|
||||
const places = [buildPlace({ lat: null, lng: null })];
|
||||
expect(computeBbox(places)).toBeNull();
|
||||
});
|
||||
|
||||
it('expands single-point bbox to at least 0.1° span', () => {
|
||||
const place = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
const bbox = computeBbox([place])!;
|
||||
expect(bbox.maxLat - bbox.minLat).toBeGreaterThan(0.09);
|
||||
expect(bbox.maxLng - bbox.minLng).toBeGreaterThan(0.09);
|
||||
});
|
||||
|
||||
it('computes multi-point bbox with padding', () => {
|
||||
const places = [
|
||||
buildPlace({ lat: 48.8566, lng: 2.3522 }), // Paris
|
||||
buildPlace({ lat: 51.5074, lng: -0.1278 }), // London
|
||||
];
|
||||
const bbox = computeBbox(places, 0.1)!;
|
||||
// Padded bbox should extend beyond raw points
|
||||
expect(bbox.minLat).toBeLessThan(48.8566);
|
||||
expect(bbox.maxLat).toBeGreaterThan(51.5074);
|
||||
expect(bbox.minLng).toBeLessThan(-0.1278);
|
||||
expect(bbox.maxLng).toBeGreaterThan(2.3522);
|
||||
});
|
||||
|
||||
it('clamps to valid Mercator lat bounds', () => {
|
||||
const places = [buildPlace({ lat: 85.0, lng: 0 })];
|
||||
const bbox = computeBbox(places, 0.5)!;
|
||||
expect(bbox.maxLat).toBeLessThanOrEqual(85.0511);
|
||||
});
|
||||
});
|
||||
|
||||
// ── tile math ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('lngToTileX', () => {
|
||||
it('returns 0 for lng=-180 at any zoom', () => {
|
||||
expect(lngToTileX(-180, 10)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns max tile for lng=180 at zoom 1', () => {
|
||||
// At zoom 1: 2^1 = 2 tiles, lng=180 → x = floor(360/360 * 2) = floor(2) = 2
|
||||
// But tile range is 0..1, so this is the "overflow" edge — that's fine
|
||||
expect(lngToTileX(180, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('increases with more easterly longitude', () => {
|
||||
const x1 = lngToTileX(0, 10);
|
||||
const x2 = lngToTileX(10, 10);
|
||||
expect(x2).toBeGreaterThan(x1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latToTileY', () => {
|
||||
it('returns smaller y for higher latitude (north = top)', () => {
|
||||
const yNorth = latToTileY(60, 10);
|
||||
const ySouth = latToTileY(10, 10);
|
||||
expect(yNorth).toBeLessThan(ySouth);
|
||||
});
|
||||
|
||||
it('equator is roughly half the tile grid', () => {
|
||||
const yEq = latToTileY(0, 1);
|
||||
// zoom 1 → 2 rows, equator ≈ row 1
|
||||
expect(yEq).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL building ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildTileUrl', () => {
|
||||
it('replaces {z}, {x}, {y}, {r} correctly', () => {
|
||||
const tmpl = 'https://tile.example.com/{z}/{x}/{y}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 500, 300);
|
||||
expect(url).toBe('https://tile.example.com/10/500/300.png');
|
||||
});
|
||||
|
||||
it('replaces {s} with a subdomain character', () => {
|
||||
const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 0, 0);
|
||||
expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/);
|
||||
});
|
||||
|
||||
it('removes {r} (retina placeholder)', () => {
|
||||
const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png';
|
||||
const url = buildTileUrl(tmpl, 10, 0, 0);
|
||||
expect(url).toBe('https://tiles.example.com/10/0/0.png');
|
||||
});
|
||||
});
|
||||
|
||||
// ── countTiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('countTiles', () => {
|
||||
it('returns more tiles at higher zoom levels', () => {
|
||||
const bbox: TileBbox = { minLat: 48.7, maxLat: 49.0, minLng: 2.2, maxLng: 2.5 };
|
||||
const low = countTiles(bbox, 10, 10);
|
||||
const high = countTiles(bbox, 12, 12);
|
||||
expect(high).toBeGreaterThan(low);
|
||||
});
|
||||
|
||||
it('stops counting after exceeding MAX_TILES', () => {
|
||||
// Very large bbox — should hit cap quickly at high zooms
|
||||
const bbox: TileBbox = { minLat: -60, maxLat: 60, minLng: -180, maxLng: 180 };
|
||||
const count = countTiles(bbox, 10, 16);
|
||||
expect(count).toBeGreaterThan(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── prefetchTiles guards ───────────────────────────────────────────────────────
|
||||
|
||||
describe('prefetchTiles — offline guard', () => {
|
||||
it('returns 0 and does not fetch when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
|
||||
expect(count).toBe(0);
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 0 when no service worker controller', async () => {
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
value: { controller: null },
|
||||
configurable: true,
|
||||
});
|
||||
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 10);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefetchTiles — normal operation', () => {
|
||||
it('fetches tiles and returns count', async () => {
|
||||
const bbox: TileBbox = { minLat: 48.84, maxLat: 48.87, minLng: 2.33, maxLng: 2.37 };
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops at zoom level where cap is exceeded', async () => {
|
||||
// Use a very small MAX_TILES override by using a huge bbox
|
||||
const bbox: TileBbox = { minLat: -80, maxLat: 80, minLng: -170, maxLng: 170 };
|
||||
// This bbox at zoom 10 alone has thousands of tiles — should trigger early stop
|
||||
const count = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 16);
|
||||
expect(count).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── prefetchTilesForTrip ──────────────────────────────────────────────────────
|
||||
|
||||
describe('prefetchTilesForTrip', () => {
|
||||
it('no-ops when no places have coordinates', async () => {
|
||||
const places = [buildPlace({ lat: null, lng: null })];
|
||||
await prefetchTilesForTrip(1, places);
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates syncMeta tilesBbox after prefetch', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
const meta = await offlineDb.syncMeta.get(1);
|
||||
expect(meta!.tilesBbox).not.toBeNull();
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
// Places far apart → huge bbox → estimate > MAX_TILES
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
|
||||
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
// No fetches should have been made
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* tripSyncManager unit tests.
|
||||
*
|
||||
* Covers: trip filtering (shouldCache/isStale), bundle fetch → Dexie upsert,
|
||||
* stale trip eviction, offline guard, file blob caching.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
buildDay,
|
||||
buildPlace,
|
||||
buildPackingItem,
|
||||
buildTodoItem,
|
||||
buildBudgetItem,
|
||||
buildReservation,
|
||||
buildTripFile,
|
||||
} from '../../helpers/factories';
|
||||
|
||||
// Helper to get today ± N days as YYYY-MM-DD
|
||||
function dateOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function makeBundle(tripId: number) {
|
||||
const trip = buildTrip({ id: tripId, end_date: dateOffset(3) });
|
||||
return {
|
||||
trip,
|
||||
days: [buildDay({ trip_id: tripId, assignments: [], notes_items: [] })],
|
||||
places: [buildPlace({ trip_id: tripId })],
|
||||
packingItems: [buildPackingItem({ trip_id: tripId })],
|
||||
todoItems: [buildTodoItem({ trip_id: tripId })],
|
||||
budgetItems: [buildBudgetItem({ trip_id: tripId })],
|
||||
reservations: [buildReservation({ trip_id: tripId })],
|
||||
files: [buildTripFile({ trip_id: tripId, url: `/api/trips/${tripId}/files/99/download`, mime_type: 'application/pdf' })],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(['data'], { type: 'application/pdf' }),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — offline guard', () => {
|
||||
it('does nothing when offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
|
||||
let listed = false;
|
||||
server.use(
|
||||
http.get('/api/trips', () => { listed = true; return HttpResponse.json({ trips: [] }); }),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(listed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── trip filtering ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — trip filtering', () => {
|
||||
it('caches ongoing trips (end_date >= today)', async () => {
|
||||
const tripId = 100;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(2) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
const cached = await offlineDb.trips.get(tripId);
|
||||
expect(cached).toBeDefined();
|
||||
expect(cached!.id).toBe(tripId);
|
||||
});
|
||||
|
||||
it('caches trips with no end_date', async () => {
|
||||
const tripId = 101;
|
||||
const bundle = makeBundle(tripId);
|
||||
const trip = buildTrip({ id: tripId, end_date: null as unknown as string });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not cache past trips (end_date < today)', async () => {
|
||||
const tripId = 102;
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(-1) })] }),
|
||||
),
|
||||
);
|
||||
|
||||
// Bundle should NOT be called for past trips
|
||||
let bundleCalled = false;
|
||||
server.use(
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => {
|
||||
bundleCalled = true;
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(bundleCalled).toBe(false);
|
||||
expect(await offlineDb.trips.get(tripId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── stale eviction ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — stale eviction', () => {
|
||||
it('evicts trips that ended more than 7 days ago', async () => {
|
||||
const staleId = 200;
|
||||
// Seed Dexie as if previously cached
|
||||
await upsertTrip(buildTrip({ id: staleId, end_date: dateOffset(-8) }));
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: staleId, end_date: dateOffset(-8) })] }),
|
||||
),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
expect(await offlineDb.trips.get(staleId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT evict trips that ended exactly 6 days ago', async () => {
|
||||
const recentId = 201;
|
||||
const bundle = makeBundle(recentId);
|
||||
const trip = buildTrip({ id: recentId, end_date: dateOffset(-6) });
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.json({ trips: [trip] })),
|
||||
http.get(`/api/trips/${recentId}/bundle`, () => HttpResponse.json({ ...bundle, trip })),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
// end_date = -6 days: still within 7d window, but < today so not cached
|
||||
// i.e., shouldCache is false (end_date < today) so won't be fetched
|
||||
// but also isStale is false (end_date = -6 >= cutoff -7), so won't be evicted
|
||||
// → trip should simply not appear in Dexie (not cached, not evicted pre-seeded data)
|
||||
expect(await offlineDb.trips.get(recentId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── bundle upsert ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager.syncAll — bundle upsert', () => {
|
||||
it('writes all bundle entities to Dexie', async () => {
|
||||
const tripId = 300;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
expect(await offlineDb.trips.get(tripId)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.places.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.todoItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.budgetItems.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.reservations.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
expect(await offlineDb.tripFiles.where('trip_id').equals(tripId).count()).toBe(1);
|
||||
});
|
||||
|
||||
it('writes syncMeta with lastSyncedAt', async () => {
|
||||
const tripId = 301;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
await tripSyncManager.syncAll();
|
||||
const after = Date.now();
|
||||
|
||||
const meta = await offlineDb.syncMeta.get(tripId);
|
||||
expect(meta).toBeDefined();
|
||||
expect(meta!.lastSyncedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(meta!.lastSyncedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
|
||||
// ── file blob caching ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('tripSyncManager — file blob caching', () => {
|
||||
it('caches non-photo files after bundle sync', async () => {
|
||||
const tripId = 400;
|
||||
const bundle = makeBundle(tripId);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
|
||||
// Give fire-and-forget a tick
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const cached = await offlineDb.blobCache.toArray();
|
||||
expect(cached.length).toBeGreaterThan(0);
|
||||
expect(cached[0].url).toContain('/download');
|
||||
});
|
||||
|
||||
it('does not cache photo files (image/* MIME)', async () => {
|
||||
const tripId = 401;
|
||||
const photoFile = buildTripFile({
|
||||
trip_id: tripId,
|
||||
mime_type: 'image/jpeg',
|
||||
url: `/api/trips/${tripId}/files/77/download`,
|
||||
});
|
||||
const bundle = {
|
||||
...makeBundle(tripId),
|
||||
files: [photoFile],
|
||||
};
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips', () =>
|
||||
HttpResponse.json({ trips: [buildTrip({ id: tripId, end_date: dateOffset(5) })] }),
|
||||
),
|
||||
http.get(`/api/trips/${tripId}/bundle`, () => HttpResponse.json(bundle)),
|
||||
);
|
||||
|
||||
await tripSyncManager.syncAll();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const cached = await offlineDb.blobCache.toArray();
|
||||
expect(cached.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ vi.mock('../../src/api/websocket', () => ({
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
Reference in New Issue
Block a user