/** * 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[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); }); });