/** * RED PHASE Tests for useQuery Hook (postgres-dsvj.1) * * Tests the query execution, caching, refetching, stale time, and error states * for the usePostgresQuery hook and QueryAdapter integration. * * These tests should FAIL because the features are not fully implemented yet. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createUsePostgresQuery, createUsePrefetchQuery, createUseInvalidateQueries, } from '../hooks' import { createQueryAdapter, QueryAdapter, createCollectionQueryOptions, } from '../adapter' import { createQueryCollection, QueryCollection } from '../query/query-collection' import type { BaseRecord, Collection, QueryCollectionOptions } from '../types' import type { PostgresQueryOptions, QueryParams, PostgresQueryKey, QueryResult, } from '../adapter' // ============================================================================ // Test Types // ============================================================================ interface User extends BaseRecord { id: number name: string email: string active: boolean createdAt: number } interface Post extends BaseRecord { id: number title: string body: string authorId: number publishedAt: number } // ============================================================================ // Mock Helpers // ============================================================================ function createMockFetch(response: unknown = { rows: [] }, options?: { delay?: number; failAfter?: number }) { let callCount = 0 return vi.fn().mockImplementation(async () => { callCount++ if (options?.delay) { await new Promise(resolve => setTimeout(resolve, options.delay)) } if (options?.failAfter && callCount > options.failAfter) { return { ok: false, json: () => Promise.resolve({ message: 'Server error' }) } } return { ok: true, json: () => Promise.resolve(response) } }) } function createMockAdapter(fetch = createMockFetch()) { return createQueryAdapter({ database: 'testdb', fetch }) } // ============================================================================ // Section 1: Query Execution // ============================================================================ describe('useQuery Hook: Query Execution', () => { it('should execute a SQL query and return rows', async () => { const mockUsers: User[] = [ { id: 1, name: 'Alice', email: 'alice@test.com', active: true, createdAt: Date.now() }, { id: 2, name: 'Bob', email: 'bob@test.com', active: true, createdAt: Date.now() }, ] const mockFetch = createMockFetch({ rows: mockUsers }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') const result = await queryOptions.queryFn() expect(result).toEqual(mockUsers) expect(mockFetch).toHaveBeenCalledTimes(1) }) it('should pass query parameters to the API', async () => { const mockFetch = createMockFetch({ rows: [{ id: 1, name: 'Alice', email: 'alice@test.com', active: true, createdAt: 1000 }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users WHERE id = $1', params: [1], }) await queryOptions.queryFn() expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/testdb/query'), expect.objectContaining({ method: 'POST', body: expect.stringContaining('"params":[1]'), }) ) }) it('should generate correct query keys for simple queries', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions('SELECT * FROM users') expect(queryOptions.queryKey).toEqual(['postgres', 'testdb', 'SELECT * FROM users']) }) it('should generate correct query keys with parameters', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users WHERE id = $1 AND active = $2', params: [1, true], }) expect(queryOptions.queryKey).toEqual([ 'postgres', 'testdb', 'SELECT * FROM users WHERE id = $1 AND active = $2', 1, true, ]) }) it('should normalize SQL in query keys for deduplication', () => { const adapter = createMockAdapter() const opts1 = adapter.queryOptions('SELECT * FROM users') const opts2 = adapter.queryOptions(' SELECT * FROM users ') expect(opts1.queryKey).toEqual(opts2.queryKey) }) it('RED: should support query timeout configuration', async () => { const mockFetch = createMockFetch({ rows: [] }, { delay: 5000 }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) // queryOptions should support a timeout parameter const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - timeout not yet supported timeout: 1000, } as QueryParams & { timeout: number }) // Should reject with a timeout error when the query takes too long await expect(queryOptions.queryFn()).rejects.toThrow(/timeout/i) }) it('RED: should support query abort via AbortController', async () => { const mockFetch = createMockFetch({ rows: [] }, { delay: 5000 }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const controller = new AbortController() // queryOptions should support signal for query cancellation const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - signal not yet supported signal: controller.signal, } as QueryParams & { signal: AbortSignal }) const queryPromise = queryOptions.queryFn() controller.abort() await expect(queryPromise).rejects.toThrow(/abort/i) }) it('RED: should support retry configuration on transient failures', async () => { let callCount = 0 const mockFetch = vi.fn().mockImplementation(async () => { callCount++ if (callCount < 3) { return { ok: false, json: () => Promise.resolve({ message: 'Connection reset' }) } } return { ok: true, json: () => Promise.resolve({ rows: [{ id: 1, name: 'Alice' }] }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - retry not yet supported retry: 3, retryDelay: 100, } as QueryParams & { retry: number; retryDelay: number }) const result = await queryOptions.queryFn() // Should have retried and eventually succeeded expect(result).toEqual([{ id: 1, name: 'Alice' }]) expect(callCount).toBe(3) }) it('RED: should track query execution timing metadata', async () => { const mockFetch = createMockFetch({ rows: [{ id: 1 }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') const result = await queryOptions.queryFn() // The result should include timing metadata // @ts-expect-error - metadata not yet in return type expect(result.__meta?.executionTimeMs).toBeGreaterThanOrEqual(0) // @ts-expect-error - metadata not yet in return type expect(result.__meta?.queryKey).toEqual(queryOptions.queryKey) }) }) // ============================================================================ // Section 2: Caching Behavior // ============================================================================ describe('useQuery Hook: Caching', () => { it('should use staleTime from adapter defaults', () => { const adapter = createQueryAdapter({ database: 'testdb', defaultStaleTime: 30000, fetch: createMockFetch(), }) const queryOptions = adapter.queryOptions('SELECT * FROM users') expect(queryOptions.staleTime).toBe(30000) }) it('should override staleTime per query', () => { const adapter = createQueryAdapter({ database: 'testdb', defaultStaleTime: 30000, fetch: createMockFetch(), }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', staleTime: 60000, }) expect(queryOptions.staleTime).toBe(60000) }) it('should use gcTime from adapter defaults', () => { const adapter = createQueryAdapter({ database: 'testdb', defaultGcTime: 600000, fetch: createMockFetch(), }) const queryOptions = adapter.queryOptions('SELECT * FROM users') expect(queryOptions.gcTime).toBe(600000) }) it('should override gcTime per query', () => { const adapter = createQueryAdapter({ database: 'testdb', fetch: createMockFetch(), }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', gcTime: 120000, }) expect(queryOptions.gcTime).toBe(120000) }) it('RED: should support cache-first strategy (return cached data, refetch in background)', async () => { const mockFetch = createMockFetch({ rows: [{ id: 1, name: 'Alice' }] }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', staleTime: 30000, // @ts-expect-error - fetchPolicy not yet supported fetchPolicy: 'cache-first', } as QueryParams & { fetchPolicy: string }) // First call populates cache const result1 = await queryOptions.queryFn() expect(result1).toHaveLength(1) // Second call within staleTime should return cache without network const result2 = await queryOptions.queryFn() expect(result2).toEqual(result1) expect(mockFetch).toHaveBeenCalledTimes(1) // Only one network call }) it('RED: should support network-only strategy (always fetch fresh data)', async () => { let callCount = 0 const mockFetch = vi.fn().mockImplementation(async () => { callCount++ return { ok: true, json: () => Promise.resolve({ rows: [{ id: callCount }] }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - fetchPolicy not yet supported fetchPolicy: 'network-only', } as QueryParams & { fetchPolicy: string }) const result1 = await queryOptions.queryFn() const result2 = await queryOptions.queryFn() // Each call should hit the network even with staleTime expect(mockFetch).toHaveBeenCalledTimes(2) expect(result1).not.toEqual(result2) }) it('RED: should support cache key prefix for multi-tenant isolation', () => { const adapter = createQueryAdapter({ database: 'testdb', // @ts-expect-error - keyPrefix not yet supported keyPrefix: 'tenant-123', fetch: createMockFetch(), }) const queryOptions = adapter.queryOptions('SELECT * FROM users') // Query key should include the tenant prefix expect(queryOptions.queryKey[0]).toBe('postgres') // @ts-expect-error - extended key not yet supported expect(queryOptions.queryKey).toContain('tenant-123') }) it('RED: should deduplicate identical concurrent queries', async () => { let fetchCount = 0 const mockFetch = vi.fn().mockImplementation(async () => { fetchCount++ await new Promise(resolve => setTimeout(resolve, 50)) return { ok: true, json: () => Promise.resolve({ rows: [{ id: 1 }] }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') // Fire multiple identical queries concurrently const results = await Promise.all([ queryOptions.queryFn(), queryOptions.queryFn(), queryOptions.queryFn(), ]) // All should return same data but only 1 network call should happen expect(results[0]).toEqual(results[1]) expect(results[1]).toEqual(results[2]) expect(fetchCount).toBe(1) // Deduplication: only one actual fetch }) it('RED: should support cache warming / seeding', async () => { const adapter = createQueryAdapter({ database: 'testdb', fetch: createMockFetch() }) // Seed the cache with known data without making a network request const seededData: User[] = [ { id: 1, name: 'Alice', email: 'a@test.com', active: true, createdAt: 1000 }, ] // @ts-expect-error - seedCache not yet implemented adapter.seedCache('SELECT * FROM users', seededData) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', staleTime: Infinity, // Never stale when seeded }) const result = await queryOptions.queryFn() expect(result).toEqual(seededData) }) }) // ============================================================================ // Section 3: Refetching // ============================================================================ describe('useQuery Hook: Refetching', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('should support refetchInterval configuration', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', refetchInterval: 5000, }) expect(queryOptions.refetchInterval).toBe(5000) }) it('should disable refetchInterval with false', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', refetchInterval: false, }) expect(queryOptions.refetchInterval).toBe(false) }) it('RED: should support refetchOnWindowFocus configuration', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - refetchOnWindowFocus not yet supported refetchOnWindowFocus: true, } as QueryParams & { refetchOnWindowFocus: boolean }) // @ts-expect-error - not yet in type expect(queryOptions.refetchOnWindowFocus).toBe(true) }) it('RED: should support refetchOnReconnect configuration', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', // @ts-expect-error - refetchOnReconnect not yet supported refetchOnReconnect: true, } as QueryParams & { refetchOnReconnect: boolean }) // @ts-expect-error - not yet in type expect(queryOptions.refetchOnReconnect).toBe(true) }) it('RED: should support conditional refetchInterval (pauses when tab hidden)', () => { const adapter = createMockAdapter() const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', refetchInterval: 5000, // @ts-expect-error - refetchIntervalInBackground not yet supported refetchIntervalInBackground: false, } as QueryParams & { refetchIntervalInBackground: boolean }) // @ts-expect-error - not yet in type expect(queryOptions.refetchIntervalInBackground).toBe(false) }) it('RED: should support exponential backoff on refetch failures', async () => { let callCount = 0 const callTimestamps: number[] = [] const mockFetch = vi.fn().mockImplementation(async () => { callCount++ callTimestamps.push(Date.now()) return { ok: false, json: () => Promise.resolve({ message: 'Server unavailable' }) } }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELECT * FROM users', refetchInterval: 1000, // @ts-expect-error - retryBackoff not yet supported retryBackoff: 'exponential', maxRetryDelay: 30000, } as QueryParams & { retryBackoff: string; maxRetryDelay: number }) // Simulate multiple failed refetches - delays should increase exponentially try { await queryOptions.queryFn() } catch {} await vi.advanceTimersByTimeAsync(1000) try { await queryOptions.queryFn() } catch {} await vi.advanceTimersByTimeAsync(2000) try { await queryOptions.queryFn() } catch {} // The intervals between calls should be increasing expect(callCount).toBeGreaterThanOrEqual(3) }) it('should refetch when QueryCollection staleTime expires', async () => { let fetchCount = 0 const collection = createQueryCollection({ id: 'users', table: 'users', staleTime: 1000, queryFn: async () => { fetchCount++ return [{ id: fetchCount, name: `User ${fetchCount}`, email: 'a@b.com', active: true, createdAt: Date.now() }] }, }) await collection.fetch() expect(fetchCount).toBe(1) // Within staleTime, fetch should return cached data await collection.fetch() expect(fetchCount).toBe(1) // After staleTime, should refetch await vi.advanceTimersByTimeAsync(1100) await collection.fetch() expect(fetchCount).toBe(2) }) }) // ============================================================================ // Section 4: Stale Time // ============================================================================ describe('useQuery Hook: Stale Time', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('should treat data as fresh within staleTime', async () => { let fetchCount = 0 const collection = createQueryCollection({ id: 'users-stale', table: 'users', staleTime: 5000, queryFn: async () => { fetchCount++ return [{ id: 1, name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }] }, }) await collection.fetch() expect(fetchCount).toBe(1) // Multiple fetches within staleTime should not trigger queryFn await collection.fetch() await collection.fetch() expect(fetchCount).toBe(1) }) it('should treat data as stale after staleTime expires', async () => { let fetchCount = 0 const collection = createQueryCollection({ id: 'users-stale-2', table: 'users', staleTime: 5000, queryFn: async () => { fetchCount++ return [{ id: 1, name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }] }, }) await collection.fetch() expect(fetchCount).toBe(1) // Advance past staleTime await vi.advanceTimersByTimeAsync(5100) await collection.fetch() expect(fetchCount).toBe(2) }) it('RED: should expose isStale property in query state', async () => { const collection = createQueryCollection({ id: 'users-stale-state', table: 'users', staleTime: 5000, queryFn: async () => [{ id: 1, name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }], }) await collection.fetch() // @ts-expect-error - isStale not yet exposed expect(collection.isStale()).toBe(false) await vi.advanceTimersByTimeAsync(5100) // @ts-expect-error - isStale not yet exposed expect(collection.isStale()).toBe(true) }) it('RED: should support staleTime of Infinity (never stale)', async () => { let fetchCount = 0 const collection = createQueryCollection({ id: 'users-never-stale', table: 'users', staleTime: Infinity, queryFn: async () => { fetchCount++ return [{ id: 1, name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }] }, }) await collection.fetch() expect(fetchCount).toBe(1) // Even after a very long time, should not refetch await vi.advanceTimersByTimeAsync(999999999) await collection.fetch() expect(fetchCount).toBe(1) }) it('RED: should support staleTime of 0 (always stale)', async () => { let fetchCount = 0 const collection = createQueryCollection({ id: 'users-always-stale', table: 'users', staleTime: 0, queryFn: async () => { fetchCount++ return [{ id: 1, name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }] }, }) await collection.fetch() expect(fetchCount).toBe(1) // With staleTime: 0, every fetch should trigger queryFn await collection.fetch() expect(fetchCount).toBe(2) await collection.fetch() expect(fetchCount).toBe(3) }) it('RED: should support dynamic staleTime based on query data', async () => { const collection = createQueryCollection({ id: 'users-dynamic-stale', table: 'users', // @ts-expect-error - dynamic staleTime not yet supported staleTime: (data: User[]) => { // If there's a lot of data, cache longer return data.length > 10 ? 60000 : 5000 }, queryFn: async () => Array.from({ length: 20 }, (_, i) => ({ id: i, name: `User ${i}`, email: `u${i}@test.com`, active: true, createdAt: Date.now(), })), }) await collection.fetch() // With 20 items, staleTime should be 60000ms await vi.advanceTimersByTimeAsync(10000) // @ts-expect-error - isStale not yet exposed expect(collection.isStale()).toBe(false) // Still fresh at 10s await vi.advanceTimersByTimeAsync(55000) // @ts-expect-error - isStale not yet exposed expect(collection.isStale()).toBe(true) // Stale after 65s > 60s }) }) // ============================================================================ // Section 5: Error States // ============================================================================ describe('useQuery Hook: Error States', () => { it('should throw on network error', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network error')) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') await expect(queryOptions.queryFn()).rejects.toThrow('Network error') }) it('should throw on API error response', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: 'Relation "users" does not exist' }), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') await expect(queryOptions.queryFn()).rejects.toThrow('Relation "users" does not exist') }) it('should handle malformed JSON response', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.reject(new Error('Invalid JSON')), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') await expect(queryOptions.queryFn()).rejects.toThrow('Query failed') }) it('should reject invalid database names', () => { expect(() => createQueryAdapter({ database: 'DROP TABLE users;--', fetch: createMockFetch(), })).toThrow(/invalid database name/i) }) it('should reject empty database name', () => { expect(() => createQueryAdapter({ database: '', fetch: createMockFetch(), })).toThrow(/cannot be empty/i) }) it('RED: should provide structured error with query context', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: 'syntax error', code: '42601' }), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions({ sql: 'SELEC * FROM users', // intentional typo params: [1], }) try { await queryOptions.queryFn() expect.fail('Should have thrown') } catch (error) { // Error should include SQL context for debugging expect(error).toBeInstanceOf(Error) // @ts-expect-error - structured error not yet implemented expect((error as Error & { sql?: string }).sql).toBe('SELEC * FROM users') // @ts-expect-error - structured error not yet implemented expect((error as Error & { code?: string }).code).toBe('42601') // @ts-expect-error - structured error not yet implemented expect((error as Error & { params?: unknown[] }).params).toEqual([1]) } }) it('RED: should distinguish between retryable and non-retryable errors', async () => { const connectionError = vi.fn().mockResolvedValue({ ok: false, status: 503, json: () => Promise.resolve({ message: 'Service unavailable' }), }) const syntaxError = vi.fn().mockResolvedValue({ ok: false, status: 400, json: () => Promise.resolve({ message: 'Syntax error' }), }) const adapter1 = createQueryAdapter({ database: 'testdb', fetch: connectionError }) const adapter2 = createQueryAdapter({ database: 'testdb', fetch: syntaxError }) try { await adapter1.queryOptions('SELECT 1').queryFn() } catch (error) { // @ts-expect-error - isRetryable not yet implemented expect((error as Error & { isRetryable?: boolean }).isRetryable).toBe(true) } try { await adapter2.queryOptions('SELECT 1').queryFn() } catch (error) { // @ts-expect-error - isRetryable not yet implemented expect((error as Error & { isRetryable?: boolean }).isRetryable).toBe(false) } }) it('RED: should emit error events for monitoring', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: 'Query failed' }), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const errorHandler = vi.fn() // @ts-expect-error - onError event not yet supported adapter.onError(errorHandler) const queryOptions = adapter.queryOptions('SELECT * FROM users') try { await queryOptions.queryFn() } catch {} expect(errorHandler).toHaveBeenCalledWith( expect.objectContaining({ error: expect.any(Error), sql: 'SELECT * FROM users', }) ) }) it('RED: should track consecutive error count for circuit breaking', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, json: () => Promise.resolve({ message: 'Server overloaded' }), }) const adapter = createQueryAdapter({ database: 'testdb', fetch: mockFetch }) const queryOptions = adapter.queryOptions('SELECT * FROM users') // Trigger multiple failures for (let i = 0; i < 5; i++) { try { await queryOptions.queryFn() } catch {} } // After consecutive failures, adapter should expose circuit state // @ts-expect-error - getCircuitState not yet implemented const circuitState = adapter.getCircuitState() expect(circuitState).toBeDefined() expect(circuitState.consecutiveFailures).toBe(5) expect(circuitState.state).toBe('open') // Circuit breaker open }) }) // ============================================================================ // Section 6: Hook Factory Integration // ============================================================================ describe('useQuery Hook: Factory Integration', () => { it('should create usePostgresQuery with correct API', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: undefined, error: null, isLoading: true, isError: false, isSuccess: false, isFetching: true, }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) expect(typeof usePostgresQuery).toBe('function') }) it('should pass query options to underlying useQuery', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: [] }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() usePostgresQuery(adapter, { sql: 'SELECT * FROM users', staleTime: 5000 }) expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ queryKey: expect.any(Array), queryFn: expect.any(Function), staleTime: 5000, }) ) }) it('should accept SQL string shorthand', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: [] }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() usePostgresQuery(adapter, 'SELECT * FROM users') expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ queryKey: ['postgres', 'testdb', 'SELECT * FROM users'], }) ) }) it('RED: should support select/transform function for result shaping', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: [ { id: 1, name: 'Alice', email: 'a@b.com', active: true }, { id: 2, name: 'Bob', email: 'b@b.com', active: false }, ], }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() // @ts-expect-error - select option not yet supported in the hook const result = usePostgresQuery(adapter, { sql: 'SELECT * FROM users', select: (data: User[]) => data.filter(u => u.active), }) // After select transform, should only contain active users expect(result.data).toHaveLength(1) expect(result.data[0].name).toBe('Alice') }) it('RED: should support placeholderData for immediate UI rendering', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: undefined, isLoading: true }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() const placeholder: User[] = [ { id: 0, name: 'Loading...', email: '', active: false, createdAt: 0 }, ] // @ts-expect-error - placeholderData not yet passed through usePostgresQuery(adapter, { sql: 'SELECT * FROM users', placeholderData: placeholder, }) expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ placeholderData: placeholder, }) ) }) it('RED: should support enabled option for conditional queries', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: undefined }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() usePostgresQuery(adapter, { sql: 'SELECT * FROM users WHERE id = $1', params: [undefined], // userId not yet available enabled: false, }) expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ enabled: false, }) ) }) it('RED: should support keepPreviousData for paginated queries', () => { const mockUseQuery = vi.fn().mockReturnValue({ data: [] }) const usePostgresQuery = createUsePostgresQuery(mockUseQuery) const adapter = createMockAdapter() // @ts-expect-error - keepPreviousData not yet supported in hook usePostgresQuery(adapter, { sql: 'SELECT * FROM users LIMIT $1 OFFSET $2', params: [10, 20], keepPreviousData: true, }) expect(mockUseQuery).toHaveBeenCalledWith( expect.objectContaining({ // TanStack Query v5 uses placeholderData: keepPreviousData placeholderData: expect.any(Function), }) ) }) }) // ============================================================================ // Section 7: Collection-Based Queries // ============================================================================ describe('useQuery Hook: Collection Query Options', () => { it('should create query options from a collection', () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) const options = createCollectionQueryOptions({ collection, staleTime: 5000, }) expect(options.queryKey).toContain('collection') expect(options.queryKey).toContain('users') expect(options.staleTime).toBe(5000) }) it('should return collection data from queryFn', async () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) // Seed data await collection.insert({ name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }) await collection.insert({ name: 'Bob', email: 'b@b.com', active: false, createdAt: Date.now() }) const options = createCollectionQueryOptions({ collection }) const result = await options.queryFn() expect(result).toHaveLength(2) }) it('should support enabled option', () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) const options = createCollectionQueryOptions({ collection, enabled: false, }) expect(options.enabled).toBe(false) }) it('RED: should support where filter in collection query options', async () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) await collection.insert({ name: 'Alice', email: 'a@b.com', active: true, createdAt: Date.now() }) await collection.insert({ name: 'Bob', email: 'b@b.com', active: false, createdAt: Date.now() }) const options = createCollectionQueryOptions({ collection, // @ts-expect-error - where filter not yet supported where: { active: true }, }) const result = await options.queryFn() expect(result).toHaveLength(1) expect(result[0].name).toBe('Alice') }) it('RED: should support orderBy in collection query options', async () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) await collection.insert({ name: 'Charlie', email: 'c@b.com', active: true, createdAt: 3000 }) await collection.insert({ name: 'Alice', email: 'a@b.com', active: true, createdAt: 1000 }) await collection.insert({ name: 'Bob', email: 'b@b.com', active: true, createdAt: 2000 }) const options = createCollectionQueryOptions({ collection, // @ts-expect-error - orderBy not yet supported orderBy: { field: 'name', direction: 'asc' }, }) const result = await options.queryFn() expect(result.map(u => u.name)).toEqual(['Alice', 'Bob', 'Charlie']) }) it('RED: should support limit/offset in collection query options', async () => { const collection = createQueryCollection({ id: 'users', table: 'users', queryFn: async () => [], }) for (let i = 0; i < 10; i++) { await collection.insert({ name: `User ${i}`, email: `u${i}@b.com`, active: true, createdAt: Date.now() }) } const options = createCollectionQueryOptions({ collection, // @ts-expect-error - limit/offset not yet supported limit: 3, offset: 2, }) const result = await options.queryFn() expect(result).toHaveLength(3) }) }) // ============================================================================ // Section 8: Prefetch and Invalidation // ============================================================================ describe('useQuery Hook: Prefetch and Invalidation', () => { it('should create a prefetch function that calls queryClient.prefetchQuery', async () => { const mockPrefetchQuery = vi.fn().mockResolvedValue(undefined) const mockUseQueryClient = vi.fn().mockReturnValue({ prefetchQuery: mockPrefetchQuery }) const usePrefetchQuery = createUsePrefetchQuery(mockUseQueryClient) const adapter = createMockAdapter() const prefetch = usePrefetchQuery(adapter) await prefetch('SELECT * FROM users') expect(mockPrefetchQuery).toHaveBeenCalledWith( expect.objectContaining({ queryKey: ['postgres', 'testdb', 'SELECT * FROM users'], queryFn: expect.any(Function), }) ) }) it('should invalidate specific queries', () => { const mockInvalidateQueries = vi.fn() const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries }) const useInvalidateQueries = createUseInvalidateQueries(mockUseQueryClient) const adapter = createMockAdapter() const { invalidateQuery } = useInvalidateQueries(adapter) invalidateQuery({ sql: 'SELECT * FROM users WHERE id = $1', params: [1] }) expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['postgres', 'testdb', 'SELECT * FROM users WHERE id = $1', 1], }) }) it('RED: should support partial query key matching for invalidation', () => { const mockInvalidateQueries = vi.fn() const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries }) const useInvalidateQueries = createUseInvalidateQueries(mockUseQueryClient) const adapter = createMockAdapter() const { invalidateTables } = useInvalidateQueries(adapter) invalidateTables(['users']) // Should invalidate with a partial key that matches all user queries expect(mockInvalidateQueries).toHaveBeenCalledWith( expect.objectContaining({ queryKey: expect.arrayContaining(['postgres', 'testdb']), // @ts-expect-error - predicate not yet passed predicate: expect.any(Function), }) ) }) it('RED: should support batch invalidation with pattern matching', () => { const mockInvalidateQueries = vi.fn() const mockUseQueryClient = vi.fn().mockReturnValue({ invalidateQueries: mockInvalidateQueries }) const useInvalidateQueries = createUseInvalidateQueries(mockUseQueryClient) const adapter = createMockAdapter() const result = useInvalidateQueries(adapter) // @ts-expect-error - invalidatePattern not yet implemented result.invalidatePattern(/FROM\s+users/i) // Should have been called with a predicate that matches SQL patterns expect(mockInvalidateQueries).toHaveBeenCalledWith( expect.objectContaining({ predicate: expect.any(Function), }) ) }) })