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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user