mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user