diff --git a/common/src/testing/knowledge.md b/common/src/testing/knowledge.md new file mode 100644 index 000000000..77dbccd5f --- /dev/null +++ b/common/src/testing/knowledge.md @@ -0,0 +1,106 @@ +# Mock Database Utilities + +Mock database objects for testing. No real database needed. + +## DbOperations + +`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 + +### `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 `DbOperations` + +## 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` | diff --git a/common/src/testing/mock-db.ts b/common/src/testing/mock-db.ts new file mode 100644 index 000000000..ee86047c2 --- /dev/null +++ b/common/src/testing/mock-db.ts @@ -0,0 +1,498 @@ +import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { + DbOperations, + DbWhereResult, +} from '@codebuff/common/types/contracts/database' + +// 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 +/* 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 +// ============================================================================ + +/** 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 DbOperations for type-safe dependency injection in tests. + */ +export type MockDb = DbOperations + +/** + * 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 = {}): 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 DbOperations['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[] +} = {}): DbOperations { + const { insertError, updateError, selectError, selectResults = [] } = config + + // Use type assertion since Mock types don't perfectly match DbOperations + // 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 DbOperations['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[]): DbOperations { + const createWhereResult = (): DbWhereResult => ({ + 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 c7250c347..24ec84fe5 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 @@ -101,3 +146,4 @@ export type AddAgentStepFn = (params: { }) => Promise export type DatabaseAgentCache = Map + 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..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 { CodebuffPgDatabase } from '../db/types' +import type { DbOperations } 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: DbOperations }): Promise { const { agentId, publisherId, db } = params @@ -96,7 +96,7 @@ export async function determineNextVersion(params: { agentId: string publisherId: string providedVersion?: string - db: CodebuffPgDatabase + 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: CodebuffPgDatabase + db: DbOperations }): Promise { const { agentId, version, publisherId, db } = params diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 160301c4b..d1e5f95ff 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -15,13 +15,15 @@ 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', '/src/__tests__/playwright-runner.test.ts', - '/src/lib/__tests__/ban-conditions.test.ts', + '/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__', ], } diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 453e98417..66f56458b 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', outputDir: '../debug/playwright-results', fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.e2e.ts similarity index 100% rename from web/src/__tests__/e2e/store-hydration.spec.ts rename to web/src/__tests__/e2e/store-hydration.e2e.ts diff --git a/web/src/__tests__/e2e/store-ssr.spec.ts b/web/src/__tests__/e2e/store-ssr.e2e.ts similarity index 100% rename from web/src/__tests__/e2e/store-ssr.spec.ts rename to web/src/__tests__/e2e/store-ssr.e2e.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..46662a17c --- /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 '@codebuff/common/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/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/__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..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,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 { + DbOperations, + GetUserInfoFromApiKeyFn, +} 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: DbOperations }) { 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..85f08d10b 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 { + DbOperations, + GetUserInfoFromApiKeyFn, +} 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: DbOperations }) { 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: DbOperations }) { 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: DbOperations }) { const { req, getUserInfoFromApiKey, loggerWithContext, trackEvent, db } = params 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 +}