feat(fase-2b): Realtime sync + offline queue + Modo Compra

Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.

2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
  reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
  that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
  `pendingTempIds` set to deduplicate own-mutation echoes against optimistic
  rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
  server response is lost and we retry, the second POST hits PK-conflict
  which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
  follow the item across sections

2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
  MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
  automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
  tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
  and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
  keeps its optimistic row and syncs on reconnect

2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
  z-50) that covers the sidebar while keeping the normal auth routing.
  56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
  Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
  new `list_start_session` message

Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
  new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
  → unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
  replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module

Totals: 103 tests green
  16 pgTAP
  59 Vitest integration (in packages/test-utils)
   6 Vitest unit (in apps/web — the SyncQueue)
  22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
   2 skipped (realtime-presence — upstream bug, unchanged)

Documented in plan/fase-2b and CLAUDE.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 03:32:15 +02:00
parent cb07f67a69
commit e7a961a66d
23 changed files with 1712 additions and 160 deletions

View File

@@ -0,0 +1,211 @@
/**
* 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<string, unknown>) => {
calls.push({ table, kind: 'update', patch, match: m });
return take();
}
}),
delete: () => ({
match: async (m: Record<string, unknown>) => {
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('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();
});
});