/** * RED PHASE Tests for TanStack Query Collection Adapter * * These tests define the expected behavior for QueryCollection. * Tests should FAIL until implementation is complete. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { createQueryCollection, QueryCollection, } from './query-collection' import type { BaseRecord, QueryCollectionOptions } from '../types' interface User extends BaseRecord { id: number name: string email: string createdAt: string } describe('QueryCollection', () => { const mockUsers: User[] = [ { id: 1, name: 'Alice', email: 'alice@test.com', createdAt: '2024-01-01' }, { id: 2, name: 'Bob', email: 'bob@test.com', createdAt: '2024-01-02' }, { id: 3, name: 'Charlie', email: 'charlie@test.com', createdAt: '2024-01-03' }, ] const createOptions = (overrides?: Partial>): QueryCollectionOptions => ({ id: 'users', table: 'users', queryFn: vi.fn().mockResolvedValue(mockUsers), ...overrides, }) describe('createQueryCollection()', () => { it('should return a QueryCollection instance', () => { const collection = createQueryCollection(createOptions()) expect(collection).toBeInstanceOf(QueryCollection) }) it('should have the correct id', () => { const collection = createQueryCollection(createOptions({ id: 'my-users' })) expect(collection.id).toBe('my-users') }) it('should store the table name', () => { const collection = createQueryCollection(createOptions({ table: 'app_users' })) expect(collection.table).toBe('app_users') }) }) describe('getAll()', () => { it('should return empty array before fetch', () => { const collection = createQueryCollection(createOptions()) expect(collection.getAll()).toEqual([]) }) it('should return all items after fetch', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() expect(collection.getAll()).toEqual(mockUsers) }) }) describe('get()', () => { it('should return undefined for non-existent id', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() expect(collection.get(999)).toBeUndefined() }) it('should return the item with matching id', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() expect(collection.get(2)).toEqual(mockUsers[1]) }) it('should work with string ids', async () => { interface Item extends BaseRecord { id: string value: number } const items: Item[] = [{ id: 'abc', value: 1 }] const collection = createQueryCollection({ id: 'items', table: 'items', queryFn: vi.fn().mockResolvedValue(items), }) await collection.fetch() expect(collection.get('abc')).toEqual(items[0]) }) }) describe('fetch()', () => { it('should call the queryFn', async () => { const queryFn = vi.fn().mockResolvedValue(mockUsers) const collection = createQueryCollection(createOptions({ queryFn })) await collection.fetch() expect(queryFn).toHaveBeenCalledTimes(1) }) it('should update items with queryFn result', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() expect(collection.getAll()).toHaveLength(3) }) it('should return the fetched items', async () => { const collection = createQueryCollection(createOptions()) const result = await collection.fetch() expect(result).toEqual(mockUsers) }) it('should throw on queryFn error', async () => { const error = new Error('Network error') const queryFn = vi.fn().mockRejectedValue(error) const collection = createQueryCollection(createOptions({ queryFn })) await expect(collection.fetch()).rejects.toThrow('Network error') }) }) describe('insert()', () => { it('should add item to collection', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const newUser = await collection.insert({ name: 'David', email: 'david@test.com', createdAt: '2024-01-04' }) expect(collection.getAll()).toContainEqual(newUser) }) it('should generate id if not provided', async () => { const collection = createQueryCollection(createOptions()) const newUser = await collection.insert({ name: 'David', email: 'david@test.com', createdAt: '2024-01-04' }) expect(newUser.id).toBeDefined() }) it('should use provided id', async () => { const collection = createQueryCollection(createOptions()) const newUser = await collection.insert({ id: 100, name: 'David', email: 'david@test.com', createdAt: '2024-01-04' }) expect(newUser.id).toBe(100) }) it('should notify subscribers', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback = vi.fn() collection.subscribe(callback) await collection.insert({ name: 'David', email: 'david@test.com', createdAt: '2024-01-04' }) expect(callback).toHaveBeenCalled() }) }) describe('update()', () => { it('should update existing item', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() await collection.update(1, { name: 'Alice Updated' }) expect(collection.get(1)?.name).toBe('Alice Updated') }) it('should return the updated item', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const updated = await collection.update(1, { name: 'Alice Updated' }) expect(updated.name).toBe('Alice Updated') expect(updated.email).toBe('alice@test.com') }) it('should throw for non-existent id', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() await expect(collection.update(999, { name: 'Ghost' })).rejects.toThrow() }) it('should notify subscribers', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback = vi.fn() collection.subscribe(callback) await collection.update(1, { name: 'Alice Updated' }) expect(callback).toHaveBeenCalled() }) }) describe('delete()', () => { it('should remove item from collection', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() await collection.delete(1) expect(collection.get(1)).toBeUndefined() }) it('should reduce collection size', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() await collection.delete(1) expect(collection.getAll()).toHaveLength(2) }) it('should throw for non-existent id', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() await expect(collection.delete(999)).rejects.toThrow() }) it('should notify subscribers', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback = vi.fn() collection.subscribe(callback) await collection.delete(1) expect(callback).toHaveBeenCalled() }) }) describe('subscribe()', () => { it('should return an unsubscribe function', async () => { const collection = createQueryCollection(createOptions()) const unsubscribe = collection.subscribe(() => {}) expect(typeof unsubscribe).toBe('function') }) it('should call callback on changes', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback = vi.fn() collection.subscribe(callback) await collection.insert({ name: 'Test', email: 'test@test.com', createdAt: '2024-01-05' }) expect(callback).toHaveBeenCalledWith(expect.any(Array)) }) it('should not call callback after unsubscribe', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback = vi.fn() const unsubscribe = collection.subscribe(callback) callback.mockClear() unsubscribe() await collection.insert({ name: 'Test', email: 'test@test.com', createdAt: '2024-01-05' }) expect(callback).not.toHaveBeenCalled() }) it('should support multiple subscribers', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() const callback1 = vi.fn() const callback2 = vi.fn() collection.subscribe(callback1) collection.subscribe(callback2) await collection.insert({ name: 'Test', email: 'test@test.com', createdAt: '2024-01-05' }) expect(callback1).toHaveBeenCalled() expect(callback2).toHaveBeenCalled() }) }) describe('getSyncState()', () => { it('should return initial sync state', () => { const collection = createQueryCollection(createOptions()) const state = collection.getSyncState() expect(state).toMatchObject({ connected: false, initialized: false, pendingCount: 0, }) }) it('should update initialized after fetch', async () => { const collection = createQueryCollection(createOptions()) await collection.fetch() expect(collection.getSyncState().initialized).toBe(true) }) it('should track lastSyncAt', async () => { const collection = createQueryCollection(createOptions()) const before = Date.now() await collection.fetch() const state = collection.getSyncState() expect(state.lastSyncAt).toBeGreaterThanOrEqual(before) }) it('should track error state', async () => { const error = new Error('Fetch failed') const queryFn = vi.fn().mockRejectedValue(error) const collection = createQueryCollection(createOptions({ queryFn })) try { await collection.fetch() } catch {} expect(collection.getSyncState().lastError).toBe(error) }) }) describe('staleTime and cacheTime', () => { it('should respect staleTime for refetch decisions', async () => { const queryFn = vi.fn().mockResolvedValue(mockUsers) const collection = createQueryCollection(createOptions({ queryFn, staleTime: 5000, })) await collection.fetch() await collection.fetch() // Should not refetch if not stale // When data is fresh, should not call queryFn again expect(queryFn).toHaveBeenCalledTimes(1) }) it('should refetch when stale', async () => { vi.useFakeTimers() const queryFn = vi.fn().mockResolvedValue(mockUsers) const collection = createQueryCollection(createOptions({ queryFn, staleTime: 100, })) await collection.fetch() vi.advanceTimersByTime(150) await collection.fetch() expect(queryFn).toHaveBeenCalledTimes(2) vi.useRealTimers() }) }) describe('refetchInterval', () => { beforeEach(() => { vi.useFakeTimers() }) it('should auto-refetch at interval', async () => { const queryFn = vi.fn().mockResolvedValue(mockUsers) const collection = createQueryCollection(createOptions({ queryFn, refetchInterval: 1000, })) collection.startAutoRefetch() await vi.advanceTimersByTimeAsync(3500) expect(queryFn.mock.calls.length).toBeGreaterThanOrEqual(3) collection.stopAutoRefetch() vi.useRealTimers() }) it('should stop auto-refetch when stopped', async () => { const queryFn = vi.fn().mockResolvedValue(mockUsers) const collection = createQueryCollection(createOptions({ queryFn, refetchInterval: 1000, })) collection.startAutoRefetch() await vi.advanceTimersByTimeAsync(1500) const callCount = queryFn.mock.calls.length collection.stopAutoRefetch() await vi.advanceTimersByTimeAsync(3000) expect(queryFn.mock.calls.length).toBe(callCount) vi.useRealTimers() }) }) })