From 18ea77a522fd7fc78184dfd2ccd310c90dc49de3 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 14:54:42 -0800 Subject: [PATCH 01/16] feat(web): add mock-db and dependencies tests --- .../__tests__/dependencies.test.ts | 367 ++++++++++++++++++ web/src/testing/mock-db.ts | 133 +++++++ 2 files changed, 500 insertions(+) create mode 100644 web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts create mode 100644 web/src/testing/mock-db.ts diff --git a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts new file mode 100644 index 000000000..86f0400bc --- /dev/null +++ b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts @@ -0,0 +1,367 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' + +import { getDependencies } from '../_get' + +import { + createMockDbSelect, + createMockLogger, + mockDbSchema, +} from '@/testing/mock-db' + +// Mock the db module +const mockDbSelect = mock(() => ({})) + +mock.module('@codebuff/internal/db', () => ({ + default: { + select: mockDbSelect, + }, +})) + +mock.module('@codebuff/internal/db/schema', () => mockDbSchema) + +describe('/api/agents/[publisherId]/[agentId]/[version]/dependencies GET endpoint', () => { + let mockLogger: ReturnType + + const createMockParams = (overrides: Partial<{ publisherId: string; agentId: string; version: string }> = {}) => { + return Promise.resolve({ + publisherId: 'test-publisher', + agentId: 'test-agent', + version: '1.0.0', + ...overrides, + }) + } + + beforeEach(() => { + mockLogger = createMockLogger() + + // Reset to default empty mock + mockDbSelect.mockImplementation(createMockDbSelect({ publishers: [], rootAgent: null })) + }) + + describe('Parameter validation', () => { + test('returns 400 when publisherId is missing', async () => { + const response = await getDependencies({ + params: createMockParams({ publisherId: '' }), + logger: mockLogger, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Missing required parameters' }) + }) + + test('returns 400 when agentId is missing', async () => { + const response = await getDependencies({ + params: createMockParams({ agentId: '' }), + logger: mockLogger, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Missing required parameters' }) + }) + + test('returns 400 when version is missing', async () => { + const response = await getDependencies({ + params: createMockParams({ version: '' }), + logger: mockLogger, + }) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body).toEqual({ error: 'Missing required parameters' }) + }) + }) + + describe('Publisher not found', () => { + test('returns 404 when publisher does not exist', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [], // No publishers + rootAgent: null, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body).toEqual({ error: 'Publisher not found' }) + }) + }) + + describe('Agent not found', () => { + test('returns 404 when agent does not exist', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: null, // No agent + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(404) + const body = await response.json() + expect(body).toEqual({ error: 'Agent not found' }) + }) + }) + + describe('Agent with no subagents', () => { + test('returns tree with single node when agent has no spawnableAgents', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { displayName: 'Test Agent', spawnableAgents: [] }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.fullId).toBe('test-publisher/test-agent@1.0.0') + expect(body.root.displayName).toBe('Test Agent') + expect(body.root.children).toEqual([]) + expect(body.totalAgents).toBe(1) + expect(body.maxDepth).toBe(0) + expect(body.hasCycles).toBe(false) + }) + + test('returns tree with single node when spawnableAgents is not an array', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { displayName: 'Test Agent', spawnableAgents: 'not-an-array' }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.children).toEqual([]) + expect(body.totalAgents).toBe(1) + }) + }) + + describe('Agent data parsing', () => { + test('handles agent data as JSON string', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: JSON.stringify({ displayName: 'Parsed Agent', spawnableAgents: [] }), + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.displayName).toBe('Parsed Agent') + }) + + test('uses agentId as displayName when displayName is not provided', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { spawnableAgents: [] }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.displayName).toBe('test-agent') + }) + + test('uses name as displayName when displayName is not provided but name is', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { name: 'Agent Name', spawnableAgents: [] }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.displayName).toBe('Agent Name') + }) + }) + + describe('Internal server error', () => { + test('returns 500 when database throws an error', async () => { + mockDbSelect.mockImplementation(() => { + throw new Error('Database connection failed') + }) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Internal server error' }) + expect(mockLogger.error).toHaveBeenCalled() + }) + + test('returns 500 when params promise rejects', async () => { + const response = await getDependencies({ + params: Promise.reject(new Error('Params error')), + logger: mockLogger, + }) + + expect(response.status).toBe(500) + const body = await response.json() + expect(body).toEqual({ error: 'Internal server error' }) + expect(mockLogger.error).toHaveBeenCalled() + }) + }) + + describe('Agent with subagents', () => { + test('returns tree with children when agent has spawnableAgents', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { + displayName: 'Root Agent', + spawnableAgents: ['test-publisher/child-agent@1.0.0'], + }, + }, + childAgents: [{ + id: 'child-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { displayName: 'Child Agent', spawnableAgents: [] }, + }], + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.displayName).toBe('Root Agent') + expect(body.root.children).toHaveLength(1) + expect(body.root.children[0].displayName).toBe('Child Agent') + expect(body.totalAgents).toBe(2) + expect(body.maxDepth).toBe(1) + }) + + test('handles unavailable child agents gracefully', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { + displayName: 'Root Agent', + spawnableAgents: ['test-publisher/missing-agent@1.0.0'], + }, + }, + childAgents: [], // No child agents found + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.children).toHaveLength(1) + expect(body.root.children[0].isAvailable).toBe(false) + expect(body.root.children[0].displayName).toBe('missing-agent') + }) + }) + + describe('spawnerPrompt handling', () => { + test('includes spawnerPrompt in response when present', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { + displayName: 'Test Agent', + spawnerPrompt: 'Use this agent to help with testing', + spawnableAgents: [], + }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.spawnerPrompt).toBe('Use this agent to help with testing') + }) + + test('sets spawnerPrompt to null when not present', async () => { + mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { + id: 'test-agent', + version: '1.0.0', + publisher_id: 'test-publisher', + data: { displayName: 'Test Agent', spawnableAgents: [] }, + }, + })) + + const response = await getDependencies({ + params: createMockParams(), + logger: mockLogger, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.root.spawnerPrompt).toBeNull() + }) + }) +}) diff --git a/web/src/testing/mock-db.ts b/web/src/testing/mock-db.ts new file mode 100644 index 000000000..fa69b89e3 --- /dev/null +++ b/web/src/testing/mock-db.ts @@ -0,0 +1,133 @@ +import { mock } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +/** + * Configuration for creating a mock database that simulates + * the batch querying pattern used in agent-related API routes. + */ +export interface MockDbConfig { + /** List of publishers to return from the publishers table */ + publishers?: Array<{ id: string }> + /** The root agent to return, or null if not found */ + rootAgent?: { + id: string + version: string + publisher_id: string + data: unknown + } | null + /** Child agents to return for batch queries */ + childAgents?: Array<{ + id: string + version: string + publisher_id: string + data: unknown + }> +} + +/** + * Creates a mock database select function that handles the batch querying pattern: + * 1. First query: fetch ALL publishers (uses .then directly on from()) + * 2. Second query: fetch root agent (with where clause) + * 3. Subsequent queries: batch queries for child agents (with where and possibly orderBy) + * + * This is designed for testing API routes that use the batch querying pattern + * like the agent dependencies route. + * + * @example + * ```ts + * const mockDbSelect = mock(() => ({})) + * mock.module('@codebuff/internal/db', () => ({ default: { select: mockDbSelect } })) + * + * // In test: + * mockDbSelect.mockImplementation(createMockDbSelect({ + * publishers: [{ id: 'test-publisher' }], + * rootAgent: { id: 'test-agent', version: '1.0.0', publisher_id: 'test-publisher', data: {} }, + * })) + * ``` + */ +export function createMockDbSelect(config: MockDbConfig) { + let queryCount = 0 + + return mock(() => ({ + from: mock(() => { + queryCount++ + const isPublisherTable = queryCount === 1 // First query is always publishers + + if (isPublisherTable) { + // Publishers query - returns all publishers directly via .then on from() + return { + where: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.publishers ?? []), + ), + orderBy: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + limit: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + })), + })), + })), + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.publishers ?? []), + ), + } + } + + // Agent queries + return { + where: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => { + if (queryCount === 2) { + // Root agent query + return cb(config.rootAgent ? [config.rootAgent] : []) + } + // Batch child agent queries + return cb(config.childAgents ?? []) + }), + orderBy: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.childAgents ?? []), + ), + limit: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.childAgents ?? []), + ), + })), + })), + })), + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + } + }), + })) +} + +/** + * Creates a mock logger for testing API routes. + * All methods are mocked and can be asserted against. + */ +export function createMockLogger(): Logger { + return { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } +} + +/** + * Mock schema for the internal database schema. + * Use this with mock.module to mock '@codebuff/internal/db/schema'. + */ +export const mockDbSchema = { + publisher: { id: 'publisher.id' }, + agentConfig: { + id: 'agentConfig.id', + version: 'agentConfig.version', + publisher_id: 'agentConfig.publisher_id', + major: 'agentConfig.major', + minor: 'agentConfig.minor', + patch: 'agentConfig.patch', + data: 'agentConfig.data', + }, +} From b90c078939434f3d279f39bfeeaa825657c8cc72 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:05:34 -0800 Subject: [PATCH 02/16] refactor(testing): centralize mock-db utilities in common package - Add TestableDb interface to common/types/contracts/database.ts for type-safe database mocking - Move mock-db.ts from web/src/testing to common/src/testing - Add createSelectOnlyMockDb for version-utils query patterns - Add createMockDb, createMockDbWithErrors for API route testing - Update API routes to use TestableDb interface instead of CodebuffPgDatabase - Refactor test files to use shared mock-db utilities without type assertions - All 1,647+ tests passing across packages --- common/src/testing/mock-db.ts | 481 ++++++++++++++++++ common/src/types/contracts/database.ts | 46 ++ .../src/utils/__tests__/version-utils.test.ts | 131 +---- packages/internal/src/utils/version-utils.ts | 8 +- .../__tests__/dependencies.test.ts | 2 +- .../[runId]/steps/__tests__/steps.test.ts | 75 +-- .../api/v1/agent-runs/[runId]/steps/_post.ts | 8 +- .../agent-runs/__tests__/agent-runs.test.ts | 48 +- web/src/app/api/v1/agent-runs/_post.ts | 12 +- web/src/testing/mock-db.ts | 133 ----- 10 files changed, 594 insertions(+), 350 deletions(-) create mode 100644 common/src/testing/mock-db.ts delete mode 100644 web/src/testing/mock-db.ts diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts new file mode 100644 index 000000000..1ee6fe3e7 --- /dev/null +++ b/common/src/testing/mock-db.ts @@ -0,0 +1,481 @@ +import { mock } from 'bun:test' + +import type { + TestableDb, + TestableDbWhereResult, +} from '@codebuff/common/types/contracts/database' +import type { Logger } from '@codebuff/common/types/contracts/logger' + +// ============================================================================ +// Types +// ============================================================================ + +/** Callback type for insert operations */ +export type InsertCallback = (values: unknown) => Promise | void + +/** Callback type for update operations */ +export type UpdateCallback = () => Promise | void + +/** Callback type for select results */ +export type SelectResultsCallback = () => unknown[] | Promise + +/** + * Configuration for creating a mock database that simulates + * the batch querying pattern used in agent-related API routes. + */ +export interface MockDbConfig { + /** List of publishers to return from the publishers table */ + publishers?: Array<{ id: string }> + /** The root agent to return, or null if not found */ + rootAgent?: { + id: string + version: string + publisher_id: string + data: unknown + } | null + /** Child agents to return for batch queries */ + childAgents?: Array<{ + id: string + version: string + publisher_id: string + data: unknown + }> +} + +/** + * Creates a mock database select function that handles the batch querying pattern: + * 1. First query: fetch ALL publishers (uses .then directly on from()) + * 2. Second query: fetch root agent (with where clause) + * 3. Subsequent queries: batch queries for child agents (with where and possibly orderBy) + * + * This is designed for testing API routes that use the batch querying pattern + * like the agent dependencies route. + * + * @example + * ```ts + * const mockDbSelect = mock(() => ({})) + * mock.module('@codebuff/internal/db', () => ({ default: { select: mockDbSelect } })) + * + * // In test: + * mockDbSelect.mockImplementation(createMockDbSelect({ + * publishers: [{ id: 'test-publisher' }], + * rootAgent: { id: 'test-agent', version: '1.0.0', publisher_id: 'test-publisher', data: {} }, + * })) + * ``` + */ +export function createMockDbSelect(config: MockDbConfig) { + let queryCount = 0 + + return mock(() => ({ + from: mock(() => { + queryCount++ + const isPublisherTable = queryCount === 1 // First query is always publishers + + if (isPublisherTable) { + // Publishers query - returns all publishers directly via .then on from() + return { + where: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.publishers ?? []), + ), + orderBy: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + limit: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + })), + })), + })), + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.publishers ?? []), + ), + } + } + + // Agent queries + return { + where: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => { + if (queryCount === 2) { + // Root agent query + return cb(config.rootAgent ? [config.rootAgent] : []) + } + // Batch child agent queries + return cb(config.childAgents ?? []) + }), + orderBy: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.childAgents ?? []), + ), + limit: mock(() => ({ + then: mock(async (cb: (rows: unknown[]) => unknown) => + cb(config.childAgents ?? []), + ), + })), + })), + })), + then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), + } + }), + })) +} + +/** + * Creates a mock logger for testing API routes. + * All methods are mocked and can be asserted against. + */ +export function createMockLogger(): Logger { + return { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), + } +} + +/** + * Mock schema for the internal database schema. + * Use this with mock.module to mock '@codebuff/internal/db/schema'. + */ +export const mockDbSchema = { + publisher: { id: 'publisher.id' }, + agentConfig: { + id: 'agentConfig.id', + version: 'agentConfig.version', + publisher_id: 'agentConfig.publisher_id', + major: 'agentConfig.major', + minor: 'agentConfig.minor', + patch: 'agentConfig.patch', + data: 'agentConfig.data', + }, +} + +// ============================================================================ +// Insert Mock +// ============================================================================ + +/** + * Configuration for mock insert operations. + */ +export interface MockDbInsertConfig { + /** Callback invoked when values() is called. Defaults to no-op. */ + onValues?: InsertCallback +} + +/** + * Creates a mock database insert function that simulates the pattern: + * `db.insert(table).values(data)` + * + * @example + * ```ts + * const mockInsert = createMockDbInsert({ + * onValues: async (values) => { + * // Verify or capture the inserted values + * expect(values).toHaveProperty('id') + * }, + * }) + * + * const mockDb = { insert: mockInsert } + * ``` + */ +export function createMockDbInsert(config: MockDbInsertConfig = {}) { + const { onValues = async () => {} } = config + + return mock(() => ({ + values: mock(async (values: unknown) => { + await onValues(values) + }), + })) +} + +// ============================================================================ +// Update Mock +// ============================================================================ + +/** + * Configuration for mock update operations. + */ +export interface MockDbUpdateConfig { + /** Callback invoked when where() is called. Defaults to no-op. */ + onWhere?: UpdateCallback +} + +/** + * Creates a mock database update function that simulates the pattern: + * `db.update(table).set(data).where(condition)` + * + * @example + * ```ts + * const mockUpdate = createMockDbUpdate({ + * onWhere: async () => { + * // Update completed + * }, + * }) + * + * const mockDb = { update: mockUpdate } + * ``` + */ +export function createMockDbUpdate(config: MockDbUpdateConfig = {}) { + const { onWhere = async () => {} } = config + + return mock(() => ({ + set: mock(() => ({ + where: mock(async () => { + await onWhere() + }), + })), + })) +} + +// ============================================================================ +// Simple Select Mock +// ============================================================================ + +/** + * Configuration for mock simple select operations (not batch pattern). + */ +export interface MockDbSimpleSelectConfig { + /** + * Results to return from the select query. + * Can be a static array or a callback for dynamic results. + */ + results?: unknown[] | SelectResultsCallback +} + +/** + * Creates a mock database select function that simulates the pattern: + * `db.select().from(table).where(condition).limit(n)` + * + * This is for simple queries, not the batch pattern used by createMockDbSelect. + * + * @example + * ```ts + * const mockSelect = createMockDbSimpleSelect({ + * results: [{ id: 'user-123', name: 'Test User' }], + * }) + * + * const mockDb = { select: mockSelect } + * ``` + */ +export function createMockDbSimpleSelect(config: MockDbSimpleSelectConfig = {}) { + const { results = [] } = config + + const getResults = async () => { + if (typeof results === 'function') { + return results() + } + return results + } + + return mock(() => ({ + from: mock(() => ({ + where: mock(() => ({ + limit: mock(async () => getResults()), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + const data = await getResults() + return cb?.(data) ?? data + }), + orderBy: mock(() => ({ + limit: mock(async () => getResults()), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + const data = await getResults() + return cb?.(data) ?? data + }), + })), + })), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + const data = await getResults() + return cb?.(data) ?? data + }), + })), + })) +} + +// ============================================================================ +// Complete Mock Database Factory +// ============================================================================ + +/** + * Configuration for creating a complete mock database object. + */ +export interface MockDbFactoryConfig { + /** Configuration for insert operations */ + insert?: MockDbInsertConfig + /** Configuration for update operations */ + update?: MockDbUpdateConfig + /** Configuration for simple select operations */ + select?: MockDbSimpleSelectConfig +} + +/** + * Return type of createMockDb - a complete mock database object. + * Implements TestableDb for type-safe dependency injection in tests. + */ +export type MockDb = TestableDb + +/** + * Creates a complete mock database object with insert, update, and select operations. + * + * This is the recommended way to create a mock database for testing API routes + * that perform multiple types of database operations. + * + * @example + * ```ts + * let mockDb: MockDb + * + * beforeEach(() => { + * mockDb = createMockDb({ + * insert: { + * onValues: async (values) => console.log('Inserted:', values), + * }, + * update: { + * onWhere: async () => console.log('Updated'), + * }, + * select: { + * results: [{ id: 'user-123' }], + * }, + * }) + * }) + * ``` + */ +export function createMockDb(config: MockDbFactoryConfig = {}): TestableDb { + // Use type assertion since Mock types don't perfectly match TestableDb + // but the runtime behavior is correct + return { + insert: createMockDbInsert(config.insert), + update: createMockDbUpdate(config.update), + select: createMockDbSimpleSelect(config.select) as TestableDb['select'], + } +} + +/** + * Creates a mock database with insert and update that throw errors. + * Useful for testing error handling paths. + * + * @example + * ```ts + * const mockDb = createMockDbWithErrors({ + * insertError: new Error('Database connection failed'), + * selectResults: [{ user_id: 'user-123' }], // Optional: results to return before error + * }) + * ``` + */ +export function createMockDbWithErrors(config: { + insertError?: Error + updateError?: Error + selectError?: Error + /** Results to return from select queries (before any error is thrown) */ + selectResults?: unknown[] +} = {}): TestableDb { + const { insertError, updateError, selectError, selectResults = [] } = config + + // Use type assertion since Mock types don't perfectly match TestableDb + // but the runtime behavior is correct + return { + insert: mock(() => ({ + values: mock(async () => { + if (insertError) throw insertError + }), + })), + update: mock(() => ({ + set: mock(() => ({ + where: mock(async () => { + if (updateError) throw updateError + }), + })), + })), + select: mock(() => ({ + from: mock(() => ({ + where: mock(() => ({ + limit: mock(async () => { + if (selectError) throw selectError + return selectResults + }), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + if (selectError) throw selectError + return cb?.(selectResults) ?? selectResults + }), + orderBy: mock(() => ({ + limit: mock(async () => { + if (selectError) throw selectError + return selectResults + }), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + if (selectError) throw selectError + return cb?.(selectResults) ?? selectResults + }), + })), + })), + then: mock(async (cb?: ((rows: unknown[]) => unknown) | null) => { + if (selectError) throw selectError + return cb?.(selectResults) ?? selectResults + }), + })), + })) as TestableDb['select'], + } +} + +// ============================================================================ +// Version Utils Mock Database +// ============================================================================ + +/** + * Creates a mock database for version-utils and similar queries that use + * the pattern: `db.select().from().where().orderBy().limit().then()` + * + * This is a simpler mock that doesn't use bun:test mocks, making it + * type-safe without requiring mock type assertions. + * + * @param selectResults - The results to return from select queries + * + * @example + * ```ts + * const mockDb = createSelectOnlyMockDb([{ major: 1, minor: 2, patch: 3 }]) + * + * const result = await getLatestAgentVersion({ + * agentId: 'test-agent', + * publisherId: 'test-publisher', + * db: mockDb, + * }) + * ``` + */ +export function createSelectOnlyMockDb(selectResults: unknown[]): TestableDb { + const createWhereResult = (): TestableDbWhereResult => ({ + then: ( + onfulfilled?: + | ((value: unknown[]) => TResult | PromiseLike) + | null + | undefined, + ): PromiseLike => { + if (onfulfilled) { + return Promise.resolve(onfulfilled(selectResults)) + } + return Promise.resolve(selectResults as unknown as TResult) + }, + limit: () => Promise.resolve(selectResults), + orderBy: () => ({ + limit: () => Promise.resolve(selectResults), + }), + }) + + return { + insert: () => ({ + values: () => Promise.resolve(), + }), + update: () => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + }), + select: () => ({ + from: () => ({ + where: () => createWhereResult(), + }), + }), + } +} + +/** + * @deprecated Use createSelectOnlyMockDb instead. This is an alias for backwards compatibility. + */ +export const createVersionUtilsMockDb = createSelectOnlyMockDb + diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index 65f770ada..d97f4e339 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -92,3 +92,49 @@ export type AddAgentStepFn = (params: { }) => Promise export type DatabaseAgentCache = Map + +// ============================================================================ +// Testable Database Interface +// ============================================================================ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Minimal database interface for dependency injection in API routes. + * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. + * + * This allows tests to provide mock implementations without type casting. + * Uses `any` for table/column parameters to be compatible with Drizzle ORM's + * specific table types while remaining flexible for mocks. + */ +export interface TestableDb { + insert: (table: any) => { + values: (data: any) => PromiseLike + } + update: (table: any) => { + set: (data: any) => { + where: (condition: any) => PromiseLike + } + } + select: (columns?: any) => { + from: (table: any) => { + where: (condition: any) => TestableDbWhereResult + } + } +} + +/** + * Result type for where() that supports multiple query patterns: + * - .limit(n) for simple queries + * - .orderBy(...).limit(n) for sorted queries + * - .then() for promise-like resolution + */ +export interface TestableDbWhereResult { + then: ( + onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, + ) => PromiseLike + limit: (n: number) => PromiseLike + orderBy: (...columns: any[]) => { + limit: (n: number) => PromiseLike + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index 1a654333e..7ebec9785 100644 --- a/packages/internal/src/utils/__tests__/version-utils.test.ts +++ b/packages/internal/src/utils/__tests__/version-utils.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, afterEach, mock } from 'bun:test' -import * as versionUtils from '../version-utils' +import { createSelectOnlyMockDb } from '@codebuff/common/testing/mock-db' -import type { CodebuffPgDatabase } from '../../db/types' +import * as versionUtils from '../version-utils' const { versionOne, @@ -124,18 +124,7 @@ describe('version-utils', () => { describe('getLatestAgentVersion', () => { it('should return version 0.0.0 when no agent exists', async () => { - // Mock the database to return empty result - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => Promise.resolve([])), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -146,20 +135,7 @@ describe('version-utils', () => { }) it('should return latest version when agent exists', async () => { - // Mock the database to return a version - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => - Promise.resolve([{ major: 1, minor: 2, patch: 3 }]), - ), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ major: 1, minor: 2, patch: 3 }]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -170,20 +146,7 @@ describe('version-utils', () => { }) it('should handle null values in database response', async () => { - // Mock the database to return null values - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => - Promise.resolve([{ major: null, minor: null, patch: null }]), - ), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ major: null, minor: null, patch: null }]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -196,19 +159,7 @@ describe('version-utils', () => { describe('determineNextVersion', () => { it('should increment patch of latest version when no version provided', async () => { - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => - Promise.resolve([{ major: 1, minor: 2, patch: 3 }]), - ), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ major: 1, minor: 2, patch: 3 }]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -219,17 +170,7 @@ describe('version-utils', () => { }) it('should use provided version when higher than latest', async () => { - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => Promise.resolve([])), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -241,19 +182,7 @@ describe('version-utils', () => { }) it('should throw error when provided version is not greater than latest', async () => { - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => - Promise.resolve([{ major: 2, minor: 0, patch: 0 }]), - ), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ major: 2, minor: 0, patch: 0 }]) await expect( determineNextVersion({ @@ -268,19 +197,7 @@ describe('version-utils', () => { }) it('should throw error when provided version equals latest', async () => { - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => - Promise.resolve([{ major: 1, minor: 5, patch: 0 }]), - ), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ major: 1, minor: 5, patch: 0 }]) await expect( determineNextVersion({ @@ -295,17 +212,7 @@ describe('version-utils', () => { }) it('should throw error for invalid provided version', async () => { - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => ({ - orderBy: mock(() => ({ - limit: mock(() => Promise.resolve([])), - })), - })), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([]) await expect( determineNextVersion({ @@ -322,14 +229,7 @@ describe('version-utils', () => { describe('versionExists', () => { it('should return true when version exists', async () => { - // Mock the database to return a result - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => Promise.resolve([{ id: 'test-agent' }])), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([{ id: 'test-agent' }]) const result = await versionExists({ agentId: 'test-agent', @@ -341,14 +241,7 @@ describe('version-utils', () => { }) it('should return false when version does not exist', async () => { - // Mock the database to return empty result - const mockDb = { - select: mock(() => ({ - from: mock(() => ({ - where: mock(() => Promise.resolve([])), - })), - })), - } as unknown as CodebuffPgDatabase + const mockDb = createSelectOnlyMockDb([]) const result = await versionExists({ agentId: 'test-agent', diff --git a/packages/internal/src/utils/version-utils.ts b/packages/internal/src/utils/version-utils.ts index a2db36c26..d1a81f052 100644 --- a/packages/internal/src/utils/version-utils.ts +++ b/packages/internal/src/utils/version-utils.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from 'drizzle-orm' import * as schema from '@codebuff/internal/db/schema' -import type { CodebuffPgDatabase } from '../db/types' +import type { TestableDb } from '@codebuff/common/types/contracts/database' export type Version = { major: number; minor: number; patch: number } @@ -54,7 +54,7 @@ export function isGreater(v1: Version, v2: Version): boolean { export async function getLatestAgentVersion(params: { agentId: string publisherId: string - db: CodebuffPgDatabase + db: TestableDb }): Promise { const { agentId, publisherId, db } = params @@ -96,7 +96,7 @@ export async function determineNextVersion(params: { agentId: string publisherId: string providedVersion?: string - db: CodebuffPgDatabase + db: TestableDb }): Promise { const { agentId, publisherId, providedVersion, db } = params @@ -137,7 +137,7 @@ export async function versionExists(params: { agentId: string version: Version publisherId: string - db: CodebuffPgDatabase + db: TestableDb }): Promise { const { agentId, version, publisherId, db } = params diff --git a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts index 86f0400bc..46662a17c 100644 --- a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts +++ b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/__tests__/dependencies.test.ts @@ -6,7 +6,7 @@ import { createMockDbSelect, createMockLogger, mockDbSchema, -} from '@/testing/mock-db' +} from '@codebuff/common/testing/mock-db' // Mock the db module const mockDbSelect = mock(() => ({})) diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts index 0e9c02293..61ba3d965 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/__tests__/steps.test.ts @@ -4,6 +4,12 @@ import { NextRequest } from 'next/server' import { postAgentRunsSteps } from '../_post' +import { + createMockDb, + createMockDbWithErrors, + createMockLogger, +} from '@codebuff/common/testing/mock-db' + import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' import type { @@ -16,7 +22,7 @@ describe('agentRunsStepsPost', () => { let mockLogger: Logger let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn - let mockDb: any + let mockDb: ReturnType beforeEach(() => { mockGetUserInfoFromApiKey = async ({ apiKey, fields }) => { @@ -39,30 +45,16 @@ describe('agentRunsStepsPost', () => { return null } - mockLogger = { - error: () => {}, - warn: () => {}, - info: () => {}, - debug: () => {}, - } + mockLogger = createMockLogger() mockLoggerWithContext = mock(() => mockLogger) mockTrackEvent = () => {} // Default mock DB with successful operations - mockDb = { - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => [{ user_id: 'user-123' }], - }), - }), - }), - insert: () => ({ - values: async () => {}, - }), - } + mockDb = createMockDb({ + select: { results: [{ user_id: 'user-123' }] }, + }) }) test('returns 401 when no API key provided', async () => { @@ -165,16 +157,9 @@ describe('agentRunsStepsPost', () => { }) test('returns 404 when agent run does not exist', async () => { - const dbWithNoRun = { - ...mockDb, - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => [], // Empty array = not found - }), - }), - }), - } as any + const dbWithNoRun = createMockDb({ + select: { results: [] }, // Empty array = not found + }) const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', @@ -201,16 +186,9 @@ describe('agentRunsStepsPost', () => { }) test('returns 403 when run belongs to different user', async () => { - const dbWithDifferentUser = { - ...mockDb, - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => [{ user_id: 'other-user' }], - }), - }), - }), - } as any + const dbWithDifferentUser = createMockDb({ + select: { results: [{ user_id: 'other-user' }] }, + }) const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', @@ -294,21 +272,10 @@ describe('agentRunsStepsPost', () => { }) test('handles database errors gracefully', async () => { - const dbWithError = { - ...mockDb, - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => [{ user_id: 'user-123' }], - }), - }), - }), - insert: () => ({ - values: async () => { - throw new Error('DB error') - }, - }), - } as any + const dbWithError = createMockDbWithErrors({ + insertError: new Error('DB error'), + selectResults: [{ user_id: 'user-123' }], + }) const req = new NextRequest( 'http://localhost/api/v1/agent-runs/run-123/steps', diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts index a892cfd30..24bab6de5 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts @@ -7,12 +7,14 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + GetUserInfoFromApiKeyFn, + TestableDb, +} from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' -import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' import type { NextRequest } from 'next/server' import { extractApiKeyFromHeader } from '@/util/auth' @@ -34,7 +36,7 @@ export async function postAgentRunsSteps(params: { logger: Logger loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn - db: CodebuffPgDatabase + db: TestableDb }) { const { req, diff --git a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts index 47dae5c0b..5c1578df4 100644 --- a/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts +++ b/web/src/app/api/v1/agent-runs/__tests__/agent-runs.test.ts @@ -5,6 +5,12 @@ import { NextRequest } from 'next/server' import { postAgentRuns } from '../_post' +import { + createMockDb, + createMockDbWithErrors, + createMockLogger, +} from '@codebuff/common/testing/mock-db' + import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn, @@ -44,29 +50,15 @@ describe('/api/v1/agent-runs POST endpoint', () => { let mockLogger: Logger let mockLoggerWithContext: LoggerWithContextFn let mockTrackEvent: TrackEventFn - let mockDb: any + let mockDb: ReturnType beforeEach(() => { - mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } + mockLogger = createMockLogger() mockLoggerWithContext = mock(() => mockLogger) mockTrackEvent = mock(() => {}) - mockDb = { - insert: mock(() => ({ - values: mock(async () => {}), - })), - update: mock(() => ({ - set: mock(() => ({ - where: mock(async () => {}), - })), - })), - } + mockDb = createMockDb() }) afterEach(() => { @@ -392,11 +384,9 @@ describe('/api/v1/agent-runs POST endpoint', () => { }) test('returns 500 when database insertion fails', async () => { - mockDb.insert = mock(() => ({ - values: mock(async () => { - throw new Error('Database error') - }), - })) + const errorDb = createMockDbWithErrors({ + insertError: new Error('Database error'), + }) const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', { method: 'POST', @@ -413,7 +403,7 @@ describe('/api/v1/agent-runs POST endpoint', () => { logger: mockLogger, loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, - db: mockDb, + db: errorDb, }) expect(response.status).toBe(500) @@ -699,13 +689,9 @@ describe('/api/v1/agent-runs POST endpoint', () => { }) test('returns 500 when database update fails', async () => { - mockDb.update = mock(() => ({ - set: mock(() => ({ - where: mock(async () => { - throw new Error('Database update error') - }), - })), - })) + const errorDb = createMockDbWithErrors({ + updateError: new Error('Database update error'), + }) const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', { method: 'POST', @@ -726,7 +712,7 @@ describe('/api/v1/agent-runs POST endpoint', () => { logger: mockLogger, loggerWithContext: mockLoggerWithContext, trackEvent: mockTrackEvent, - db: mockDb, + db: errorDb, }) expect(response.status).toBe(500) diff --git a/web/src/app/api/v1/agent-runs/_post.ts b/web/src/app/api/v1/agent-runs/_post.ts index a74630d7d..16d2f7888 100644 --- a/web/src/app/api/v1/agent-runs/_post.ts +++ b/web/src/app/api/v1/agent-runs/_post.ts @@ -7,12 +7,14 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { + GetUserInfoFromApiKeyFn, + TestableDb, +} from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, } from '@codebuff/common/types/contracts/logger' -import type { CodebuffPgDatabase } from '@codebuff/internal/db/types' import type { NextRequest } from 'next/server' import { extractApiKeyFromHeader } from '@/util/auth' @@ -43,7 +45,7 @@ async function handleStartAction(params: { userId: string logger: Logger trackEvent: TrackEventFn - db: CodebuffPgDatabase + db: TestableDb }) { const { data, userId, logger, trackEvent, db } = params const { agentId, ancestorRunIds } = data @@ -105,7 +107,7 @@ async function handleFinishAction(params: { userId: string logger: Logger trackEvent: TrackEventFn - db: CodebuffPgDatabase + db: TestableDb }) { const { data, userId, logger, trackEvent, db } = params const { @@ -174,7 +176,7 @@ export async function postAgentRuns(params: { logger: Logger loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn - db: CodebuffPgDatabase + db: TestableDb }) { const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, db } = params diff --git a/web/src/testing/mock-db.ts b/web/src/testing/mock-db.ts deleted file mode 100644 index fa69b89e3..000000000 --- a/web/src/testing/mock-db.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { mock } from 'bun:test' - -import type { Logger } from '@codebuff/common/types/contracts/logger' - -/** - * Configuration for creating a mock database that simulates - * the batch querying pattern used in agent-related API routes. - */ -export interface MockDbConfig { - /** List of publishers to return from the publishers table */ - publishers?: Array<{ id: string }> - /** The root agent to return, or null if not found */ - rootAgent?: { - id: string - version: string - publisher_id: string - data: unknown - } | null - /** Child agents to return for batch queries */ - childAgents?: Array<{ - id: string - version: string - publisher_id: string - data: unknown - }> -} - -/** - * Creates a mock database select function that handles the batch querying pattern: - * 1. First query: fetch ALL publishers (uses .then directly on from()) - * 2. Second query: fetch root agent (with where clause) - * 3. Subsequent queries: batch queries for child agents (with where and possibly orderBy) - * - * This is designed for testing API routes that use the batch querying pattern - * like the agent dependencies route. - * - * @example - * ```ts - * const mockDbSelect = mock(() => ({})) - * mock.module('@codebuff/internal/db', () => ({ default: { select: mockDbSelect } })) - * - * // In test: - * mockDbSelect.mockImplementation(createMockDbSelect({ - * publishers: [{ id: 'test-publisher' }], - * rootAgent: { id: 'test-agent', version: '1.0.0', publisher_id: 'test-publisher', data: {} }, - * })) - * ``` - */ -export function createMockDbSelect(config: MockDbConfig) { - let queryCount = 0 - - return mock(() => ({ - from: mock(() => { - queryCount++ - const isPublisherTable = queryCount === 1 // First query is always publishers - - if (isPublisherTable) { - // Publishers query - returns all publishers directly via .then on from() - return { - where: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => - cb(config.publishers ?? []), - ), - orderBy: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), - limit: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), - })), - })), - })), - then: mock(async (cb: (rows: unknown[]) => unknown) => - cb(config.publishers ?? []), - ), - } - } - - // Agent queries - return { - where: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => { - if (queryCount === 2) { - // Root agent query - return cb(config.rootAgent ? [config.rootAgent] : []) - } - // Batch child agent queries - return cb(config.childAgents ?? []) - }), - orderBy: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => - cb(config.childAgents ?? []), - ), - limit: mock(() => ({ - then: mock(async (cb: (rows: unknown[]) => unknown) => - cb(config.childAgents ?? []), - ), - })), - })), - })), - then: mock(async (cb: (rows: unknown[]) => unknown) => cb([])), - } - }), - })) -} - -/** - * Creates a mock logger for testing API routes. - * All methods are mocked and can be asserted against. - */ -export function createMockLogger(): Logger { - return { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), - } -} - -/** - * Mock schema for the internal database schema. - * Use this with mock.module to mock '@codebuff/internal/db/schema'. - */ -export const mockDbSchema = { - publisher: { id: 'publisher.id' }, - agentConfig: { - id: 'agentConfig.id', - version: 'agentConfig.version', - publisher_id: 'agentConfig.publisher_id', - major: 'agentConfig.major', - minor: 'agentConfig.minor', - patch: 'agentConfig.patch', - data: 'agentConfig.data', - }, -} From 507fd3db21d822230c9e46be08c28e5be3a7c22e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:13:13 -0800 Subject: [PATCH 03/16] docs(testing): add knowledge file for mock-db utilities --- common/src/testing/knowledge.md | 96 +++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 common/src/testing/knowledge.md diff --git a/common/src/testing/knowledge.md b/common/src/testing/knowledge.md new file mode 100644 index 000000000..0b94664b2 --- /dev/null +++ b/common/src/testing/knowledge.md @@ -0,0 +1,96 @@ +# Mock Database Utilities + +Mock database objects for testing. No real database needed. + +## TestableDb + +`TestableDb` is a minimal interface in `@codebuff/common/types/contracts/database`. Both the real `CodebuffPgDatabase` and these mocks satisfy it, so you can pass mocks directly to functions without `as any`. + +## Utilities + +### `createMockDb(config?)` + +API route tests with insert/update/select: + +```ts +import { createMockDb } from '@codebuff/common/testing/mock-db' + +const mockDb = createMockDb({ + insert: { onValues: async (values) => { /* check values */ } }, + update: { onWhere: async () => {} }, + select: { results: [{ id: 'user-123' }] }, +}) + +await postAgentRuns({ db: mockDb, ... }) +``` + +### `createMockDbWithErrors(config)` + +Test error paths: + +```ts +import { createMockDbWithErrors } from '@codebuff/common/testing/mock-db' + +const mockDb = createMockDbWithErrors({ + insertError: new Error('Connection failed'), + selectResults: [{ user_id: 'user-123' }], +}) +``` + +### `createSelectOnlyMockDb(results)` + +Read-only queries (version-utils, etc.): + +```ts +import { createSelectOnlyMockDb } from '@codebuff/common/testing/mock-db' + +const mockDb = createSelectOnlyMockDb([{ major: 1, minor: 2, patch: 3 }]) + +const result = await getLatestAgentVersion({ + agentId: 'test-agent', + publisherId: 'test-publisher', + db: mockDb, +}) +``` + +### `createMockDbSelect(config)` + +Batch queries (agent dependencies route): + +```ts +import { createMockDbSelect, mockDbSchema } from '@codebuff/common/testing/mock-db' + +const mockDbSelect = mock(() => ({})) +mock.module('@codebuff/internal/db', () => ({ default: { select: mockDbSelect } })) + +mockDbSelect.mockImplementation(createMockDbSelect({ + publishers: [{ id: 'test-publisher' }], + rootAgent: { id: 'agent', version: '1.0.0', publisher_id: 'test-publisher', data: {} }, + childAgents: [], +})) +``` + +### `createMockLogger()` + +```ts +import { createMockLogger } from '@codebuff/common/testing/mock-db' + +const mockLogger = createMockLogger() +// error, warn, info, debug are all mocks +``` + +## How to use + +1. Import from `@codebuff/common/testing/mock-db` +2. Create in `beforeEach()` for fresh state +3. Pass to functions that take `TestableDb` + +## Query patterns + +| Pattern | Use | +|---------|---------| +| `db.insert(table).values(data)` | `createMockDb` | +| `db.update(table).set(data).where(cond)` | `createMockDb` | +| `db.select().from().where().limit()` | `createMockDb` | +| `db.select().from().where().orderBy().limit()` | `createSelectOnlyMockDb` | +| Batch queries with counting | `createMockDbSelect` | From 7c1894c9354e5042915588469df80c48205feb16 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:27:30 -0800 Subject: [PATCH 04/16] fix(testing): add Bun/Jest compatibility layer for mock-db Use runtime detection to conditionally load bun:test mock function. Falls back to identity function when running in Jest. --- common/src/testing/mock-db.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts index 1ee6fe3e7..adfb3fdd6 100644 --- a/common/src/testing/mock-db.ts +++ b/common/src/testing/mock-db.ts @@ -1,11 +1,25 @@ -import { mock } from 'bun:test' - import type { TestableDb, TestableDbWhereResult, } from '@codebuff/common/types/contracts/database' import type { Logger } from '@codebuff/common/types/contracts/logger' +// Compatibility layer: use bun:test mock when available, otherwise identity function +// This allows the utilities to work in both Bun and Jest environments +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ +const mock: any>(fn: T) => T = (() => { + if (typeof globalThis.Bun !== 'undefined') { + try { + return require('bun:test').mock + } catch { + // Fall through to identity function + } + } + // Identity function for Jest or when bun:test is not available + return any>(fn: T) => fn +})() +/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ + // ============================================================================ // Types // ============================================================================ From 4717d6d41086b71fc602a610ba435e1cdf4e1ccd Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:33:13 -0800 Subject: [PATCH 05/16] fix(testing): add bun:test stub for Jest module resolution Jest cannot resolve bun:test at parse time even with runtime checks. Add moduleNameMapper to stub bun:test with an identity function. --- web/jest.config.cjs | 1 + web/src/test-stubs/bun-test.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) create mode 100644 web/src/test-stubs/bun-test.ts diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 4f480b279..061dc3b2d 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -15,6 +15,7 @@ const config = { '^@codebuff/internal/xml-parser$': '/src/test-stubs/xml-parser.ts', '^react$': '/node_modules/react', '^react-dom$': '/node_modules/react-dom', + '^bun:test$': '/src/test-stubs/bun-test.ts', }, testPathIgnorePatterns: [ '/src/__tests__/e2e', diff --git a/web/src/test-stubs/bun-test.ts b/web/src/test-stubs/bun-test.ts new file mode 100644 index 000000000..b9ab630d1 --- /dev/null +++ b/web/src/test-stubs/bun-test.ts @@ -0,0 +1,12 @@ +/** + * Stub for bun:test module when running in Jest. + * Provides a mock() function that wraps jest.fn() for compatibility. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function mock any>(fn: T): T { + // In Jest, we can use jest.fn() to create a mock that supports assertions + // But for simplicity, just return the function as-is since the mock-db + // utilities work without spy capabilities in Jest + return fn +} From 3d8acc35a3904aae3a9e55022bbf874614514956 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:40:07 -0800 Subject: [PATCH 06/16] fix(testing): exclude Bun-only tests from Jest test runner Add testPathIgnorePatterns for dependencies test that uses bun:test directly. --- web/jest.config.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 061dc3b2d..853e5c5d7 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -21,6 +21,7 @@ const config = { '/src/__tests__/e2e', '/src/app/api/v1/.*/__tests__', '/src/app/api/agents/publish/__tests__', + '/src/app/api/agents/\\[publisherId\\]/.*/__tests__', ], } From 4bb1046401769d2c8709275f879efa8eb3a4ecbb Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 16:51:03 -0800 Subject: [PATCH 07/16] fix(e2e): wrap test.use() in describe block for proper Playwright scoping Playwright requires test.use() to be called inside a describe block. --- web/src/__tests__/e2e/store-ssr.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.spec.ts index 0bff328b0..3bbedc687 100644 --- a/web/src/__tests__/e2e/store-ssr.spec.ts +++ b/web/src/__tests__/e2e/store-ssr.spec.ts @@ -1,9 +1,10 @@ import { test, expect } from '@playwright/test' -// Disable JS to validate pure SSR HTML -test.use({ javaScriptEnabled: false }) +test.describe('Store SSR', () => { + // Disable JS to validate pure SSR HTML + test.use({ javaScriptEnabled: false }) -test('SSR HTML contains at least one agent card', async ({ page }) => { + test('SSR HTML contains at least one agent card', async ({ page }) => { const agents = [ { id: 'base', @@ -48,4 +49,5 @@ test('SSR HTML contains at least one agent card', async ({ page }) => { // Validate SSR output contains agent content (publisher + id) expect(html).toContain('@codebuff') expect(html).toContain('>base<') + }) }) From c6117806e6a7fa3637aee9f07d719632616b2941 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 17:04:49 -0800 Subject: [PATCH 08/16] refactor(testing): move TestableDb interface to mock-db.ts TestableDb is a testing-specific interface, so it belongs with the mock utilities. --- common/src/testing/mock-db.ts | 50 +++++++++++++++++-- common/src/types/contracts/database.ts | 45 ----------------- packages/internal/src/utils/version-utils.ts | 2 +- .../api/v1/agent-runs/[runId]/steps/_post.ts | 6 +-- web/src/app/api/v1/agent-runs/_post.ts | 6 +-- 5 files changed, 51 insertions(+), 58 deletions(-) diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts index adfb3fdd6..72ba7bd72 100644 --- a/common/src/testing/mock-db.ts +++ b/common/src/testing/mock-db.ts @@ -1,9 +1,51 @@ -import type { - TestableDb, - TestableDbWhereResult, -} from '@codebuff/common/types/contracts/database' import type { Logger } from '@codebuff/common/types/contracts/logger' +// ============================================================================ +// Testable Database Interface +// ============================================================================ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Minimal database interface for dependency injection in API routes. + * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. + * + * This allows tests to provide mock implementations without type casting. + * Uses `any` for table/column parameters to be compatible with Drizzle ORM's + * specific table types while remaining flexible for mocks. + */ +export interface TestableDb { + insert: (table: any) => { + values: (data: any) => PromiseLike + } + update: (table: any) => { + set: (data: any) => { + where: (condition: any) => PromiseLike + } + } + select: (columns?: any) => { + from: (table: any) => { + where: (condition: any) => TestableDbWhereResult + } + } +} + +/** + * Result type for where() that supports multiple query patterns: + * - .limit(n) for simple queries + * - .orderBy(...).limit(n) for sorted queries + * - .then() for promise-like resolution + */ +export interface TestableDbWhereResult { + then: ( + onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, + ) => PromiseLike + limit: (n: number) => PromiseLike + orderBy: (...columns: any[]) => { + limit: (n: number) => PromiseLike + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + // Compatibility layer: use bun:test mock when available, otherwise identity function // This allows the utilities to work in both Bun and Jest environments /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any */ diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index d97f4e339..d37ebdacd 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -93,48 +93,3 @@ export type AddAgentStepFn = (params: { export type DatabaseAgentCache = Map -// ============================================================================ -// Testable Database Interface -// ============================================================================ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/** - * Minimal database interface for dependency injection in API routes. - * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. - * - * This allows tests to provide mock implementations without type casting. - * Uses `any` for table/column parameters to be compatible with Drizzle ORM's - * specific table types while remaining flexible for mocks. - */ -export interface TestableDb { - insert: (table: any) => { - values: (data: any) => PromiseLike - } - update: (table: any) => { - set: (data: any) => { - where: (condition: any) => PromiseLike - } - } - select: (columns?: any) => { - from: (table: any) => { - where: (condition: any) => TestableDbWhereResult - } - } -} - -/** - * Result type for where() that supports multiple query patterns: - * - .limit(n) for simple queries - * - .orderBy(...).limit(n) for sorted queries - * - .then() for promise-like resolution - */ -export interface TestableDbWhereResult { - then: ( - onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, - ) => PromiseLike - limit: (n: number) => PromiseLike - orderBy: (...columns: any[]) => { - limit: (n: number) => PromiseLike - } -} -/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/packages/internal/src/utils/version-utils.ts b/packages/internal/src/utils/version-utils.ts index d1a81f052..b38b4f856 100644 --- a/packages/internal/src/utils/version-utils.ts +++ b/packages/internal/src/utils/version-utils.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from 'drizzle-orm' import * as schema from '@codebuff/internal/db/schema' -import type { TestableDb } from '@codebuff/common/types/contracts/database' +import type { TestableDb } from '@codebuff/common/testing/mock-db' export type Version = { major: number; minor: number; patch: number } diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts index 24bab6de5..c4411ea57 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts @@ -7,10 +7,8 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { - GetUserInfoFromApiKeyFn, - TestableDb, -} from '@codebuff/common/types/contracts/database' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { TestableDb } from '@codebuff/common/testing/mock-db' import type { Logger, LoggerWithContextFn, diff --git a/web/src/app/api/v1/agent-runs/_post.ts b/web/src/app/api/v1/agent-runs/_post.ts index 16d2f7888..2ae6345d8 100644 --- a/web/src/app/api/v1/agent-runs/_post.ts +++ b/web/src/app/api/v1/agent-runs/_post.ts @@ -7,10 +7,8 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { - GetUserInfoFromApiKeyFn, - TestableDb, -} from '@codebuff/common/types/contracts/database' +import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' +import type { TestableDb } from '@codebuff/common/testing/mock-db' import type { Logger, LoggerWithContextFn, From c56774965b1a93f0c1d537b163a73b5d635284dc Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 17:05:44 -0800 Subject: [PATCH 09/16] chore(e2e): rename .spec.ts to .test.ts for consistency --- .../e2e/{store-hydration.spec.ts => store-hydration.test.ts} | 0 web/src/__tests__/e2e/{store-ssr.spec.ts => store-ssr.test.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename web/src/__tests__/e2e/{store-hydration.spec.ts => store-hydration.test.ts} (100%) rename web/src/__tests__/e2e/{store-ssr.spec.ts => store-ssr.test.ts} (100%) diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.test.ts similarity index 100% rename from web/src/__tests__/e2e/store-hydration.spec.ts rename to web/src/__tests__/e2e/store-hydration.test.ts diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.test.ts similarity index 100% rename from web/src/__tests__/e2e/store-ssr.spec.ts rename to web/src/__tests__/e2e/store-ssr.test.ts From 521958dd3c24a2d9db780e13e6f7f49fcde4f7d3 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 17:08:48 -0800 Subject: [PATCH 10/16] refactor(testing): rename TestableDb to DbOperations TestableDb implied it was only for testing, but it is used as a parameter type in production code. DbOperations is more accurate. --- common/src/testing/mock-db.ts | 29 +++++++++---------- packages/internal/src/utils/version-utils.ts | 8 ++--- .../api/v1/agent-runs/[runId]/steps/_post.ts | 4 +-- web/src/app/api/v1/agent-runs/_post.ts | 8 ++--- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts index 72ba7bd72..5d3dc306b 100644 --- a/common/src/testing/mock-db.ts +++ b/common/src/testing/mock-db.ts @@ -1,7 +1,7 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' // ============================================================================ -// Testable Database Interface +// Database Operations Interface // ============================================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -9,11 +9,10 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' * Minimal database interface for dependency injection in API routes. * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. * - * This allows tests to provide mock implementations without type casting. * Uses `any` for table/column parameters to be compatible with Drizzle ORM's * specific table types while remaining flexible for mocks. */ -export interface TestableDb { +export interface DbOperations { insert: (table: any) => { values: (data: any) => PromiseLike } @@ -24,7 +23,7 @@ export interface TestableDb { } select: (columns?: any) => { from: (table: any) => { - where: (condition: any) => TestableDbWhereResult + where: (condition: any) => DbWhereResult } } } @@ -35,7 +34,7 @@ export interface TestableDb { * - .orderBy(...).limit(n) for sorted queries * - .then() for promise-like resolution */ -export interface TestableDbWhereResult { +export interface DbWhereResult { then: ( onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, ) => PromiseLike @@ -364,9 +363,9 @@ export interface MockDbFactoryConfig { /** * Return type of createMockDb - a complete mock database object. - * Implements TestableDb for type-safe dependency injection in tests. + * Implements DbOperations for type-safe dependency injection in tests. */ -export type MockDb = TestableDb +export type MockDb = DbOperations /** * Creates a complete mock database object with insert, update, and select operations. @@ -393,13 +392,13 @@ export type MockDb = TestableDb * }) * ``` */ -export function createMockDb(config: MockDbFactoryConfig = {}): TestableDb { - // Use type assertion since Mock types don't perfectly match TestableDb +export function createMockDb(config: MockDbFactoryConfig = {}): DbOperations { + // Use type assertion since Mock types don't perfectly match DbOperations // but the runtime behavior is correct return { insert: createMockDbInsert(config.insert), update: createMockDbUpdate(config.update), - select: createMockDbSimpleSelect(config.select) as TestableDb['select'], + select: createMockDbSimpleSelect(config.select) as DbOperations['select'], } } @@ -421,10 +420,10 @@ export function createMockDbWithErrors(config: { selectError?: Error /** Results to return from select queries (before any error is thrown) */ selectResults?: unknown[] -} = {}): TestableDb { +} = {}): DbOperations { const { insertError, updateError, selectError, selectResults = [] } = config - // Use type assertion since Mock types don't perfectly match TestableDb + // Use type assertion since Mock types don't perfectly match DbOperations // but the runtime behavior is correct return { insert: mock(() => ({ @@ -466,7 +465,7 @@ export function createMockDbWithErrors(config: { return cb?.(selectResults) ?? selectResults }), })), - })) as TestableDb['select'], + })) as DbOperations['select'], } } @@ -494,8 +493,8 @@ export function createMockDbWithErrors(config: { * }) * ``` */ -export function createSelectOnlyMockDb(selectResults: unknown[]): TestableDb { - const createWhereResult = (): TestableDbWhereResult => ({ +export function createSelectOnlyMockDb(selectResults: unknown[]): DbOperations { + const createWhereResult = (): DbWhereResult => ({ then: ( onfulfilled?: | ((value: unknown[]) => TResult | PromiseLike) diff --git a/packages/internal/src/utils/version-utils.ts b/packages/internal/src/utils/version-utils.ts index b38b4f856..8c0fead7b 100644 --- a/packages/internal/src/utils/version-utils.ts +++ b/packages/internal/src/utils/version-utils.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from 'drizzle-orm' import * as schema from '@codebuff/internal/db/schema' -import type { TestableDb } from '@codebuff/common/testing/mock-db' +import type { DbOperations } from '@codebuff/common/testing/mock-db' export type Version = { major: number; minor: number; patch: number } @@ -54,7 +54,7 @@ export function isGreater(v1: Version, v2: Version): boolean { export async function getLatestAgentVersion(params: { agentId: string publisherId: string - db: TestableDb + db: DbOperations }): Promise { const { agentId, publisherId, db } = params @@ -96,7 +96,7 @@ export async function determineNextVersion(params: { agentId: string publisherId: string providedVersion?: string - db: TestableDb + db: DbOperations }): Promise { const { agentId, publisherId, providedVersion, db } = params @@ -137,7 +137,7 @@ export async function versionExists(params: { agentId: string version: Version publisherId: string - db: TestableDb + db: DbOperations }): Promise { const { agentId, version, publisherId, db } = params diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts index c4411ea57..54984898b 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { TestableDb } from '@codebuff/common/testing/mock-db' +import type { DbOperations } from '@codebuff/common/testing/mock-db' import type { Logger, LoggerWithContextFn, @@ -34,7 +34,7 @@ export async function postAgentRunsSteps(params: { logger: Logger loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn - db: TestableDb + db: DbOperations }) { const { req, diff --git a/web/src/app/api/v1/agent-runs/_post.ts b/web/src/app/api/v1/agent-runs/_post.ts index 2ae6345d8..6d12f1f71 100644 --- a/web/src/app/api/v1/agent-runs/_post.ts +++ b/web/src/app/api/v1/agent-runs/_post.ts @@ -8,7 +8,7 @@ import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { TestableDb } from '@codebuff/common/testing/mock-db' +import type { DbOperations } from '@codebuff/common/testing/mock-db' import type { Logger, LoggerWithContextFn, @@ -43,7 +43,7 @@ async function handleStartAction(params: { userId: string logger: Logger trackEvent: TrackEventFn - db: TestableDb + db: DbOperations }) { const { data, userId, logger, trackEvent, db } = params const { agentId, ancestorRunIds } = data @@ -105,7 +105,7 @@ async function handleFinishAction(params: { userId: string logger: Logger trackEvent: TrackEventFn - db: TestableDb + db: DbOperations }) { const { data, userId, logger, trackEvent, db } = params const { @@ -174,7 +174,7 @@ export async function postAgentRuns(params: { logger: Logger loggerWithContext: LoggerWithContextFn trackEvent: TrackEventFn - db: TestableDb + db: DbOperations }) { const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, db } = params From 71119537730c14fac8f00add71b67c7576fbc320 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 17:41:04 -0800 Subject: [PATCH 11/16] docs(testing): update knowledge.md with DbOperations name --- common/src/testing/knowledge.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/src/testing/knowledge.md b/common/src/testing/knowledge.md index 0b94664b2..4085b1b66 100644 --- a/common/src/testing/knowledge.md +++ b/common/src/testing/knowledge.md @@ -2,9 +2,9 @@ Mock database objects for testing. No real database needed. -## TestableDb +## DbOperations -`TestableDb` is a minimal interface in `@codebuff/common/types/contracts/database`. Both the real `CodebuffPgDatabase` and these mocks satisfy it, so you can pass mocks directly to functions without `as any`. +`DbOperations` is a minimal interface in `@codebuff/common/testing/mock-db`. Both the real `CodebuffPgDatabase` and these mocks satisfy it, so you can pass mocks directly to functions without `as any`. ## Utilities @@ -83,7 +83,7 @@ const mockLogger = createMockLogger() 1. Import from `@codebuff/common/testing/mock-db` 2. Create in `beforeEach()` for fresh state -3. Pass to functions that take `TestableDb` +3. Pass to functions that take `DbOperations` ## Query patterns From ba0d08b5087684a418a129e01a5f80026f715ec6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 18:12:00 -0800 Subject: [PATCH 12/16] refactor(testing): move DbOperations interface to canonical contracts location - Move DbOperations and DbWhereResult from testing/mock-db.ts to types/contracts/database.ts - Update production imports in version-utils.ts, agent-runs/_post.ts, steps/_post.ts - Re-export from mock-db.ts for backwards compatibility with test imports - Fix type error in dependencies/_get.ts by handling version: string | null - Update knowledge.md with proper import guidance --- common/src/testing/knowledge.md | 12 ++++- common/src/testing/mock-db.ts | 50 +++---------------- common/src/types/contracts/database.ts | 45 +++++++++++++++++ packages/internal/src/utils/version-utils.ts | 2 +- .../[agentId]/[version]/dependencies/_get.ts | 7 ++- .../api/v1/agent-runs/[runId]/steps/_post.ts | 6 ++- web/src/app/api/v1/agent-runs/_post.ts | 6 ++- 7 files changed, 77 insertions(+), 51 deletions(-) diff --git a/common/src/testing/knowledge.md b/common/src/testing/knowledge.md index 4085b1b66..77dbccd5f 100644 --- a/common/src/testing/knowledge.md +++ b/common/src/testing/knowledge.md @@ -4,7 +4,17 @@ Mock database objects for testing. No real database needed. ## DbOperations -`DbOperations` is a minimal interface in `@codebuff/common/testing/mock-db`. Both the real `CodebuffPgDatabase` and these mocks satisfy it, so you can pass mocks directly to functions without `as any`. +`DbOperations` is a minimal interface defined in `@codebuff/common/types/contracts/database`. Both the real `CodebuffPgDatabase` and test mocks satisfy it, enabling dependency injection without `as any` casts. + +**Production code** should import from the contracts location: +```ts +import type { DbOperations } from '@codebuff/common/types/contracts/database' +``` + +**Test code** can import from either location (mock-db re-exports the interface for convenience): +```ts +import { createMockDb, type DbOperations } from '@codebuff/common/testing/mock-db' +``` ## Utilities diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts index 5d3dc306b..ee86047c2 100644 --- a/common/src/testing/mock-db.ts +++ b/common/src/testing/mock-db.ts @@ -1,49 +1,11 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { + DbOperations, + DbWhereResult, +} from '@codebuff/common/types/contracts/database' -// ============================================================================ -// Database Operations Interface -// ============================================================================ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -/** - * Minimal database interface for dependency injection in API routes. - * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. - * - * Uses `any` for table/column parameters to be compatible with Drizzle ORM's - * specific table types while remaining flexible for mocks. - */ -export interface DbOperations { - insert: (table: any) => { - values: (data: any) => PromiseLike - } - update: (table: any) => { - set: (data: any) => { - where: (condition: any) => PromiseLike - } - } - select: (columns?: any) => { - from: (table: any) => { - where: (condition: any) => DbWhereResult - } - } -} - -/** - * Result type for where() that supports multiple query patterns: - * - .limit(n) for simple queries - * - .orderBy(...).limit(n) for sorted queries - * - .then() for promise-like resolution - */ -export interface DbWhereResult { - then: ( - onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, - ) => PromiseLike - limit: (n: number) => PromiseLike - orderBy: (...columns: any[]) => { - limit: (n: number) => PromiseLike - } -} -/* eslint-enable @typescript-eslint/no-explicit-any */ +// Re-export database interfaces for backwards compatibility with test imports +export type { DbOperations, DbWhereResult } // Compatibility layer: use bun:test mock when available, otherwise identity function // This allows the utilities to work in both Bun and Jest environments diff --git a/common/src/types/contracts/database.ts b/common/src/types/contracts/database.ts index d37ebdacd..bac09f5b9 100644 --- a/common/src/types/contracts/database.ts +++ b/common/src/types/contracts/database.ts @@ -1,6 +1,51 @@ import type { AgentTemplate } from '@codebuff/common/types/agent-template' import type { Logger } from '@codebuff/common/types/contracts/logger' +// ============================================================================ +// Database Operations Interface +// ============================================================================ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Minimal database interface for dependency injection in API routes. + * Both the real CodebuffPgDatabase and test mocks can satisfy this interface. + * + * Uses `any` for table/column parameters to be compatible with Drizzle ORM's + * specific table types while remaining flexible for mocks. + */ +export interface DbOperations { + insert: (table: any) => { + values: (data: any) => PromiseLike + } + update: (table: any) => { + set: (data: any) => { + where: (condition: any) => PromiseLike + } + } + select: (columns?: any) => { + from: (table: any) => { + where: (condition: any) => DbWhereResult + } + } +} + +/** + * Result type for where() that supports multiple query patterns: + * - .limit(n) for simple queries + * - .orderBy(...).limit(n) for sorted queries + * - .then() for promise-like resolution + */ +export interface DbWhereResult { + then: ( + onfulfilled?: ((value: any[]) => TResult | PromiseLike) | null | undefined, + ) => PromiseLike + limit: (n: number) => PromiseLike + orderBy: (...columns: any[]) => { + limit: (n: number) => PromiseLike + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + type User = { id: string email: string diff --git a/packages/internal/src/utils/version-utils.ts b/packages/internal/src/utils/version-utils.ts index 8c0fead7b..6b0025a53 100644 --- a/packages/internal/src/utils/version-utils.ts +++ b/packages/internal/src/utils/version-utils.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from 'drizzle-orm' import * as schema from '@codebuff/internal/db/schema' -import type { DbOperations } from '@codebuff/common/testing/mock-db' +import type { DbOperations } from '@codebuff/common/types/contracts/database' export type Version = { major: number; minor: number; patch: number } diff --git a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts index 3f488d947..ca741f25e 100644 --- a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts +++ b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts @@ -152,8 +152,13 @@ function createBatchingAgentLookup( return async function lookupAgent( publisher: string, agentId: string, - version: string, + version: string | null, ): Promise { + // Can't look up an agent without a version + if (version === null) { + return null + } + const cacheKey = `${publisher}/${agentId}@${version}` // Return from cache if available diff --git a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts index 54984898b..10da1142d 100644 --- a/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts +++ b/web/src/app/api/v1/agent-runs/[runId]/steps/_post.ts @@ -7,8 +7,10 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { DbOperations } from '@codebuff/common/testing/mock-db' +import type { + DbOperations, + GetUserInfoFromApiKeyFn, +} from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, diff --git a/web/src/app/api/v1/agent-runs/_post.ts b/web/src/app/api/v1/agent-runs/_post.ts index 6d12f1f71..85f08d10b 100644 --- a/web/src/app/api/v1/agent-runs/_post.ts +++ b/web/src/app/api/v1/agent-runs/_post.ts @@ -7,8 +7,10 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' -import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { DbOperations } from '@codebuff/common/testing/mock-db' +import type { + DbOperations, + GetUserInfoFromApiKeyFn, +} from '@codebuff/common/types/contracts/database' import type { Logger, LoggerWithContextFn, From eaa17563a7d36f2e607d3735b6f5a257941762b1 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 18:14:33 -0800 Subject: [PATCH 13/16] fix(web): rename Playwright e2e tests to .e2e.ts extension - Rename store-hydration.test.ts -> store-hydration.e2e.ts - Rename store-ssr.test.ts -> store-ssr.e2e.ts - Update playwright.config.ts to match .e2e.ts files - Add web/bunfig.toml for test runner configuration This prevents Bun test runner from picking up Playwright tests, which should only be run via `bun run e2e` --- web/bunfig.toml | 5 +++++ web/playwright.config.ts | 1 + .../e2e/{store-hydration.test.ts => store-hydration.e2e.ts} | 0 .../__tests__/e2e/{store-ssr.test.ts => store-ssr.e2e.ts} | 0 4 files changed, 6 insertions(+) create mode 100644 web/bunfig.toml rename web/src/__tests__/e2e/{store-hydration.test.ts => store-hydration.e2e.ts} (100%) rename web/src/__tests__/e2e/{store-ssr.test.ts => store-ssr.e2e.ts} (100%) diff --git a/web/bunfig.toml b/web/bunfig.toml new file mode 100644 index 000000000..3133619ed --- /dev/null +++ b/web/bunfig.toml @@ -0,0 +1,5 @@ +[test] +# Exclude Playwright e2e tests from Bun's test runner +# These should be run via `bun run e2e` (which uses Playwright's runner) +# E2E tests use .e2e.ts extension which Bun doesn't pick up by default +root = "./src" diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 6a1c81ea4..11c71f66b 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -6,6 +6,7 @@ const BASE_URL = `http://127.0.0.1:${PORT}` export default defineConfig({ testDir: './src/__tests__/e2e', + testMatch: '**/*.e2e.ts', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, diff --git a/web/src/__tests__/e2e/store-hydration.test.ts b/web/src/__tests__/e2e/store-hydration.e2e.ts similarity index 100% rename from web/src/__tests__/e2e/store-hydration.test.ts rename to web/src/__tests__/e2e/store-hydration.e2e.ts diff --git a/web/src/__tests__/e2e/store-ssr.test.ts b/web/src/__tests__/e2e/store-ssr.e2e.ts similarity index 100% rename from web/src/__tests__/e2e/store-ssr.test.ts rename to web/src/__tests__/e2e/store-ssr.e2e.ts From 6965e1340a92c77b2b230280321b4eae86880929 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 18:27:25 -0800 Subject: [PATCH 14/16] fix(web): remove bunfig.toml that may interfere with CI The .e2e.ts extension is sufficient to exclude Playwright tests from Bun test runner --- web/bunfig.toml | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 web/bunfig.toml diff --git a/web/bunfig.toml b/web/bunfig.toml deleted file mode 100644 index 3133619ed..000000000 --- a/web/bunfig.toml +++ /dev/null @@ -1,5 +0,0 @@ -[test] -# Exclude Playwright e2e tests from Bun's test runner -# These should be run via `bun run e2e` (which uses Playwright's runner) -# E2E tests use .e2e.ts extension which Bun doesn't pick up by default -root = "./src" From 92d99eeae979d2d7ff3c7548f97b6676caa7147f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 18:46:21 -0800 Subject: [PATCH 15/16] fix(web): exclude lib/__tests__ from Jest runner Tests in lib/__tests__ use bun:test mock.module() which is not supported by the Jest stub. These tests should run with bun test only. This fixes CI failure where ban-conditions.test.ts failed due to missing fetch polyfill when Stripe SDK was initialized. --- web/jest.config.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 853e5c5d7..fa9e94355 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -19,6 +19,7 @@ const config = { }, testPathIgnorePatterns: [ '/src/__tests__/e2e', + '/src/lib/__tests__', // Uses bun:test mock.module() - run with bun test '/src/app/api/v1/.*/__tests__', '/src/app/api/agents/publish/__tests__', '/src/app/api/agents/\\[publisherId\\]/.*/__tests__', From c05b2992a52db826101eedf68afabd7c82209b32 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 22:54:38 -0800 Subject: [PATCH 16/16] fix(web): only exclude ban-conditions.test.ts from Jest, not entire lib/__tests__ agent-tree.test.ts uses Jest globals and should run in CI --- web/jest.config.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/jest.config.cjs b/web/jest.config.cjs index fa9e94355..3352a1eda 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -19,7 +19,7 @@ const config = { }, testPathIgnorePatterns: [ '/src/__tests__/e2e', - '/src/lib/__tests__', // Uses bun:test mock.module() - run with bun test + '/src/lib/__tests__/ban-conditions\\.test\\.ts', // Uses bun:test mock.module() - run with bun test '/src/app/api/v1/.*/__tests__', '/src/app/api/agents/publish/__tests__', '/src/app/api/agents/\\[publisherId\\]/.*/__tests__',