/** * RED PHASE Tests for TanStack Sync Collection * * Tests for local-first sync with background synchronization. * These tests should FAIL until implementation is complete. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createSyncCollection, SyncCollection, SyncEngine, } from './sync-collection' import type { BaseRecord, SyncCollectionOptions } from '../types' interface Todo extends BaseRecord { id: string title: string completed: boolean updatedAt: number } describe('SyncCollection', () => { const mockTodos: Todo[] = [ { id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }, { id: '2', title: 'Task 2', completed: true, updatedAt: 2000 }, { id: '3', title: 'Task 3', completed: false, updatedAt: 3000 }, ] let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockTodos), }) vi.stubGlobal('fetch', mockFetch) }) afterEach(() => { vi.unstubAllGlobals() }) const createOptions = (overrides?: Partial>): SyncCollectionOptions => ({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', ...overrides, }) describe('createSyncCollection()', () => { it('should return a SyncCollection instance', () => { const collection = createSyncCollection(createOptions()) expect(collection).toBeInstanceOf(SyncCollection) }) it('should have the correct id', () => { const collection = createSyncCollection(createOptions({ id: 'my-todos' })) expect(collection.id).toBe('my-todos') }) it('should store the sync URL', () => { const collection = createSyncCollection(createOptions({ syncUrl: 'http://example.com/sync' })) expect(collection.syncUrl).toBe('http://example.com/sync') }) }) describe('Initial state', () => { it('should start with empty collection', () => { const collection = createSyncCollection(createOptions()) expect(collection.getAll()).toEqual([]) }) it('should start as not connected', () => { const collection = createSyncCollection(createOptions()) expect(collection.getSyncState().connected).toBe(false) }) it('should start as not initialized', () => { const collection = createSyncCollection(createOptions()) expect(collection.getSyncState().initialized).toBe(false) }) }) describe('connect()', () => { it('should fetch initial data from sync URL', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(mockFetch).toHaveBeenCalled() }) it('should include table param in fetch URL', async () => { const collection = createSyncCollection(createOptions({ table: 'my_todos' })) await collection.connect() const [url] = mockFetch.mock.calls[0] as [string] expect(url).toContain('table=my_todos') }) it('should include custom shapeParams', async () => { const collection = createSyncCollection(createOptions({ shapeParams: { where: 'completed = false' }, })) await collection.connect() const [url] = mockFetch.mock.calls[0] as [string] expect(url).toContain('where=') }) it('should populate collection with fetched data', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(collection.getAll()).toHaveLength(3) }) it('should set connected to true', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(collection.getSyncState().connected).toBe(true) }) it('should set initialized to true', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(collection.getSyncState().initialized).toBe(true) }) it('should notify subscribers after initial fetch', async () => { const collection = createSyncCollection(createOptions()) const callback = vi.fn() collection.subscribe(callback) await collection.connect() expect(callback).toHaveBeenCalled() }) }) describe('disconnect()', () => { it('should set connected to false', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() collection.disconnect() expect(collection.getSyncState().connected).toBe(false) }) it('should stop polling', async () => { vi.useFakeTimers() const collection = createSyncCollection(createOptions({ pollInterval: 1000 })) await collection.connect() collection.disconnect() mockFetch.mockClear() await vi.advanceTimersByTimeAsync(3000) expect(mockFetch).not.toHaveBeenCalled() vi.useRealTimers() }) it('should keep existing data', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() collection.disconnect() expect(collection.getAll()).toHaveLength(3) }) }) describe('Polling', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('should poll at configured interval', async () => { const collection = createSyncCollection(createOptions({ pollInterval: 1000 })) await collection.connect() mockFetch.mockClear() await vi.advanceTimersByTimeAsync(3500) expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(3) collection.disconnect() }) it('should merge new data from poll', async () => { const collection = createSyncCollection(createOptions({ pollInterval: 1000 })) await collection.connect() // Simulate new item arriving mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([ ...mockTodos, { id: '4', title: 'Task 4', completed: false, updatedAt: 4000 }, ]), }) await vi.advanceTimersByTimeAsync(1500) expect(collection.getAll()).toHaveLength(4) collection.disconnect() }) it('should update existing items from poll', async () => { const collection = createSyncCollection(createOptions({ pollInterval: 1000 })) await collection.connect() // Simulate update mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([ { id: '1', title: 'Task 1 Updated', completed: true, updatedAt: 5000 }, ...mockTodos.slice(1), ]), }) await vi.advanceTimersByTimeAsync(1500) expect(collection.get('1')?.title).toBe('Task 1 Updated') collection.disconnect() }) }) describe('Local mutations', () => { it('should insert locally immediately', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const item = await collection.insert({ title: 'New Task', completed: false, updatedAt: Date.now() }) expect(collection.get(item.id)).toBeDefined() }) it('should generate string id for insert', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const item = await collection.insert({ title: 'New Task', completed: false, updatedAt: Date.now() }) expect(typeof item.id).toBe('string') }) it('should update locally immediately', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() await collection.update('1', { title: 'Updated Title' }) expect(collection.get('1')?.title).toBe('Updated Title') }) it('should delete locally immediately', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() await collection.delete('1') expect(collection.get('1')).toBeUndefined() }) it('should track pending mutations', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() await collection.insert({ title: 'New Task', completed: false, updatedAt: Date.now() }) expect(collection.getSyncState().pendingCount).toBe(1) }) }) describe('get() and getAll()', () => { it('should get item by string id', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(collection.get('2')?.title).toBe('Task 2') }) it('should return undefined for non-existent id', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() expect(collection.get('999')).toBeUndefined() }) it('should return all items sorted by id', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const all = collection.getAll() expect(all).toHaveLength(3) }) }) describe('subscribe()', () => { it('should call callback on connect', async () => { const collection = createSyncCollection(createOptions()) const callback = vi.fn() collection.subscribe(callback) await collection.connect() expect(callback).toHaveBeenCalled() }) it('should call callback on insert', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const callback = vi.fn() collection.subscribe(callback) await collection.insert({ title: 'New', completed: false, updatedAt: Date.now() }) expect(callback).toHaveBeenCalled() }) it('should call callback on update', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const callback = vi.fn() collection.subscribe(callback) await collection.update('1', { completed: true }) expect(callback).toHaveBeenCalled() }) it('should call callback on delete', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const callback = vi.fn() collection.subscribe(callback) await collection.delete('1') expect(callback).toHaveBeenCalled() }) it('should return unsubscribe function', async () => { const collection = createSyncCollection(createOptions()) const unsubscribe = collection.subscribe(() => {}) expect(typeof unsubscribe).toBe('function') }) it('should not call callback after unsubscribe', async () => { const collection = createSyncCollection(createOptions()) await collection.connect() const callback = vi.fn() const unsubscribe = collection.subscribe(callback) callback.mockClear() unsubscribe() await collection.insert({ title: 'New', completed: false, updatedAt: Date.now() }) expect(callback).not.toHaveBeenCalled() }) }) describe('Error handling', () => { it('should set lastError on fetch failure', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')) const collection = createSyncCollection(createOptions()) try { await collection.connect() } catch {} expect(collection.getSyncState().lastError).toBeDefined() }) it('should throw on connect failure', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')) const collection = createSyncCollection(createOptions()) await expect(collection.connect()).rejects.toThrow('Network error') }) it('should remain not connected after failure', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')) const collection = createSyncCollection(createOptions()) try { await collection.connect() } catch {} expect(collection.getSyncState().connected).toBe(false) }) }) }) describe('SyncEngine', () => { let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]), }) vi.stubGlobal('fetch', mockFetch) }) afterEach(() => { vi.unstubAllGlobals() }) it('should be exported', () => { expect(SyncEngine).toBeDefined() }) it('should create engine with options', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000', }) expect(engine).toBeInstanceOf(SyncEngine) }) it('should have registerCollection method', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) expect(typeof engine.registerCollection).toBe('function') }) it('should have getCollection method', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) expect(typeof engine.getCollection).toBe('function') }) describe('registerCollection()', () => { it('should register and return a SyncCollection', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const collection = engine.registerCollection({ id: 'todos', table: 'todos', }) expect(collection).toBeInstanceOf(SyncCollection) expect(collection.id).toBe('todos') }) it('should use engine baseUrl for sync URL', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const collection = engine.registerCollection({ id: 'todos', table: 'todos', }) expect(collection.syncUrl).toBe('http://localhost:3000/v1/shape') }) it('should allow custom syncUrl override', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const collection = engine.registerCollection({ id: 'todos', table: 'todos', syncUrl: 'http://custom.example.com/sync', }) expect(collection.syncUrl).toBe('http://custom.example.com/sync') }) it('should store registered collections', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) engine.registerCollection({ id: 'todos', table: 'todos', }) expect(engine.getCollection('todos')).toBeDefined() }) }) describe('getCollection()', () => { it('should return undefined for unregistered collection', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) expect(engine.getCollection('unknown')).toBeUndefined() }) it('should return registered collection by id', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const registered = engine.registerCollection({ id: 'todos', table: 'todos', }) const retrieved = engine.getCollection('todos') expect(retrieved).toBe(registered) }) }) describe('getCollectionIds()', () => { it('should return empty array for new engine', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) expect(engine.getCollectionIds()).toEqual([]) }) it('should return all registered collection ids', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) engine.registerCollection({ id: 'todos', table: 'todos', }) engine.registerCollection({ id: 'completed', table: 'todos', }) const ids = engine.getCollectionIds() expect(ids).toContain('todos') expect(ids).toContain('completed') expect(ids).toHaveLength(2) }) }) describe('connectAll()', () => { it('should connect all registered collections', async () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const collection1 = engine.registerCollection({ id: 'todos', table: 'todos', }) const collection2 = engine.registerCollection({ id: 'completed', table: 'completed_todos', }) await engine.connectAll() expect(collection1.getSyncState().connected).toBe(true) expect(collection2.getSyncState().connected).toBe(true) }) it('should handle empty collections', async () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) await expect(engine.connectAll()).resolves.toBeUndefined() }) it('should propagate connection errors', async () => { mockFetch.mockRejectedValue(new Error('Network error')) const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) engine.registerCollection({ id: 'todos', table: 'todos', }) await expect(engine.connectAll()).rejects.toThrow() }) }) describe('disconnectAll()', () => { it('should disconnect all registered collections', async () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) const collection1 = engine.registerCollection({ id: 'todos', table: 'todos', }) const collection2 = engine.registerCollection({ id: 'completed', table: 'completed_todos', }) await engine.connectAll() engine.disconnectAll() expect(collection1.getSyncState().connected).toBe(false) expect(collection2.getSyncState().connected).toBe(false) }) it('should handle empty collections', () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) expect(() => engine.disconnectAll()).not.toThrow() }) it('should handle already disconnected collections', async () => { const engine = new SyncEngine({ baseUrl: 'http://localhost:3000' }) engine.registerCollection({ id: 'todos', table: 'todos', }) // Not connected yet expect(() => engine.disconnectAll()).not.toThrow() }) }) }) describe('SyncCollection - Merge Strategy', () => { const mockTodos: Todo[] = [ { id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }, { id: '2', title: 'Task 2', completed: true, updatedAt: 2000 }, ] let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockTodos), }) vi.stubGlobal('fetch', mockFetch) vi.useFakeTimers() }) afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() }) it('should not overwrite items with pending local mutations on poll', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', pollInterval: 1000, }) await collection.connect() // Make a local update await collection.update('1', { title: 'Local Update' }) // Simulate remote returning old data mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([ { id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }, // Old data { id: '2', title: 'Task 2 Updated', completed: true, updatedAt: 3000 }, ]), }) await vi.advanceTimersByTimeAsync(1500) // Local update should be preserved expect(collection.get('1')?.title).toBe('Local Update') // Remote update should be applied to non-pending item expect(collection.get('2')?.title).toBe('Task 2 Updated') collection.disconnect() }) it('should handle remote deletes when no local pending changes', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', pollInterval: 1000, }) await collection.connect() // Verify both items exist expect(collection.getAll()).toHaveLength(2) // Simulate remote returning only one item (other was deleted) mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([ { id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }, ]), }) await vi.advanceTimersByTimeAsync(1500) // Should have removed item 2 expect(collection.getAll()).toHaveLength(1) expect(collection.get('2')).toBeUndefined() collection.disconnect() }) it('should preserve locally inserted items even if not in remote', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', pollInterval: 1000, }) await collection.connect() // Insert a local item const newItem = await collection.insert({ title: 'New Local Task', completed: false, updatedAt: Date.now() }) // Simulate remote returning original data without our new item mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodos), }) await vi.advanceTimersByTimeAsync(1500) // Local insert should be preserved expect(collection.get(newItem.id)).toBeDefined() expect(collection.getAll()).toHaveLength(3) collection.disconnect() }) }) describe('SyncCollection - Error Recovery', () => { let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) vi.useFakeTimers() }) afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() }) it('should continue polling after a poll error', async () => { const mockTodos = [{ id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }] // Initial connect succeeds mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodos), }) const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', pollInterval: 1000, }) await collection.connect() // First poll fails mockFetch.mockRejectedValueOnce(new Error('Network error')) // Second poll succeeds with updated data mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([ { id: '1', title: 'Task 1 Updated', completed: false, updatedAt: 2000 }, ]), }) await vi.advanceTimersByTimeAsync(2500) // Should have recovered and updated expect(collection.get('1')?.title).toBe('Task 1 Updated') expect(collection.getSyncState().lastError).toBeUndefined() collection.disconnect() }) it('should track lastError on poll failure', async () => { const mockTodos = [{ id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }] mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockTodos), }) const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', pollInterval: 1000, }) await collection.connect() // Poll fails mockFetch.mockRejectedValueOnce(new Error('Poll error')) await vi.advanceTimersByTimeAsync(1500) expect(collection.getSyncState().lastError).toBeDefined() expect(collection.getSyncState().lastError?.message).toBe('Poll error') collection.disconnect() }) it('should handle HTTP error status', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, }) const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await expect(collection.connect()).rejects.toThrow('Sync failed: 500') expect(collection.getSyncState().connected).toBe(false) }) }) describe('SyncCollection - Mutation Tracking', () => { const mockTodos: Todo[] = [ { id: '1', title: 'Task 1', completed: false, updatedAt: 1000 }, ] let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(mockTodos), }) vi.stubGlobal('fetch', mockFetch) }) afterEach(() => { vi.unstubAllGlobals() }) it('should track multiple pending mutations', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await collection.insert({ title: 'New 1', completed: false, updatedAt: Date.now() }) await collection.insert({ title: 'New 2', completed: false, updatedAt: Date.now() }) await collection.update('1', { completed: true }) expect(collection.getSyncState().pendingCount).toBe(3) }) it('should track pending count for inserts', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await collection.insert({ title: 'New Task', completed: false, updatedAt: Date.now() }) expect(collection.getSyncState().pendingCount).toBe(1) }) it('should track pending count for updates', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await collection.update('1', { completed: true }) expect(collection.getSyncState().pendingCount).toBe(1) }) it('should track pending count for deletes', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await collection.delete('1') expect(collection.getSyncState().pendingCount).toBe(1) }) it('should throw when updating non-existent item', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await expect(collection.update('999', { completed: true })).rejects.toThrow('Item with id 999 not found') }) it('should throw when deleting non-existent item', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() await expect(collection.delete('999')).rejects.toThrow('Item with id 999 not found') }) it('should use provided id for insert', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() const item = await collection.insert({ id: 'custom-id', title: 'New Task', completed: false, updatedAt: Date.now() }) expect(item.id).toBe('custom-id') expect(collection.get('custom-id')).toBeDefined() }) }) describe('SyncCollection - getSyncState immutability', () => { let mockFetch: ReturnType beforeEach(() => { mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]), }) vi.stubGlobal('fetch', mockFetch) }) afterEach(() => { vi.unstubAllGlobals() }) it('should return a copy of sync state', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) await collection.connect() const state1 = collection.getSyncState() await collection.insert({ title: 'New', completed: false, updatedAt: Date.now() }) const state2 = collection.getSyncState() expect(state1.pendingCount).toBe(0) expect(state2.pendingCount).toBe(1) }) it('should track lastSyncAt timestamp', async () => { const collection = createSyncCollection({ id: 'todos', table: 'todos', syncUrl: 'http://localhost:3000/v1/shape', }) const before = Date.now() await collection.connect() const after = Date.now() const state = collection.getSyncState() expect(state.lastSyncAt).toBeGreaterThanOrEqual(before) expect(state.lastSyncAt).toBeLessThanOrEqual(after) }) })