/** * Sync Engine Tests * * @module @bytelyst/sync/engine.test */ import { describe, it, expect, beforeEach } from 'vitest'; import { createSyncEngine, InMemoryAdapter } from './index.js'; import type { ApiClient, ApiResult } from '@bytelyst/api-client'; // ───────────────────────────────────────────────────────────────────────────── // Mock API Client // ───────────────────────────────────────────────────────────────────────────── function createMockApiClient(): ApiClient & { getRequests: () => { path: string; options?: RequestInit }[]; } { const requests: { path: string; options?: RequestInit }[] = []; return { fetch: async (path: string, options?: RequestInit): Promise => { requests.push({ path, options }); return { items: [] } as unknown as T; }, safeFetch: async (path: string, options?: RequestInit): Promise> => { requests.push({ path, options }); return { data: { items: [] } as unknown as T, error: null }; }, getRequests: () => requests, }; } // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── describe('Sync Engine', () => { let storage: InMemoryAdapter; let apiClient: ReturnType; beforeEach(() => { storage = new InMemoryAdapter(); apiClient = createMockApiClient(); }); describe('createSyncEngine', () => { it('creates a sync engine with default config', () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); expect(engine).toBeDefined(); expect(engine.push).toBeDefined(); expect(engine.pull).toBeDefined(); expect(engine.fullSync).toBeDefined(); }); }); describe('push', () => { it('adds item to queue', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.push('tasks', { title: 'Test Task' }); const status = engine.getStatus(); expect(status.status).toBe('idle'); }); it('deduplicates items for same entity', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.push('tasks', { id: '1', title: 'Task 1' }); await engine.push('tasks', { id: '1', title: 'Task 1 Updated' }); // Queue should have 1 item (deduplicated) const result = await engine.fullSync(); expect(result.pushed).toBe(1); }); }); describe('delete', () => { it('creates delete operation', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.delete('tasks', 'task-123'); const requests = apiClient.getRequests(); // Delete is queued but not flushed until sync expect(requests.length).toBe(0); }); }); describe('fullSync', () => { it('pushes queued items', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.push('tasks', { title: 'Test Task' }); const result = await engine.fullSync(); expect(result.pushed).toBe(1); const requests = apiClient.getRequests(); expect(requests).toHaveLength(2); // Pull + Push }); it('pulls remote changes', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); const result = await engine.fullSync(); expect(result.pulled).toBe(0); // Mock returns empty }); }); describe('status and monitoring', () => { it('returns initial status', () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); const status = engine.getStatus(); expect(status.status).toBe('idle'); }); it('notifies status changes', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); const statuses: string[] = []; engine.onStatusChange(status => { statuses.push(status.status); }); await engine.push('tasks', { title: 'Test' }); // Status changes during push expect(statuses.length).toBeGreaterThanOrEqual(0); }); }); describe('clearQueue', () => { it('removes all queued items', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.push('tasks', { title: 'Task 1' }); await engine.push('tasks', { title: 'Task 2' }); await engine.clearQueue(); const result = await engine.fullSync(); expect(result.pushed).toBe(0); }); }); describe('reprocessFailed', () => { it('resets retry count on failed items', async () => { const engine = createSyncEngine({ productId: 'test', entities: { tasks: { endpoint: '/tasks', partitionKey: 'userId', conflictStrategy: 'server-wins' }, }, storage, apiClient, }); await engine.push('tasks', { title: 'Test' }); await engine.reprocessFailed(); // Items should be reprocessed const result = await engine.fullSync(); expect(result.pushed).toBe(1); }); }); }); describe('Storage Adapters', () => { describe('InMemoryAdapter', () => { it('stores and retrieves items', () => { const storage = new InMemoryAdapter(); storage.setItem('key1', { value: 123 }); const retrieved = storage.getItem<{ value: number }>('key1'); expect(retrieved).toEqual({ value: 123 }); }); it('returns null for missing keys', () => { const storage = new InMemoryAdapter(); const retrieved = storage.getItem('missing'); expect(retrieved).toBeNull(); }); it('lists all keys', () => { const storage = new InMemoryAdapter(); storage.setItem('key1', 'value1'); storage.setItem('key2', 'value2'); const keys = storage.keys(); expect(keys).toContain('key1'); expect(keys).toContain('key2'); }); it('removes items', () => { const storage = new InMemoryAdapter(); storage.setItem('key1', 'value1'); storage.removeItem('key1'); expect(storage.getItem('key1')).toBeNull(); }); it('clears all items', () => { const storage = new InMemoryAdapter(); storage.setItem('key1', 'value1'); storage.setItem('key2', 'value2'); storage.clear(); expect(storage.keys()).toHaveLength(0); }); }); });