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:
jubnl
2026-04-14 23:04:13 +02:00
parent 8c7567faf3
commit b194e8317d
64 changed files with 3837 additions and 638 deletions
@@ -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);
});
});