/** * Sync Engine Tests — 25+ tests * * Covers: queue persistence, retry with backoff, conflict resolution (all 4 * strategies), deduplication, connectivity, onPull callback, telemetry, * delete consolidation, multiple entities, status monitoring, destroy. * * @module @bytelyst/sync/sync.test */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { createSyncEngine, InMemoryAdapter, computeBackoff, SyncConflictError } from './index.js'; import type { SyncStatusInfo, SyncEngineConfig, EntityConfig } from './types.js'; import type { ApiClient, ApiResult } from '@bytelyst/api-client'; import type { TelemetryClient } from '@bytelyst/telemetry-client'; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── interface MockApiClient extends ApiClient { getRequests(): { path: string; options?: RequestInit }[]; setFetchBehavior(fn: (path: string, options?: RequestInit) => unknown): void; setSafeFetchBehavior(fn: (path: string) => unknown): void; } function createMockApiClient(): MockApiClient { const requests: { path: string; options?: RequestInit }[] = []; let fetchBehavior: ((path: string, options?: RequestInit) => unknown) | null = null; let safeFetchBehavior: ((path: string) => unknown) | null = null; return { fetch: async (path: string, options?: RequestInit): Promise => { requests.push({ path, options }); if (fetchBehavior) return fetchBehavior(path, options) as T; return {} as T; }, safeFetch: async (path: string, options?: RequestInit): Promise> => { requests.push({ path, options }); if (safeFetchBehavior) return safeFetchBehavior(path) as ApiResult; return { data: { items: [] } as unknown as T, error: null }; }, getRequests: () => requests, setFetchBehavior: fn => { fetchBehavior = fn; }, setSafeFetchBehavior: fn => { safeFetchBehavior = fn; }, }; } function createMockTelemetry(): TelemetryClient & { events: { eventName: string }[] } { const events: { eventName: string }[] = []; return { init: vi.fn(), trackEvent: (eventType: string, module: string, eventName: string) => { events.push({ eventName }); }, flush: vi.fn(), shutdown: vi.fn(), getInstallId: () => 'test-install', getSessionId: () => 'test-session', events, }; } const TASKS_ENTITY: EntityConfig = { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins', }; function makeConfig( storage: InMemoryAdapter, apiClient: MockApiClient, overrides?: Partial ): SyncEngineConfig { return { productId: 'test', entities: { tasks: TASKS_ENTITY }, storage, apiClient, maxRetries: 3, retryBaseDelayMs: 1, // fast for tests retryMaxDelayMs: 10, ...overrides, }; } // ───────────────────────────────────────────────────────────────────────────── // Storage Adapter Tests // ───────────────────────────────────────────────────────────────────────────── describe('InMemoryAdapter', () => { it('stores and retrieves items', () => { const s = new InMemoryAdapter(); s.setItem('k', { x: 1 }); expect(s.getItem<{ x: number }>('k')).toEqual({ x: 1 }); }); it('returns null for missing keys', () => { expect(new InMemoryAdapter().getItem('nope')).toBeNull(); }); it('lists all keys', () => { const s = new InMemoryAdapter(); s.setItem('a', 1); s.setItem('b', 2); expect(s.keys()).toEqual(expect.arrayContaining(['a', 'b'])); }); it('removes items', () => { const s = new InMemoryAdapter(); s.setItem('a', 1); s.removeItem('a'); expect(s.getItem('a')).toBeNull(); }); it('clears all items', () => { const s = new InMemoryAdapter(); s.setItem('a', 1); s.setItem('b', 2); s.clear(); expect(s.keys()).toHaveLength(0); }); }); // ───────────────────────────────────────────────────────────────────────────── // computeBackoff // ───────────────────────────────────────────────────────────────────────────── describe('computeBackoff', () => { it('returns increasing delays', () => { const d0 = computeBackoff(0, 1000, 30000); const d1 = computeBackoff(1, 1000, 30000); const d2 = computeBackoff(2, 1000, 30000); // d0 ~ 1000, d1 ~ 2000, d2 ~ 4000 (+ jitter) expect(d0).toBeLessThan(d1); expect(d1).toBeLessThan(d2); }); it('caps at maxMs', () => { const d = computeBackoff(20, 1000, 5000); expect(d).toBeLessThanOrEqual(5500); // 5000 + 10% jitter max }); }); // ───────────────────────────────────────────────────────────────────────────── // Sync Engine — Core Operations // ───────────────────────────────────────────────────────────────────────────── describe('Sync Engine', () => { let storage: InMemoryAdapter; let apiClient: MockApiClient; beforeEach(() => { storage = new InMemoryAdapter(); apiClient = createMockApiClient(); }); // ─── Creation ────────────────────────────────────────────────────────── it('creates engine with all interface methods', () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); expect(engine.push).toBeTypeOf('function'); expect(engine.delete).toBeTypeOf('function'); expect(engine.pull).toBeTypeOf('function'); expect(engine.fullSync).toBeTypeOf('function'); expect(engine.getQueueLength).toBeTypeOf('function'); expect(engine.getStatus).toBeTypeOf('function'); expect(engine.onStatusChange).toBeTypeOf('function'); expect(engine.clearQueue).toBeTypeOf('function'); expect(engine.reprocessFailed).toBeTypeOf('function'); expect(engine.flush).toBeTypeOf('function'); expect(engine.destroy).toBeTypeOf('function'); }); // ─── Queue Persistence ───────────────────────────────────────────────── it('persists queue across engine instances (simulated restart)', async () => { const engine1 = createSyncEngine(makeConfig(storage, apiClient)); await engine1.push('tasks', { id: 't1', title: 'persist me' }); engine1.destroy(); // "Restart" — new engine, same storage const engine2 = createSyncEngine(makeConfig(storage, apiClient)); const result = await engine2.fullSync(); expect(result.pushed).toBe(1); const reqs = apiClient.getRequests(); const postReq = reqs.find(r => r.options?.method === 'POST'); expect(postReq).toBeDefined(); expect(postReq!.path).toBe('/tasks'); }); // ─── Push & Deduplication ────────────────────────────────────────────── it('deduplicates updates to same entity+id', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { id: '1', title: 'v1' }, 'update'); await engine.push('tasks', { id: '1', title: 'v2' }, 'update'); expect(engine.getQueueLength()).toBe(1); const result = await engine.fullSync(); expect(result.pushed).toBe(1); // The last value should be sent const patchReq = apiClient.getRequests().find(r => r.options?.method === 'PATCH'); expect(patchReq).toBeDefined(); expect(patchReq!.path).toBe('/tasks/1'); const body = JSON.parse(patchReq!.options!.body as string); expect(body.title).toBe('v2'); }); it('does not deduplicate different operations on same id', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { id: '1', title: 'create' }, 'create'); await engine.push('tasks', { id: '1', title: 'update' }, 'update'); expect(engine.getQueueLength()).toBe(2); }); it('does not deduplicate items without id', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { title: 'Task A' }); await engine.push('tasks', { title: 'Task B' }); expect(engine.getQueueLength()).toBe(2); }); // ─── Delete ──────────────────────────────────────────────────────────── it('delete removes pending create/update for same entity+id', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { id: 'x', title: 'created' }); await engine.push('tasks', { id: 'x', title: 'updated' }, 'update'); // Now delete should collapse the above await engine.delete('tasks', 'x'); expect(engine.getQueueLength()).toBe(1); const result = await engine.fullSync(); expect(result.pushed).toBe(1); const delReq = apiClient.getRequests().find(r => r.options?.method === 'DELETE'); expect(delReq).toBeDefined(); expect(delReq!.path).toBe('/tasks/x'); }); // ─── Pull + onPull Callback ──────────────────────────────────────────── it('invokes onPull with pulled items', async () => { const pulled: { entity: string; items: unknown[] }[] = []; apiClient.setSafeFetchBehavior(() => ({ data: { items: [{ id: 'r1', title: 'Remote Task' }] }, error: null, })); const engine = createSyncEngine( makeConfig(storage, apiClient, { onPull: (entity, items) => { pulled.push({ entity, items }); }, }) ); const result = await engine.pull(); expect(result.pulled).toBe(1); expect(pulled).toHaveLength(1); expect(pulled[0].entity).toBe('tasks'); expect(pulled[0].items).toHaveLength(1); }); it('pull appends ?since= parameter after first sync', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.pull(); // first pull — no since const firstReq = apiClient.getRequests().find(r => r.path.startsWith('/tasks')); expect(firstReq!.path).toBe('/tasks'); await engine.pull(); // second pull — should have since= const allReqs = apiClient.getRequests().filter(r => r.path.startsWith('/tasks')); const secondReq = allReqs[allReqs.length - 1]; expect(secondReq.path).toContain('?since='); }); // ─── fullSync ────────────────────────────────────────────────────────── it('fullSync pushes then pulls', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { id: 't1', title: 'local' }); const result = await engine.fullSync(); expect(result.pushed).toBe(1); expect(engine.getQueueLength()).toBe(0); expect(engine.getStatus().lastSyncAt).toBeTruthy(); }); // ─── Retry with Backoff ──────────────────────────────────────────────── it('retries on transient errors and keeps item in queue', async () => { let callCount = 0; apiClient.setFetchBehavior(() => { callCount++; throw new Error('500 Internal Server Error'); }); const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 3 })); await engine.push('tasks', { id: 'fail', title: 'will fail' }); await engine.flush(); // Item should still be in queue with incremented retryCount expect(engine.getQueueLength()).toBe(1); // Multiple fetch attempts were made (backoff retries within pushItemWithRetry) expect(callCount).toBeGreaterThan(1); }); it('drops item after exceeding maxRetries', async () => { apiClient.setFetchBehavior(() => { throw new Error('500'); }); const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); await engine.push('tasks', { id: 'drop', title: 'drop me' }); // First flush: pushItemWithRetry exhausts attempts, pushQueue increments retryCount await engine.flush(); // Second flush: retryCount >= maxRetries → dropped await engine.flush(); expect(engine.getQueueLength()).toBe(0); }); // ─── Conflict Resolution ─────────────────────────────────────────────── it('server-wins: accepts remote data on conflict', async () => { const pulled: unknown[][] = []; apiClient.setFetchBehavior(() => { throw new SyncConflictError({ id: 'c1', title: 'Server Version' }); }); const engine = createSyncEngine( makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, onPull: (_entity, items) => { pulled.push(items); }, }) ); await engine.push('tasks', { id: 'c1', title: 'Client Version' }); const result = await engine.fullSync(); expect(result.conflicts).toBe(1); // server-wins: onPull should have been called with remote data expect(pulled.length).toBeGreaterThanOrEqual(1); }); it('client-wins: re-pushes local data on conflict', async () => { let callIdx = 0; apiClient.setFetchBehavior(() => { callIdx++; if (callIdx === 1) { throw new SyncConflictError({ id: 'c2', title: 'Server' }); } return {}; // Second call (PUT) succeeds }); const engine = createSyncEngine( makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'client-wins' }, }, }) ); await engine.push('tasks', { id: 'c2', title: 'Client' }); const result = await engine.fullSync(); expect(result.conflicts).toBe(1); expect(result.pushed).toBe(1); // conflict resolved → counted as pushed // Should have made a PUT request with client data const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); expect(putReq).toBeDefined(); }); it('last-write-wins: picks newer timestamp', async () => { const pulled: unknown[][] = []; const oldDate = '2020-01-01T00:00:00.000Z'; apiClient.setFetchBehavior(() => { throw new SyncConflictError({ id: 'c3', title: 'Server', updatedAt: oldDate }); }); const engine = createSyncEngine( makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'last-write-wins', }, }, onPull: (_entity, items) => { pulled.push(items); }, }) ); // Client push will have a newer timestamp than 2020 await engine.push('tasks', { id: 'c3', title: 'Client Newer' }); const result = await engine.fullSync(); expect(result.conflicts).toBe(1); // Client is newer → should NOT have called onPull with server data // Instead it should have re-pushed (PUT) const putReq = apiClient.getRequests().find(r => r.options?.method === 'PUT'); expect(putReq).toBeDefined(); }); it('manual: calls onConflict handler', async () => { apiClient.setFetchBehavior((_path, options) => { const method = options?.method; if (method === 'POST') { throw new SyncConflictError({ id: 'c4', title: 'Server' }); } return {}; }); const onConflict = vi.fn().mockResolvedValue({ id: 'c4', title: 'Merged' }); const engine = createSyncEngine( makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'manual' }, }, onConflict, }) ); await engine.push('tasks', { id: 'c4', title: 'Client' }); const result = await engine.fullSync(); expect(result.conflicts).toBe(1); expect(onConflict).toHaveBeenCalledTimes(1); expect(onConflict.mock.calls[0][0]).toMatchObject({ entity: 'tasks', remoteData: { id: 'c4', title: 'Server' }, }); }); // ─── Multiple Entities ───────────────────────────────────────────────── it('handles multiple entity types', async () => { const pulled: { entity: string; items: unknown[] }[] = []; apiClient.setSafeFetchBehavior(path => { if (path.startsWith('/tasks')) { return { data: { items: [{ id: 't1' }] }, error: null }; } if (path.startsWith('/notes')) { return { data: { items: [{ id: 'n1' }, { id: 'n2' }] }, error: null }; } return { data: { items: [] }, error: null }; }); const engine = createSyncEngine( makeConfig(storage, apiClient, { entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, notes: { endpoint: '/notes', partitionKey: 'userId', conflictStrategy: 'client-wins' }, }, onPull: (entity, items) => { pulled.push({ entity, items }); }, }) ); await engine.push('tasks', { id: 't-new', title: 'Task' }); await engine.push('notes', { id: 'n-new', body: 'Note' }); const result = await engine.fullSync(); expect(result.pushed).toBe(2); expect(result.pulled).toBe(3); // 1 task + 2 notes expect(pulled).toHaveLength(2); }); // ─── Status Monitoring ───────────────────────────────────────────────── it('returns correct initial status', () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); const status = engine.getStatus(); expect(status.status).toBe('idle'); expect(status.queueLength).toBe(0); expect(status.lastSyncAt).toBeUndefined(); }); it('notifies listeners on status changes', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); const statuses: SyncStatusInfo[] = []; engine.onStatusChange(s => statuses.push({ ...s })); await engine.push('tasks', { id: 'x', title: 'X' }); await engine.fullSync(); const statusNames = statuses.map(s => s.status); expect(statusNames).toContain('syncing'); expect(statusNames).toContain('idle'); }); it('unsubscribe stops notifications', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); const statuses: string[] = []; const unsub = engine.onStatusChange(s => statuses.push(s.status)); await engine.push('tasks', { title: 'A' }); const countBefore = statuses.length; unsub(); await engine.push('tasks', { title: 'B' }); expect(statuses.length).toBe(countBefore); }); // ─── clearQueue ──────────────────────────────────────────────────────── it('clearQueue empties the queue', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { title: 'A' }); await engine.push('tasks', { title: 'B' }); expect(engine.getQueueLength()).toBe(2); await engine.clearQueue(); expect(engine.getQueueLength()).toBe(0); const result = await engine.fullSync(); expect(result.pushed).toBe(0); }); // ─── reprocessFailed ─────────────────────────────────────────────────── it('reprocessFailed resets retry counts and re-flushes', async () => { let failCount = 0; apiClient.setFetchBehavior(() => { failCount++; if (failCount <= 2) throw new Error('transient'); return {}; }); const engine = createSyncEngine(makeConfig(storage, apiClient, { maxRetries: 1 })); await engine.push('tasks', { id: 'rp', title: 'reprocess' }); await engine.flush(); // fails, item stays in queue expect(engine.getQueueLength()).toBe(1); // Now make API succeed and reprocess apiClient.setFetchBehavior(() => ({})); await engine.reprocessFailed(); expect(engine.getQueueLength()).toBe(0); }); // ─── Telemetry Integration ───────────────────────────────────────────── it('tracks sync events via telemetry client', async () => { const telemetry = createMockTelemetry(); const engine = createSyncEngine( makeConfig(storage, apiClient, { telemetryClient: telemetry, }) ); await engine.push('tasks', { id: 't1', title: 'test' }); await engine.fullSync(); const eventNames = telemetry.events.map(e => e.eventName); expect(eventNames).toContain('sync_push_success'); expect(eventNames).toContain('sync_complete'); }); it('telemetry tracks push errors', async () => { const telemetry = createMockTelemetry(); apiClient.setFetchBehavior(() => { throw new Error('400 Bad Request'); }); const engine = createSyncEngine( makeConfig(storage, apiClient, { telemetryClient: telemetry, maxRetries: 1, }) ); await engine.push('tasks', { id: 'bad', title: 'fail' }); await engine.flush(); await engine.flush(); // second flush drops item const eventNames = telemetry.events.map(e => e.eventName); expect(eventNames).toContain('sync_push_dropped'); }); // ─── Destroy ─────────────────────────────────────────────────────────── it('destroy prevents further flush', async () => { const engine = createSyncEngine(makeConfig(storage, apiClient)); await engine.push('tasks', { title: 'orphan' }); engine.destroy(); await engine.flush(); // should be no-op after destroy // Item still in queue (flush was no-op) expect(engine.getQueueLength()).toBe(1); }); });