/** * Unit tests for the offline mutation queue (Fase 2b.2). * * Uses fake-indexeddb (installed via vitest.setup.ts). A hand-rolled mock * Supabase client lets us control success/failure per op without any network. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncQueue, attachOnlineFlush, MAX_ATTEMPTS, type PendingOp } from './queue.js'; // In-memory Supabase-like client. Each call records the invocation and // returns whatever the caller pushed into `responses` (FIFO). type ClientCall = { table: string; kind: string; payload?: unknown; match?: unknown; patch?: unknown }; function makeMockClient(responses: Array<{ error: { message: string } | null }>) { const calls: ClientCall[] = []; const take = () => responses.shift() ?? { error: null }; const client = { from: (table: string) => ({ insert: (payload: unknown) => ({ select: () => ({ single: async () => { calls.push({ table, kind: 'insert', payload }); return take(); } }) }), update: (patch: unknown) => ({ match: async (m: Record) => { calls.push({ table, kind: 'update', patch, match: m }); return take(); } }), delete: () => ({ match: async (m: Record) => { calls.push({ table, kind: 'delete', match: m }); return take(); } }) }) }; return { client, calls }; } async function clearIdb() { const { openDB } = await import('idb'); const db = await openDB('colectivo-sync', 1, { upgrade(db) { if (!db.objectStoreNames.contains('pending_ops')) { db.createObjectStore('pending_ops', { keyPath: 'id' }).createIndex( 'createdAt', 'createdAt' ); } } }); await db.clear('pending_ops'); db.close(); } beforeEach(async () => { await clearIdb(); }); afterEach(async () => { await clearIdb(); }); describe('SyncQueue — pending_ops contract', () => { it('Q-01: enqueue writes the op to pending_ops BEFORE calling Supabase', async () => { let pendingDuringSend: PendingOp[] = []; // Mock that snapshots the queue during the Supabase call. Holds a // forward reference to `queue` so the handler can query it. let queueRef!: SyncQueue; const client = { from: () => ({ insert: (_payload: unknown) => ({ select: () => ({ single: async () => { pendingDuringSend = await queueRef.pending(); return { error: null }; } }) }), update: () => ({ match: async () => ({ error: null }) }), delete: () => ({ match: async () => ({ error: null }) }) }) }; queueRef = new SyncQueue(client); await queueRef.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'Milk' } }); // During the send, the op MUST have been persisted. expect(pendingDuringSend).toHaveLength(1); expect(pendingDuringSend[0].kind).toBe('insert'); // And after success, the queue is empty. expect(await queueRef.pending()).toHaveLength(0); }); it('Q-02: successful send removes the op from pending_ops', async () => { const { client } = makeMockClient([{ error: null }]); const queue = new SyncQueue(client); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'Bread' } }); expect(await queue.pending()).toHaveLength(0); }); it('Q-03: failed send keeps the op and increments attempts', async () => { // First call fails, second succeeds const { client } = makeMockClient([ { error: { message: 'network' } }, { error: null } ]); const queue = new SyncQueue(client); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'Eggs' } }); let pending = await queue.pending(); expect(pending).toHaveLength(1); expect(pending[0].attempts).toBe(1); // Flush should retry and succeed this time const result = await queue.flush(); expect(result.sent).toBe(1); expect(await queue.pending()).toHaveLength(0); }); it('Q-04: ops are processed in insertion order', async () => { const { client, calls } = makeMockClient([ { error: null }, { error: null }, { error: null } ]); const queue = new SyncQueue(client); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'A' } }); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'B' } }); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'C' } }); expect( calls .filter((c) => c.kind === 'insert') .map((c) => (c.payload as { name: string }).name) ).toEqual(['A', 'B', 'C']); }); it('Q-05: ops are dropped after MAX_ATTEMPTS failures', async () => { const { client } = makeMockClient( Array.from({ length: MAX_ATTEMPTS + 2 }, () => ({ error: { message: 'nope' } })) ); const queue = new SyncQueue(client); await queue.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'X' } }); // enqueue made 1 attempt; flush repeatedly until dropped for (let i = 0; i < MAX_ATTEMPTS; i++) { await queue.flush(); } expect(await queue.pending()).toHaveLength(0); }); }); describe('SyncQueue — last-write-wins conflict logging (Fase 9.3)', () => { it('SC-01: an UPDATE whose remote pre-image differs from the local pre-image logs a sync_conflicts row', async () => { // The client tracks two kinds of calls: // * shopping_items.update → succeeds (last-write-wins). // * shopping_items.select before that update returns a row whose // `name` already differs from what the queue believed. // * sync_conflicts.insert is observed here. const calls: Array<{ table: string; kind: string; payload?: unknown; patch?: unknown; match?: unknown }> = []; const client = { from: (table: string) => ({ insert: (payload: unknown) => ({ select: () => ({ single: async () => { calls.push({ table, kind: 'insert', payload }); return { error: null }; } }) }), update: (patch: unknown) => ({ match: async (m: Record) => { calls.push({ table, kind: 'update', patch, match: m }); return { error: null }; } }), delete: () => ({ match: async (m: Record) => { calls.push({ table, kind: 'delete', match: m }); return { error: null }; } }), select: () => ({ match: () => ({ maybeSingle: async () => { calls.push({ table, kind: 'select' }); // Remote has a value that disagrees with our preImage. return { data: { name: 'remote-name' }, error: null }; } }) }) }) }; const queue = new SyncQueue(client, { currentUserId: 'user-1', collectiveId: 'coll-1' }); await queue.enqueue({ kind: 'update', table: 'shopping_items', match: { id: 'item-1' }, patch: { name: 'local-name' }, preImage: { name: 'old-name' } }); // The UPDATE happened (remote-won under last-write-wins) AND a // sync_conflicts row was inserted with both payloads. const updateCall = calls.find((c) => c.kind === 'update' && c.table === 'shopping_items'); expect(updateCall).toBeTruthy(); const conflict = calls.find((c) => c.kind === 'insert' && c.table === 'sync_conflicts'); expect(conflict).toBeTruthy(); const payload = conflict?.payload as { user_id: string; entity_type: string; entity_id: string; local_version: Record; remote_version: Record; resolution: string; }; expect(payload.user_id).toBe('user-1'); expect(payload.entity_type).toBe('shopping_item'); expect(payload.entity_id).toBe('item-1'); expect(payload.local_version).toEqual({ name: 'local-name' }); expect(payload.remote_version).toEqual({ name: 'remote-name' }); expect(payload.resolution).toBe('remote_won'); }); it('SC-02: no conflict is logged when the remote pre-image matches the local one', async () => { const calls: Array<{ table: string; kind: string }> = []; const client = { from: (table: string) => ({ insert: (_payload: unknown) => ({ select: () => ({ single: async () => ({ error: null }) }) }), update: (_patch: unknown) => ({ match: async (_m: Record) => { calls.push({ table, kind: 'update' }); return { error: null }; } }), delete: () => ({ match: async () => ({ error: null }) }), select: () => ({ match: () => ({ maybeSingle: async () => { calls.push({ table, kind: 'select' }); // Remote agrees with our preImage — no conflict. return { data: { name: 'old-name' }, error: null }; } }) }) }) }; const queue = new SyncQueue(client, { currentUserId: 'user-1' }); await queue.enqueue({ kind: 'update', table: 'shopping_items', match: { id: 'item-1' }, patch: { name: 'new-name' }, preImage: { name: 'old-name' } }); expect(calls.find((c) => c.table === 'sync_conflicts')).toBeUndefined(); }); it('SC-03: a failed sync_conflicts INSERT does NOT block the UPDATE', async () => { // The UPDATE must still come through even if the conflict log // insertion errors (best-effort logging). let updateLanded = false; const client = { from: (table: string) => ({ insert: (_payload: unknown) => ({ select: () => ({ single: async () => { // sync_conflicts insert blows up if (table === 'sync_conflicts') return { error: { message: 'rls' } }; return { error: null }; } }) }), update: (_patch: unknown) => ({ match: async (_m: Record) => { updateLanded = true; return { error: null }; } }), delete: () => ({ match: async () => ({ error: null }) }), select: () => ({ match: () => ({ maybeSingle: async () => ({ data: { name: 'remote-name' }, error: null }) }) }) }) }; const queue = new SyncQueue(client, { currentUserId: 'user-1' }); await queue.enqueue({ kind: 'update', table: 'shopping_items', match: { id: 'item-1' }, patch: { name: 'local' }, preImage: { name: 'old' } }); expect(updateLanded).toBe(true); // Op was removed from the queue (treated as success). expect(await queue.pending()).toHaveLength(0); }); }); describe('attachOnlineFlush — window online event triggers flush', () => { it('F-01: dispatching "online" calls flush() within a tick', async () => { const { client } = makeMockClient([{ error: null }]); const queue = new SyncQueue(client); const flushSpy = vi.spyOn(queue, 'flush'); // Enqueue with a failing mock so the op stays queued await clearIdb(); const failing = makeMockClient([{ error: { message: 'offline' } }]); const q2 = new SyncQueue(failing.client); await q2.enqueue({ kind: 'insert', table: 'shopping_items', payload: { name: 'Q' } }); expect(await q2.pending()).toHaveLength(1); // Attach and fire the event const detach = attachOnlineFlush(queue); window.dispatchEvent(new Event('online')); await new Promise((r) => setTimeout(r, 50)); expect(flushSpy).toHaveBeenCalled(); detach(); }); });