From 11f6e439b5446e25a1cce76fec551a590d5a737a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 15 Dec 2025 15:56:53 -0800 Subject: [PATCH 01/17] refactor(tests): replace mockModule with dependency injection - Add typed mock helpers in common/src/testing/fixtures/: - billing.ts: createGrantCreditsDbMock, createOrgBillingDbMock, etc. - fetch.ts: createMockFetch, wrapMockAsFetch, etc. - database.ts: createVersionQueryDbMock, createExistsQueryDbMock - agent-runtime.ts: mockAnalytics, mockBigQuery, mockRandomUUID - index.ts: barrel exports - Refactor billing package to use DI: - Add optional conn parameter to grant-credits, org-billing, credit-delegation - Add injectable function parameters to usage-service - Update all 4 test files to use typed mock helpers - Refactor agent-runtime tests to use shared mock helpers: - Replace inline spyOn calls with mockAnalytics/mockBigQuery/mockRandomUUID - Update 8 test files - Refactor SDK code-search to use DI: - Export SpawnFn type and codeSearchWithSpawn function - Update tests to use typed mock spawn - Refactor CLI tests to use wrapMockAsFetch: - Update codebuff-api, use-usage-query, and integration tests - Add SDK build step to init-worktree.ts for fresh worktree setup - Add barrel export for testing/fixtures in common/package.json Eliminates ~30 mockModule calls and ~50 "as unknown as" type casts. All 100+ affected tests pass. --- .../integration/api-integration.test.ts | 3 +- .../usage-refresh-on-completion.test.ts | 8 +- .../hooks/__tests__/use-usage-query.test.ts | 51 +-- cli/src/utils/__tests__/codebuff-api.test.ts | 45 +-- common/package.json | 6 + common/src/testing/fixtures/agent-runtime.ts | 89 +++++ common/src/testing/fixtures/billing.ts | 317 ++++++++++++++++++ common/src/testing/fixtures/database.ts | 67 ++++ common/src/testing/fixtures/fetch.ts | 214 ++++++++++++ common/src/testing/fixtures/index.ts | 59 ++++ .../src/__tests__/fast-rewrite.test.ts | 27 +- .../src/__tests__/loop-agent-steps.test.ts | 35 +- .../src/__tests__/main-prompt.test.ts | 13 +- .../src/__tests__/n-parameter.test.ts | 16 +- .../src/__tests__/process-file-block.test.ts | 27 +- .../src/__tests__/read-docs-tool.test.ts | 14 +- .../__tests__/run-agent-step-tools.test.ts | 11 +- .../__tests__/run-programmatic-step.test.ts | 16 +- .../src/__tests__/web-search-tool.test.ts | 11 +- .../src/llm-api/__tests__/linkup-api.test.ts | 121 ++----- .../src/__tests__/credit-delegation.test.ts | 163 ++++----- .../src/__tests__/grant-credits.test.ts | 161 +++------ .../billing/src/__tests__/org-billing.test.ts | 107 ++---- .../src/__tests__/usage-service.test.ts | 184 +++++----- packages/billing/src/credit-delegation.ts | 55 ++- packages/billing/src/grant-credits.ts | 75 +++-- packages/billing/src/org-billing.ts | 49 ++- packages/billing/src/usage-service.ts | 56 +++- .../src/utils/__tests__/version-utils.test.ts | 134 +------- scripts/init-worktree.ts | 4 + sdk/src/__tests__/client.test.ts | 4 +- sdk/src/__tests__/code-search.test.ts | 265 ++++++++------- sdk/src/tools/code-search.ts | 58 ++-- 33 files changed, 1514 insertions(+), 951 deletions(-) create mode 100644 common/src/testing/fixtures/billing.ts create mode 100644 common/src/testing/fixtures/database.ts create mode 100644 common/src/testing/fixtures/fetch.ts create mode 100644 common/src/testing/fixtures/index.ts diff --git a/cli/src/__tests__/integration/api-integration.test.ts b/cli/src/__tests__/integration/api-integration.test.ts index f2af505a0..d40fd6271 100644 --- a/cli/src/__tests__/integration/api-integration.test.ts +++ b/cli/src/__tests__/integration/api-integration.test.ts @@ -1,3 +1,4 @@ +import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' import { AuthenticationError, NetworkError, @@ -44,7 +45,7 @@ describe('API Integration', () => { impl: Parameters[0], ): ReturnType => { const fetchMock = mock(impl) - globalThis.fetch = fetchMock as unknown as typeof fetch + globalThis.fetch = wrapMockAsFetch(fetchMock) return fetchMock } diff --git a/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts b/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts index 86d56c872..80366e247 100644 --- a/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts +++ b/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts @@ -1,3 +1,4 @@ +import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' import { QueryClient } from '@tanstack/react-query' import { describe, @@ -52,8 +53,8 @@ describe('Usage Refresh on SDK Completion', () => { ) // Mock successful API response - globalThis.fetch = mock( - async () => + globalThis.fetch = wrapMockAsFetch( + mock(async () => new Response( JSON.stringify({ type: 'usage-response', @@ -63,7 +64,8 @@ describe('Usage Refresh on SDK Completion', () => { }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ), - ) as unknown as typeof fetch + ), + ) }) afterEach(() => { diff --git a/cli/src/hooks/__tests__/use-usage-query.test.ts b/cli/src/hooks/__tests__/use-usage-query.test.ts index 567745ebc..5158e58c9 100644 --- a/cli/src/hooks/__tests__/use-usage-query.test.ts +++ b/cli/src/hooks/__tests__/use-usage-query.test.ts @@ -1,3 +1,4 @@ +import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { @@ -44,13 +45,15 @@ describe('fetchUsageData', () => { next_quota_reset: '2024-02-01T00:00:00.000Z', } - globalThis.fetch = mock( - async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch + globalThis.fetch = wrapMockAsFetch( + mock( + async () => + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) const result = await fetchUsageData({ authToken: 'test-token' }) @@ -58,9 +61,9 @@ describe('fetchUsageData', () => { }) test('should throw error on failed request', async () => { - globalThis.fetch = mock( - async () => new Response('Error', { status: 500 }), - ) as unknown as typeof fetch + globalThis.fetch = wrapMockAsFetch( + mock(async () => new Response('Error', { status: 500 })), + ) const mockLogger = { error: mock(() => {}), warn: mock(() => {}), @@ -127,13 +130,15 @@ describe('useUsageQuery', () => { next_quota_reset: '2024-02-01T00:00:00.000Z', } - globalThis.fetch = mock( - async () => - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) as unknown as typeof fetch + globalThis.fetch = wrapMockAsFetch( + mock( + async () => + new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) const { result } = renderHook(() => useUsageQuery(), { wrapper: createWrapper(), @@ -148,10 +153,8 @@ describe('useUsageQuery', () => { getAuthTokenSpy = spyOn(authModule, 'getAuthToken').mockReturnValue( 'test-token', ) - const fetchMock = mock( - async () => new Response('{}'), - ) as unknown as typeof fetch - globalThis.fetch = fetchMock + const fetchMock = mock(async () => new Response('{}')) + globalThis.fetch = wrapMockAsFetch(fetchMock) const { result } = renderHook(() => useUsageQuery({ enabled: false }), { wrapper: createWrapper(), @@ -167,10 +170,8 @@ describe('useUsageQuery', () => { getAuthTokenSpy = spyOn(authModule, 'getAuthToken').mockReturnValue( undefined, ) - const fetchMock = mock( - async () => new Response('{}'), - ) as unknown as typeof fetch - globalThis.fetch = fetchMock + const fetchMock = mock(async () => new Response('{}')) + globalThis.fetch = wrapMockAsFetch(fetchMock) renderHook(() => useUsageQuery(), { wrapper: createWrapper(), diff --git a/cli/src/utils/__tests__/codebuff-api.test.ts b/cli/src/utils/__tests__/codebuff-api.test.ts index 31be2844d..a1cc914cc 100644 --- a/cli/src/utils/__tests__/codebuff-api.test.ts +++ b/cli/src/utils/__tests__/codebuff-api.test.ts @@ -1,3 +1,4 @@ +import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' import { describe, test, expect, mock, beforeEach } from 'bun:test' import { createCodebuffApiClient } from '../codebuff-api' @@ -39,7 +40,7 @@ describe('createCodebuffApiClient', () => { test('should make GET request with correct URL', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/test', { retry: false }) @@ -52,7 +53,7 @@ describe('createCodebuffApiClient', () => { test('should add query parameters', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/me', { @@ -68,7 +69,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', authToken: 'my-token', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/test', { retry: false }) @@ -86,7 +87,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', authToken: 'my-token', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/test', { includeAuth: false, retry: false }) @@ -103,7 +104,7 @@ describe('createCodebuffApiClient', () => { test('should make POST request with JSON body', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.post('/api/v1/test', { key: 'value' }, { retry: false }) @@ -123,7 +124,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', authToken: 'my-token', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.post( @@ -147,7 +148,7 @@ describe('createCodebuffApiClient', () => { test('should make PUT request with JSON body', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.put('/api/v1/test', { key: 'value' }, { retry: false }) @@ -167,7 +168,7 @@ describe('createCodebuffApiClient', () => { test('should make PATCH request with JSON body', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.patch('/api/v1/test', { key: 'value' }, { retry: false }) @@ -184,7 +185,7 @@ describe('createCodebuffApiClient', () => { test('should make DELETE request without body', async () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.delete('/api/v1/test/123', { retry: false }) @@ -212,7 +213,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockSuccessFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockSuccessFetch), }) const result = await client.get('/api/v1/me', { retry: false }) @@ -236,7 +237,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockErrorFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockErrorFetch), }) const result = await client.get('/api/v1/me', { retry: false }) @@ -249,6 +250,8 @@ describe('createCodebuffApiClient', () => { }) test('should handle non-JSON error responses', async () => { + // This partial Response mock is acceptable - it tests a specific error path + // where json() rejects and we fall back to text() const mockErrorFetch = mock(() => Promise.resolve({ ok: false, @@ -256,12 +259,12 @@ describe('createCodebuffApiClient', () => { statusText: 'Internal Server Error', json: () => Promise.reject(new Error('Not JSON')), text: () => Promise.resolve('Server error occurred'), - } as unknown as Response), + } as Response), ) const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockErrorFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockErrorFetch), }) const result = await client.get('/api/v1/test', { retry: false }) @@ -284,7 +287,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockNoContentFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockNoContentFetch), }) const result = await client.delete('/api/v1/test/123', { retry: false }) @@ -316,7 +319,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockRetryFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockRetryFetch), retry: { maxRetries: 3, initialDelayMs: 10, // Fast for testing @@ -342,7 +345,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockBadRequestFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockBadRequestFetch), retry: { maxRetries: 3, initialDelayMs: 10 }, }) @@ -365,7 +368,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockServerErrorFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockServerErrorFetch), retry: { maxRetries: 3 }, }) @@ -391,7 +394,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockNetworkErrorFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockNetworkErrorFetch), retry: { maxRetries: 3, initialDelayMs: 10 }, }) @@ -419,7 +422,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetchWithSignal as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetchWithSignal), defaultTimeoutMs: 5000, }) @@ -438,7 +441,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockAbortFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockAbortFetch), }) // Should retry on abort errors @@ -453,7 +456,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', authToken: 'my-token', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/test', { diff --git a/common/package.json b/common/package.json index 485378a84..c5cb7f6ce 100644 --- a/common/package.json +++ b/common/package.json @@ -10,6 +10,12 @@ "import": "./src/*.ts", "types": "./src/*.ts", "default": "./src/*.ts" + }, + "./testing/fixtures": { + "bun": "./src/testing/fixtures/index.ts", + "import": "./src/testing/fixtures/index.ts", + "types": "./src/testing/fixtures/index.ts", + "default": "./src/testing/fixtures/index.ts" } }, "scripts": { diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index 3c5531cf5..0d26fc8bc 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -5,6 +5,8 @@ * Do not import from production code. */ +import { spyOn } from 'bun:test' + import type { AgentTemplate } from '../../types/agent-template' import type { AgentRuntimeDeps, @@ -135,3 +137,90 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< apiKey: 'test-api-key', }) + +/** + * Type for the analytics module to be mocked. + * Matches the shape of @codebuff/common/analytics. + */ +type AnalyticsModule = { + initAnalytics: (...args: unknown[]) => void + trackEvent: (...args: unknown[]) => void + flushAnalytics?: (...args: unknown[]) => Promise +} + +/** + * Type for the bigquery module to be mocked. + * Matches the shape of @codebuff/bigquery. + */ +type BigQueryModule = { + insertTrace: (...args: unknown[]) => Promise +} + +/** + * Mocks the analytics module with no-op implementations. + * Call this in beforeEach or beforeAll in tests that use analytics. + * + * @param analyticsModule - The imported analytics module (import * as analytics from '@codebuff/common/analytics') + * + * @example + * ```ts + * import * as analytics from '@codebuff/common/analytics' + * import { mockAnalytics } from '@codebuff/common/testing/fixtures/agent-runtime' + * + * beforeEach(() => { + * mockAnalytics(analytics) + * }) + * ``` + */ +export function mockAnalytics(analyticsModule: AnalyticsModule): void { + spyOn(analyticsModule, 'initAnalytics').mockImplementation(() => {}) + spyOn(analyticsModule, 'trackEvent').mockImplementation(() => {}) + if (analyticsModule.flushAnalytics) { + spyOn(analyticsModule, 'flushAnalytics').mockImplementation(() => + Promise.resolve(), + ) + } +} + +/** + * Mocks the bigquery module with no-op implementations. + * Call this in beforeEach or beforeAll in tests that use bigquery tracing. + * + * @param bigqueryModule - The imported bigquery module (import * as bigquery from '@codebuff/bigquery') + * + * @example + * ```ts + * import * as bigquery from '@codebuff/bigquery' + * import { mockBigQuery } from '@codebuff/common/testing/fixtures/agent-runtime' + * + * beforeEach(() => { + * mockBigQuery(bigquery) + * }) + * ``` + */ +export function mockBigQuery(bigqueryModule: BigQueryModule): void { + spyOn(bigqueryModule, 'insertTrace').mockImplementation(async () => true) +} + +/** + * Mocks the crypto.randomUUID function with a predictable value. + * Useful for tests that need deterministic UUIDs. + * + * @param uuid - The UUID string to return (defaults to a test UUID) + * + * @example + * ```ts + * import { mockRandomUUID } from '@codebuff/common/testing/fixtures/agent-runtime' + * + * beforeEach(() => { + * mockRandomUUID() + * }) + * ``` + */ +export function mockRandomUUID( + uuid: string = 'mock-uuid-0000-0000-0000-000000000000', +): void { + spyOn(crypto, 'randomUUID').mockImplementation( + () => uuid as `${string}-${string}-${string}-${string}-${string}`, + ) +} diff --git a/common/src/testing/fixtures/billing.ts b/common/src/testing/fixtures/billing.ts new file mode 100644 index 000000000..6aad77d9e --- /dev/null +++ b/common/src/testing/fixtures/billing.ts @@ -0,0 +1,317 @@ +/** + * Test-only billing database mock fixtures. + * + * Provides typed mock factories for billing-related database operations. + * These helpers create properly-typed mocks without requiring ugly `as unknown as` casts. + */ + +import type { Logger } from '../../types/contracts/logger' + +// Re-export the test logger for convenience +export { testLogger } from './agent-runtime' + +/** + * Chainable query builder mock - matches Drizzle's query builder pattern + */ +type ChainableQuery = { + from: () => ChainableQuery + where: () => ChainableQuery + orderBy: () => ChainableQuery + limit: () => TResult + innerJoin: () => ChainableQuery + then: (cb: (result: TResult) => TNext) => TNext +} + +function createChainableQuery(result: TResult): ChainableQuery { + const chain: ChainableQuery = { + from: () => chain, + where: () => chain, + orderBy: () => chain, + limit: () => result, + innerJoin: () => chain, + then: (cb) => cb(result), + } + return chain +} + +// ============================================================================ +// Grant Credits Mock (packages/billing/src/grant-credits.ts) +// ============================================================================ + +export interface GrantCreditsMockOptions { + user: { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null + } | null +} + +/** + * Database connection shape for grant-credits module. + * Structurally matches BillingDbConn from grant-credits.ts + */ +export interface GrantCreditsDbConn { + transaction: (callback: (tx: GrantCreditsTx) => Promise) => Promise + select: () => ChainableQuery +} + +export interface GrantCreditsTx { + query: { + user: { + findFirst: () => Promise + } + } + update: () => { set: () => { where: () => Promise } } + insert: () => { values: () => Promise } + select: () => ChainableQuery +} + +/** + * Creates a typed mock database for grant-credits tests. + * + * @example + * ```ts + * const mockDb = createGrantCreditsDbMock({ + * user: { next_quota_reset: futureDate, auto_topup_enabled: true }, + * }) + * + * const result = await triggerMonthlyResetAndGrant({ + * userId: 'user-123', + * logger, + * conn: mockDb, + * }) + * ``` + */ +export function createGrantCreditsDbMock( + options: GrantCreditsMockOptions, +): GrantCreditsDbConn { + const { user } = options + + const createTx = (): GrantCreditsTx => ({ + query: { + user: { + findFirst: async () => user, + }, + }, + update: () => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + }), + insert: () => ({ + values: () => Promise.resolve(), + }), + select: () => createChainableQuery([] as never[]), + }) + + return { + transaction: async (callback) => callback(createTx()), + select: () => createChainableQuery([] as never[]), + } +} + +// ============================================================================ +// Org Billing Mock (packages/billing/src/org-billing.ts) +// ============================================================================ + +export interface OrgBillingGrant { + operation_id: string + user_id: string + organization_id: string + principal: number + balance: number + type: 'organization' + description: string + priority: number + expires_at: Date + created_at: Date +} + +export interface OrgBillingMockOptions { + grants?: OrgBillingGrant[] + insert?: () => { values: () => Promise } + update?: () => { set: () => { where: () => Promise } } +} + +/** + * Database connection shape for org-billing module. + * Structurally matches OrgBillingDbConn from org-billing.ts + */ +export interface OrgBillingDbConn { + select: () => { + from: () => { + where: () => { + orderBy: () => OrgBillingGrant[] + } + } + } + insert: () => { values: () => Promise } + update: () => { set: () => { where: () => Promise } } +} + +/** + * Transaction wrapper function type for org-billing. + */ +export type OrgBillingWithTransactionFn = (params: { + callback: (tx: OrgBillingDbConn) => Promise + context: Record + logger: Logger +}) => Promise + +/** + * Creates a typed mock database for org-billing tests. + * + * @example + * ```ts + * const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + * + * const result = await calculateOrganizationUsageAndBalance({ + * organizationId: 'org-123', + * quotaResetDate: new Date(), + * now: new Date(), + * logger, + * conn: mockDb, + * }) + * ``` + */ +export function createOrgBillingDbMock( + options?: OrgBillingMockOptions, +): OrgBillingDbConn { + const { grants = [], insert, update } = options ?? {} + + return { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => grants, + }), + }), + }), + insert: + insert ?? + (() => ({ + values: () => Promise.resolve(), + })), + update: + update ?? + (() => ({ + set: () => ({ + where: () => Promise.resolve(), + }), + })), + } +} + +/** + * Creates a mock transaction wrapper that immediately calls the callback. + * + * @example + * ```ts + * const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + * const mockWithTransaction = createOrgBillingTransactionMock(mockDb) + * + * await consumeOrganizationCredits({ + * organizationId: 'org-123', + * creditsToConsume: 100, + * logger, + * withTransaction: mockWithTransaction, + * }) + * ``` + */ +export function createOrgBillingTransactionMock( + mockDb: OrgBillingDbConn, +): OrgBillingWithTransactionFn { + return async ({ callback }) => callback(mockDb) +} + +// ============================================================================ +// Credit Delegation Mock (packages/billing/src/credit-delegation.ts) +// ============================================================================ + +export interface UserOrganization { + orgId: string + orgName: string + orgSlug: string +} + +export interface OrgRepo { + repoUrl: string + repoName: string + isActive: boolean +} + +export interface CreditDelegationMockOptions { + userOrganizations?: UserOrganization[] + orgRepos?: OrgRepo[] +} + +/** + * Database connection shape for credit-delegation module. + * Structurally matches CreditDelegationDbConn from credit-delegation.ts + */ +export interface CreditDelegationDbConn { + select: (fields: Record) => { + from: () => { + innerJoin?: () => { + where: () => Promise + } + where: () => Promise + } + } +} + +/** + * Creates a typed mock database for credit-delegation tests. + * The select function inspects the fields to determine which data to return. + * + * @example + * ```ts + * const mockDb = createCreditDelegationDbMock({ + * userOrganizations: [{ orgId: 'org-123', orgName: 'Test Org', orgSlug: 'test-org' }], + * orgRepos: [{ repoUrl: 'https://github.com/test/repo', repoName: 'repo', isActive: true }], + * }) + * + * const result = await findOrganizationForRepository({ + * userId: 'user-123', + * repositoryUrl: 'https://github.com/test/repo', + * logger, + * conn: mockDb, + * }) + * ``` + */ +export function createCreditDelegationDbMock( + options?: CreditDelegationMockOptions, +): CreditDelegationDbConn { + const { userOrganizations = [], orgRepos = [] } = options ?? {} + + return { + select: (fields: Record) => { + // Return user organizations when querying for orgId/orgName fields + if ('orgId' in fields && 'orgName' in fields) { + return { + from: () => ({ + innerJoin: () => ({ + where: () => Promise.resolve(userOrganizations), + }), + where: () => Promise.resolve([] as never[]), + }), + } + } + + // Return org repos when querying for repoUrl field + if ('repoUrl' in fields) { + return { + from: () => ({ + where: () => Promise.resolve(orgRepos), + }), + } + } + + // Default: return empty array + return { + from: () => ({ + where: () => Promise.resolve([] as never[]), + }), + } + }, + } +} diff --git a/common/src/testing/fixtures/database.ts b/common/src/testing/fixtures/database.ts new file mode 100644 index 000000000..cd0282785 --- /dev/null +++ b/common/src/testing/fixtures/database.ts @@ -0,0 +1,67 @@ +/** + * Test-only database mock fixtures. + * + * Provides typed mock factories for Drizzle ORM query patterns. + * These helpers create properly-typed mocks for common query chains. + */ + +import { mock } from 'bun:test' + +/** + * Version data returned from agent config queries. + */ +export type VersionRow = { + major: number | null + minor: number | null + patch: number | null +} + +/** + * Creates a mock database for version queries that use the pattern: + * db.select().from().where().orderBy().limit() + * + * @param result - The array of version rows to return from the query + * @returns A mock database object with the chained query builder pattern + * + * @example + * ```ts + * const mockDb = createVersionQueryDbMock([{ major: 1, minor: 2, patch: 3 }]) + * const result = await getLatestAgentVersion({ db: mockDb, ... }) + * ``` + */ +export function createVersionQueryDbMock(result: T[]) { + return { + select: mock(() => ({ + from: mock(() => ({ + where: mock(() => ({ + orderBy: mock(() => ({ + limit: mock(() => Promise.resolve(result)), + })), + })), + })), + })), + } +} + +/** + * Creates a mock database for existence check queries that use the pattern: + * db.select().from().where() + * + * @param result - The array of rows to return from the query + * @returns A mock database object with the chained query builder pattern + * + * @example + * ```ts + * const mockDb = createExistsQueryDbMock([{ id: 'agent-1' }]) + * const exists = await versionExists({ db: mockDb, ... }) + * ``` + */ +export function createExistsQueryDbMock(result: T[]) { + return { + select: mock(() => ({ + from: mock(() => ({ + where: mock(() => Promise.resolve(result)), + })), + })), + } +} diff --git a/common/src/testing/fixtures/fetch.ts b/common/src/testing/fixtures/fetch.ts new file mode 100644 index 000000000..c6ebcae18 --- /dev/null +++ b/common/src/testing/fixtures/fetch.ts @@ -0,0 +1,214 @@ +/** + * Test-only fetch mock fixtures. + * + * Provides typed mock factories for fetch operations in tests. + * These helpers create properly-typed mocks without requiring ugly `as unknown as` casts. + */ + +import { mock } from 'bun:test' + +/** + * Type alias for the global fetch function. + * Using this ensures our mocks match the exact signature. + */ +export type FetchFn = typeof globalThis.fetch + +/** + * Type for a mock fetch function that can be used in place of globalThis.fetch. + * Includes the mock utilities from bun:test. + */ +export type MockFetchFn = FetchFn & { + mock: { + calls: Array<[RequestInfo | URL, RequestInit | undefined]> + } +} + +/** + * Configuration for creating a mock fetch response. + */ +export interface MockFetchResponseConfig { + /** HTTP status code (default: 200) */ + status?: number + /** HTTP status text (default: 'OK' for 200, 'Internal Server Error' for 500, etc.) */ + statusText?: string + /** Response body as JSON (will be stringified) */ + json?: unknown + /** Response body as string */ + body?: string + /** Response headers */ + headers?: Record +} + +/** + * Default status text for common HTTP status codes. + */ +const DEFAULT_STATUS_TEXT: Record = { + 200: 'OK', + 201: 'Created', + 204: 'No Content', + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', +} + +/** + * Creates a typed mock fetch function that returns a configured Response. + * + * @example + * ```ts + * // JSON response + * const mockFetch = createMockFetch({ + * status: 200, + * json: { answer: 'test', sources: [] }, + * }) + * + * // Error response + * const mockFetch = createMockFetch({ + * status: 500, + * body: 'Internal Server Error', + * }) + * + * // Use in test + * agentRuntimeImpl.fetch = mockFetch + * ``` + */ +export function createMockFetch(config: MockFetchResponseConfig = {}): FetchFn { + const { + status = 200, + statusText = DEFAULT_STATUS_TEXT[status] ?? '', + json, + body, + headers = {}, + } = config + + // Determine response body + const responseBody = json !== undefined ? JSON.stringify(json) : (body ?? '') + + // Add Content-Type header if returning JSON + const responseHeaders = { ...headers } + if (json !== undefined && !responseHeaders['Content-Type']) { + responseHeaders['Content-Type'] = 'application/json' + } + + const mockFn = mock( + (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + return Promise.resolve( + new Response(responseBody, { + status, + statusText, + headers: responseHeaders, + }), + ) + }, + ) + + // Add preconnect stub to match fetch interface + const fetchFn = mockFn as unknown as FetchFn + fetchFn.preconnect = () => {} + + return fetchFn +} + +/** + * Creates a typed mock fetch function that rejects with an error. + * Useful for testing network failure scenarios. + * + * @example + * ```ts + * const mockFetch = createMockFetchError(new Error('Network error')) + * agentRuntimeImpl.fetch = mockFetch + * ``` + */ +export function createMockFetchError(error: Error): FetchFn { + const mockFn = mock( + (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + return Promise.reject(error) + }, + ) + + // Add preconnect stub to match fetch interface + const fetchFn = mockFn as unknown as FetchFn + fetchFn.preconnect = () => {} + + return fetchFn +} + +/** + * Creates a typed mock fetch function with custom implementation. + * For advanced scenarios where you need full control over the mock behavior. + * + * @example + * ```ts + * let callCount = 0 + * const mockFetch = createMockFetchCustom(async (input, init) => { + * callCount++ + * if (callCount === 1) { + * return new Response('First call', { status: 200 }) + * } + * return new Response('Subsequent call', { status: 200 }) + * }) + * ``` + */ +export function createMockFetchCustom( + implementation: (input: RequestInfo | URL, init?: RequestInit) => Promise, +): FetchFn { + const mockFn = mock(implementation) + + // Add preconnect stub to match fetch interface + const fetchFn = mockFn as unknown as FetchFn + fetchFn.preconnect = () => {} + + return fetchFn +} + +/** + * Creates a typed mock fetch that returns a partial Response object. + * Useful for tests that need to mock specific Response methods like json() or text(). + * + * @example + * ```ts + * const mockFetch = createMockFetchPartial({ + * ok: true, + * status: 200, + * json: () => Promise.resolve({ id: 'test' }), + * }) + * ``` + */ +export function createMockFetchPartial( + response: Partial & { ok: boolean; status: number }, +): FetchFn { + const mockFn = mock( + (_input: RequestInfo | URL, _init?: RequestInit): Promise => { + return Promise.resolve(response as Response) + }, + ) + + // Add preconnect stub to match fetch interface + const fetchFn = mockFn as unknown as FetchFn + fetchFn.preconnect = () => {} + + return fetchFn +} + +/** + * Wraps an existing mock function to make it compatible with the fetch type. + * Use this when you need to keep track of mock.calls but want proper typing. + * + * @example + * ```ts + * const mockFn = mock(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) } as Response)) + * const typedFetch = wrapMockAsFetch(mockFn) + * // Now you can use typedFetch without casting and still access mockFn.mock.calls + * ``` + */ +export function wrapMockAsFetch( + mockFn: ReturnType, +): FetchFn { + const fetchFn = mockFn as unknown as FetchFn + fetchFn.preconnect = () => {} + return fetchFn +} diff --git a/common/src/testing/fixtures/index.ts b/common/src/testing/fixtures/index.ts new file mode 100644 index 000000000..723229547 --- /dev/null +++ b/common/src/testing/fixtures/index.ts @@ -0,0 +1,59 @@ +/** + * Test fixtures barrel file. + * + * Re-exports all test fixtures for cleaner imports: + * @example + * ```ts + * import { testLogger, createMockFetch, createGrantCreditsDbMock } from '@codebuff/common/testing/fixtures' + * ``` + */ + +// Agent runtime fixtures +export { + testLogger, + testFetch, + testClientEnv, + testCiEnv, + TEST_AGENT_RUNTIME_IMPL, + mockAnalytics, + mockBigQuery, + mockRandomUUID, +} from './agent-runtime' + +// Billing database mock fixtures +export { + createGrantCreditsDbMock, + createOrgBillingDbMock, + createOrgBillingTransactionMock, + createCreditDelegationDbMock, + type GrantCreditsMockOptions, + type GrantCreditsDbConn, + type GrantCreditsTx, + type OrgBillingGrant, + type OrgBillingMockOptions, + type OrgBillingDbConn, + type OrgBillingWithTransactionFn, + type UserOrganization, + type OrgRepo, + type CreditDelegationMockOptions, + type CreditDelegationDbConn, +} from './billing' + +// Database mock fixtures +export { + createVersionQueryDbMock, + createExistsQueryDbMock, + type VersionRow, +} from './database' + +// Fetch mock fixtures +export { + createMockFetch, + createMockFetchError, + createMockFetchCustom, + createMockFetchPartial, + wrapMockAsFetch, + type FetchFn, + type MockFetchFn, + type MockFetchResponseConfig, +} from './fetch' diff --git a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts index 9dfd6ff70..de2056d6a 100644 --- a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts +++ b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts @@ -2,11 +2,7 @@ import path from 'path' import { TEST_USER_ID } from '@codebuff/common/old-constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { afterAll, beforeEach, describe, expect, it } from 'bun:test' import { createPatch } from 'diff' import { rewriteWithOpenAI } from '../fast-rewrite' @@ -19,30 +15,11 @@ import type { describe.skip('rewriteWithOpenAI', () => { let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps - beforeAll(async () => { - // Mock database interactions - await mockModule('pg-pool', () => ({ - Pool: class { - connect() { - return { - query: () => ({ - rows: [{ id: 'test-user-id' }], - rowCount: 1, - }), - release: () => {}, - } - } - }, - })) - }) - beforeEach(() => { agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL } }) - afterAll(() => { - clearMockedModules() - }) + afterAll(() => {}) it('should correctly integrate edit snippet changes while preserving formatting', async () => { const testDataDir = path.join(__dirname, 'test-data', 'dex-go') diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 2015f8f06..cb01c79b4 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -1,12 +1,14 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' + mockAnalytics, + mockBigQuery, + mockRandomUUID, +} from '@codebuff/common/testing/fixtures/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' +import * as bigquery from '@codebuff/bigquery' import db from '@codebuff/internal/db' import { afterAll, @@ -45,16 +47,16 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => 'localAgentTemplates' | 'agentType' > - beforeAll(async () => { + beforeAll(() => { disableLiveUserInputCheck() - - // Mock bigquery - await mockModule('@codebuff/bigquery', () => ({ - insertTrace: () => {}, - })) }) beforeEach(() => { + // Mock external dependencies + mockBigQuery(bigquery) + mockAnalytics(analytics) + mockRandomUUID() + agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL, sendAction: () => {}, @@ -85,15 +87,6 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => return 'mock-message-id' } - // Mock analytics - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - - // Mock crypto.randomUUID - spyOn(crypto, 'randomUUID').mockImplementation( - () => 'mock-uuid-0000-0000-0000-000000000000' as const, - ) - // Create mock template with programmatic agent mockTemplate = { id: 'test-agent', @@ -150,9 +143,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL } }) - afterAll(() => { - clearMockedModules() - }) + afterAll(() => {}) it('should verify correct STEP behavior - LLM called once after STEP', async () => { // This test verifies that when a programmatic agent yields STEP, diff --git a/packages/agent-runtime/src/__tests__/main-prompt.test.ts b/packages/agent-runtime/src/__tests__/main-prompt.test.ts index ab87fcbe1..f23af5d8a 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -1,6 +1,10 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { AgentTemplateTypes, @@ -101,12 +105,9 @@ describe('mainPrompt', () => { } // Mock analytics and tracing - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) - analytics.initAnalytics(mainPromptBaseParams) // Initialize the mock - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - spyOn(bigquery, 'insertTrace').mockImplementation(() => - Promise.resolve(true), - ) // Return Promise + mockAnalytics(analytics) + mockBigQuery(bigquery) + analytics.initAnalytics(mainPromptBaseParams) // Mock processFileBlock spyOn(processFileBlockModule, 'processFileBlock').mockImplementation( diff --git a/packages/agent-runtime/src/__tests__/n-parameter.test.ts b/packages/agent-runtime/src/__tests__/n-parameter.test.ts index 48239f728..28e7b1472 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -1,5 +1,9 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockRandomUUID, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' @@ -55,16 +59,10 @@ describe('n parameter and GENERATE_N functionality', () => { sendAction: () => {}, } - // Mock analytics - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) + // Mock external dependencies + mockAnalytics(analytics) + mockRandomUUID() analytics.initAnalytics({ logger }) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - - // Mock crypto.randomUUID - spyOn(crypto, 'randomUUID').mockImplementation( - () => - 'mock-uuid-0000-0000-0000-000000000000' as `${string}-${string}-${string}-${string}-${string}`, - ) // Create mock template mockTemplate = { diff --git a/packages/agent-runtime/src/__tests__/process-file-block.test.ts b/packages/agent-runtime/src/__tests__/process-file-block.test.ts index 9c85878cd..5cb00fd4d 100644 --- a/packages/agent-runtime/src/__tests__/process-file-block.test.ts +++ b/packages/agent-runtime/src/__tests__/process-file-block.test.ts @@ -1,11 +1,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' import { cleanMarkdownCodeBlock } from '@codebuff/common/util/file' -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import { beforeEach, describe, expect, it } from 'bun:test' import { applyPatch } from 'diff' import { processFileBlock } from '../process-file-block' @@ -18,27 +14,6 @@ import type { let agentRuntimeImpl: AgentRuntimeDeps & AgentRuntimeScopedDeps describe('processFileBlockModule', () => { - beforeAll(async () => { - // Mock database interactions - await mockModule('pg-pool', () => ({ - Pool: class { - connect() { - return { - query: () => ({ - rows: [{ id: 'test-user-id' }], - rowCount: 1, - }), - release: () => {}, - } - } - }, - })) - }) - - afterAll(() => { - clearMockedModules() - }) - beforeEach(() => { agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL } }) diff --git a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts index 1ed0d8b28..480e07524 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -1,6 +1,10 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { @@ -54,15 +58,9 @@ describe('read_docs tool with researcher agent (via web API facade)', () => { beforeEach(() => { agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL, sendAction: () => {} } - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) + mockAnalytics(analytics) + mockBigQuery(bigquery) analytics.initAnalytics(agentRuntimeImpl) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - spyOn(analytics, 'flushAnalytics').mockImplementation(() => - Promise.resolve(), - ) - spyOn(bigquery, 'insertTrace').mockImplementation(() => - Promise.resolve(true), - ) agentRuntimeImpl.requestFiles = async () => ({}) agentRuntimeImpl.requestOptionalFile = async () => null diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index d7f51fdc3..af8ceaee7 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -1,6 +1,10 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' @@ -76,12 +80,9 @@ describe('runAgentStep - set_output tool', () => { } as any) // Mock analytics and tracing - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) + mockAnalytics(analytics) + mockBigQuery(bigquery) analytics.initAnalytics(agentRuntimeImpl) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - spyOn(bigquery, 'insertTrace').mockImplementation(() => - Promise.resolve(true), - ) agentRuntimeImpl.requestFiles = async ({ filePaths }) => { const results: Record = {} diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index 3d99ccd00..8a54aa95d 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -1,5 +1,9 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockRandomUUID, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { @@ -61,10 +65,10 @@ describe('runProgrammaticStep', () => { sendAction: () => {}, } - // Mock analytics - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) + // Mock external dependencies + mockAnalytics(analytics) + mockRandomUUID() analytics.initAnalytics({ logger }) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) // Mock executeToolCall executeToolCallSpy = spyOn( @@ -72,12 +76,6 @@ describe('runProgrammaticStep', () => { 'executeToolCall', ).mockImplementation(async () => {}) - // Mock crypto.randomUUID - spyOn(crypto, 'randomUUID').mockImplementation( - () => - 'mock-uuid-0000-0000-0000-000000000000' as `${string}-${string}-${string}-${string}-${string}`, - ) - // Create mock template mockTemplate = { id: 'test-agent', diff --git a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts index 34becae01..90535c572 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -1,6 +1,10 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures/agent-runtime' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { success } from '@codebuff/common/util/error' @@ -78,12 +82,9 @@ describe('web_search tool with researcher agent (via web API facade)', () => { } // Mock analytics and tracing - spyOn(analytics, 'initAnalytics').mockImplementation(() => {}) + mockAnalytics(analytics) + mockBigQuery(bigquery) analytics.initAnalytics(runAgentStepBaseParams) - spyOn(analytics, 'trackEvent').mockImplementation(() => {}) - spyOn(bigquery, 'insertTrace').mockImplementation(() => - Promise.resolve(true), - ) // Mock websocket actions runAgentStepBaseParams.requestFiles = async () => ({}) diff --git a/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts b/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts index b5c933d96..64eae2add 100644 --- a/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts +++ b/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts @@ -1,12 +1,11 @@ import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' + createMockFetch, + createMockFetchError, +} from '@codebuff/common/testing/fixtures' import { afterAll, afterEach, - beforeAll, beforeEach, describe, expect, @@ -24,13 +23,6 @@ const testServerEnv = { LINKUP_API_KEY: 'test-api-key' } describe('Linkup API', () => { let agentRuntimeImpl: AgentRuntimeDeps & { serverEnv: typeof testServerEnv } - beforeAll(async () => { - // Mock withTimeout utility - await mockModule('@codebuff/common/util/promise', () => ({ - withTimeout: async (promise: Promise, timeout: number) => promise, - })) - }) - beforeEach(() => { agentRuntimeImpl = { ...TEST_AGENT_RUNTIME_IMPL, @@ -42,9 +34,7 @@ describe('Linkup API', () => { mock.restore() }) - afterAll(() => { - clearMockedModules() - }) + afterAll(() => {}) test('should successfully search with basic query', async () => { const mockResponse = { @@ -60,14 +50,7 @@ describe('Linkup API', () => { ], } - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ json: mockResponse }) const result = await searchWeb({ ...agentRuntimeImpl, @@ -109,14 +92,7 @@ describe('Linkup API', () => { ], } - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ json: mockResponse }) const result = await searchWeb({ ...agentRuntimeImpl, @@ -142,14 +118,10 @@ describe('Linkup API', () => { }) test('should handle API errors gracefully', async () => { - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response('Internal Server Error', { - status: 500, - statusText: 'Internal Server Error', - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ + status: 500, + body: 'Internal Server Error', + }) const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -157,9 +129,7 @@ describe('Linkup API', () => { }) test('should handle network errors', async () => { - agentRuntimeImpl.fetch = mock(() => { - return Promise.reject(new Error('Network error')) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetchError(new Error('Network error')) const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -167,14 +137,7 @@ describe('Linkup API', () => { }) test('should handle invalid response format', async () => { - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify({ invalid: 'format' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ json: { invalid: 'format' } }) const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -182,14 +145,7 @@ describe('Linkup API', () => { }) test('should handle missing answer field', async () => { - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify({ sources: [] }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ json: { sources: [] } }) const result = await searchWeb({ ...agentRuntimeImpl, @@ -199,19 +155,9 @@ describe('Linkup API', () => { expect(result).toBeNull() }) test('should handle empty answer', async () => { - const mockResponse = { - answer: '', - sources: [], - } - - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ + json: { answer: '', sources: [] }, + }) const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -226,14 +172,7 @@ describe('Linkup API', () => { ], } - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(JSON.stringify(mockResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ json: mockResponse }) await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -251,14 +190,10 @@ describe('Linkup API', () => { }) test('should handle malformed JSON response', async () => { - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response('invalid json{', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ + body: 'invalid json{', + headers: { 'Content-Type': 'application/json' }, + }) agentRuntimeImpl.logger.error = mock(() => {}) const result = await searchWeb({ ...agentRuntimeImpl, query: 'test query' }) @@ -271,15 +206,11 @@ describe('Linkup API', () => { test('should log detailed error information for 404 responses', async () => { const mockErrorResponse = 'Not Found - The requested endpoint does not exist' - agentRuntimeImpl.fetch = mock(() => { - return Promise.resolve( - new Response(mockErrorResponse, { - status: 404, - statusText: 'Not Found', - headers: { 'Content-Type': 'text/plain' }, - }), - ) - }) as unknown as typeof global.fetch + agentRuntimeImpl.fetch = createMockFetch({ + status: 404, + body: mockErrorResponse, + headers: { 'Content-Type': 'text/plain' }, + }) const result = await searchWeb({ ...agentRuntimeImpl, diff --git a/packages/billing/src/__tests__/credit-delegation.test.ts b/packages/billing/src/__tests__/credit-delegation.test.ts index 7517c0ec6..96579c9f4 100644 --- a/packages/billing/src/__tests__/credit-delegation.test.ts +++ b/packages/billing/src/__tests__/credit-delegation.test.ts @@ -1,8 +1,9 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' + import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test' + createCreditDelegationDbMock, + testLogger, +} from '@codebuff/common/testing/fixtures' import { consumeCreditsWithDelegation, @@ -10,82 +11,31 @@ import { } from '../credit-delegation' describe('Credit Delegation', () => { - const logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } - - beforeAll(async () => { - // Mock the org-billing functions that credit-delegation depends on - await mockModule('@codebuff/billing/org-billing', () => ({ - normalizeRepositoryUrl: mock((url: string) => url.toLowerCase().trim()), - extractOwnerAndRepo: mock((url: string) => { - if (url.includes('codebuffai/codebuff')) { - return { owner: 'codebuffai', repo: 'codebuff' } - } - return null - }), - consumeOrganizationCredits: mock(() => Promise.resolve()), - })) - - // Mock common dependencies - await mockModule('@codebuff/internal/db', () => { - const select = mock((fields: Record) => { - if ('orgId' in fields && 'orgName' in fields) { - return { - from: () => ({ - innerJoin: () => ({ - where: () => - Promise.resolve([ - { - orgId: 'org-123', - orgName: 'CodebuffAI', - orgSlug: 'codebuffai', - }, - ]), - }), - }), - } - } - - if ('repoUrl' in fields) { - return { - from: () => ({ - where: () => - Promise.resolve([ - { - repoUrl: 'https://github.com/codebuffai/codebuff', - repoName: 'codebuff', - isActive: true, - }, - ]), - }), - } - } - - return { - from: () => ({ - where: () => Promise.resolve([]), - }), - } - }) - - return { - default: { - select, - }, - } - }) - }) + const logger = testLogger - afterAll(() => { - clearMockedModules() + afterEach(() => { + mock.restore() }) describe('findOrganizationForRepository', () => { it('should find organization for matching repository', async () => { + const mockDb = createCreditDelegationDbMock({ + userOrganizations: [ + { + orgId: 'org-123', + orgName: 'CodebuffAI', + orgSlug: 'codebuffai', + }, + ], + orgRepos: [ + { + repoUrl: 'https://github.com/codebuffai/codebuff', + repoName: 'codebuff', + isActive: true, + }, + ], + }) + const userId = 'user-123' const repositoryUrl = 'https://github.com/codebuffai/codebuff' @@ -93,6 +43,7 @@ describe('Credit Delegation', () => { userId, repositoryUrl, logger, + conn: mockDb, }) expect(result.found).toBe(true) @@ -101,6 +52,23 @@ describe('Credit Delegation', () => { }) it('should return not found for non-matching repository', async () => { + const mockDb = createCreditDelegationDbMock({ + userOrganizations: [ + { + orgId: 'org-123', + orgName: 'CodebuffAI', + orgSlug: 'codebuffai', + }, + ], + orgRepos: [ + { + repoUrl: 'https://github.com/codebuffai/codebuff', + repoName: 'codebuff', + isActive: true, + }, + ], + }) + const userId = 'user-123' const repositoryUrl = 'https://github.com/other/repo' @@ -108,6 +76,26 @@ describe('Credit Delegation', () => { userId, repositoryUrl, logger, + conn: mockDb, + }) + + expect(result.found).toBe(false) + }) + + it('should return not found when user has no organizations', async () => { + const mockDb = createCreditDelegationDbMock({ + userOrganizations: [], + orgRepos: [], + }) + + const userId = 'user-123' + const repositoryUrl = 'https://github.com/some/repo' + + const result = await findOrganizationForRepository({ + userId, + repositoryUrl, + logger, + conn: mockDb, }) expect(result.found).toBe(false) @@ -116,6 +104,8 @@ describe('Credit Delegation', () => { describe('consumeCreditsWithDelegation', () => { it('should fail when no repository URL provided', async () => { + const mockDb = createCreditDelegationDbMock() + const userId = 'user-123' const repositoryUrl = null const creditsToConsume = 100 @@ -125,10 +115,33 @@ describe('Credit Delegation', () => { repositoryUrl, creditsToConsume, logger, + conn: mockDb, }) expect(result.success).toBe(false) expect(result.error).toBe('No repository URL provided') }) + + it('should fail when no organization found for repository', async () => { + const mockDb = createCreditDelegationDbMock({ + userOrganizations: [], + orgRepos: [], + }) + + const userId = 'user-123' + const repositoryUrl = 'https://github.com/other/repo' + const creditsToConsume = 100 + + const result = await consumeCreditsWithDelegation({ + userId, + repositoryUrl, + creditsToConsume, + logger, + conn: mockDb, + }) + + expect(result.success).toBe(false) + expect(result.error).toBe('No organization found for repository') + }) }) }) diff --git a/packages/billing/src/__tests__/grant-credits.test.ts b/packages/billing/src/__tests__/grant-credits.test.ts index 65db57f45..3a2849756 100644 --- a/packages/billing/src/__tests__/grant-credits.test.ts +++ b/packages/billing/src/__tests__/grant-credits.test.ts @@ -1,95 +1,36 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' + import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { afterEach, describe, expect, it } from 'bun:test' + createGrantCreditsDbMock, + testLogger, +} from '@codebuff/common/testing/fixtures' import { triggerMonthlyResetAndGrant } from '../grant-credits' -import type { Logger } from '@codebuff/common/types/contracts/logger' - -const logger: Logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, -} +const logger = testLogger const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now -const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago - -const createDbMock = (options: { - user: { - next_quota_reset: Date | null - auto_topup_enabled: boolean | null - } | null -}) => { - const { user } = options - - return { - transaction: async (callback: (tx: any) => Promise) => { - const tx = { - query: { - user: { - findFirst: async () => user, - }, - }, - update: () => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - }), - insert: () => ({ - values: () => Promise.resolve(), - }), - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: () => [], - }), - }), - then: (cb: any) => cb([]), - }), - }), - } - return callback(tx) - }, - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => ({ - limit: () => [], - }), - }), - }), - }), - } -} +// pastDate removed - unused describe('grant-credits', () => { afterEach(() => { - clearMockedModules() + mock.restore() }) describe('triggerMonthlyResetAndGrant', () => { describe('autoTopupEnabled return value', () => { it('should return autoTopupEnabled: true when user has auto_topup_enabled: true', async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - user: { - next_quota_reset: futureDate, - auto_topup_enabled: true, - }, - }), - })) - - // Need to re-import after mocking - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const mockDb = createGrantCreditsDbMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: true, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + conn: mockDb, }) expect(result.autoTopupEnabled).toBe(true) @@ -97,58 +38,49 @@ describe('grant-credits', () => { }) it('should return autoTopupEnabled: false when user has auto_topup_enabled: false', async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - user: { - next_quota_reset: futureDate, - auto_topup_enabled: false, - }, - }), - })) - - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const mockDb = createGrantCreditsDbMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: false, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + conn: mockDb, }) expect(result.autoTopupEnabled).toBe(false) }) it('should default autoTopupEnabled to false when user has auto_topup_enabled: null', async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - user: { - next_quota_reset: futureDate, - auto_topup_enabled: null, - }, - }), - })) - - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const mockDb = createGrantCreditsDbMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: null, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + conn: mockDb, }) expect(result.autoTopupEnabled).toBe(false) }) it('should throw error when user is not found', async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - user: null, - }), - })) - - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const mockDb = createGrantCreditsDbMock({ + user: null, + }) await expect( - fn({ + triggerMonthlyResetAndGrant({ userId: 'nonexistent-user', logger, + conn: mockDb, }), ).rejects.toThrow('User nonexistent-user not found') }) @@ -156,20 +88,17 @@ describe('grant-credits', () => { describe('quota reset behavior', () => { it('should return existing reset date when it is in the future', async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - user: { - next_quota_reset: futureDate, - auto_topup_enabled: false, - }, - }), - })) - - const { triggerMonthlyResetAndGrant: fn } = await import('../grant-credits') + const mockDb = createGrantCreditsDbMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: false, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + conn: mockDb, }) expect(result.quotaResetDate).toEqual(futureDate) diff --git a/packages/billing/src/__tests__/org-billing.test.ts b/packages/billing/src/__tests__/org-billing.test.ts index 990fd676e..3f4ac8f54 100644 --- a/packages/billing/src/__tests__/org-billing.test.ts +++ b/packages/billing/src/__tests__/org-billing.test.ts @@ -1,8 +1,11 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test' + import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + createOrgBillingDbMock, + createOrgBillingTransactionMock, + testLogger, + type OrgBillingGrant, +} from '@codebuff/common/testing/fixtures' import { calculateOrganizationUsageAndBalance, @@ -12,17 +15,15 @@ import { validateAndNormalizeRepositoryUrl, } from '../org-billing' -import type { Logger } from '@codebuff/common/types/contracts/logger' - // Mock the database -const mockGrants = [ +const mockGrants: OrgBillingGrant[] = [ { operation_id: 'org-grant-1', user_id: '', organization_id: 'org-123', principal: 1000, balance: 800, - type: 'organization' as const, + type: 'organization', description: 'Organization credits', priority: 60, expires_at: new Date('2024-12-31'), @@ -34,7 +35,7 @@ const mockGrants = [ organization_id: 'org-123', principal: 500, balance: -100, // Debt - type: 'organization' as const, + type: 'organization', description: 'Organization credits with debt', priority: 60, expires_at: new Date('2024-11-30'), @@ -42,63 +43,16 @@ const mockGrants = [ }, ] -const logger: Logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, -} - -const createDbMock = (options?: { - grants?: typeof mockGrants | any[] - insert?: () => { values: () => Promise } - update?: () => { set: () => { where: () => Promise } } -}) => { - const { grants = mockGrants, insert, update } = options ?? {} - - return { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => grants, - }), - }), - }), - insert: - insert ?? - (() => ({ - values: () => Promise.resolve(), - })), - update: - update ?? - (() => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - })), - } -} +const logger = testLogger describe('Organization Billing', () => { - beforeEach(async () => { - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock(), - })) - await mockModule('@codebuff/internal/db/transaction', () => ({ - withSerializableTransaction: async ({ - callback, - }: { - callback: (tx: any) => Promise | unknown - }) => await callback(createDbMock()), - })) - }) - afterEach(() => { - clearMockedModules() + mock.restore() }) describe('calculateOrganizationUsageAndBalance', () => { it('should calculate balance correctly with positive and negative balances', async () => { + const mockDb = createOrgBillingDbMock({ grants: mockGrants }) const organizationId = 'org-123' const quotaResetDate = new Date('2024-01-01') const now = new Date('2024-06-01') @@ -108,6 +62,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, + conn: mockDb, }) // Total positive balance: 800 @@ -123,9 +78,7 @@ describe('Organization Billing', () => { it('should handle organization with no grants', async () => { // Mock empty grants - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ grants: [] }), - })) + const mockDb = createOrgBillingDbMock({ grants: [] }) const organizationId = 'org-empty' const quotaResetDate = new Date('2024-01-01') @@ -136,6 +89,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, + conn: mockDb, }) expect(result.balance.totalRemaining).toBe(0) @@ -214,6 +168,9 @@ describe('Organization Billing', () => { describe('consumeOrganizationCredits', () => { it('should consume credits from organization grants', async () => { + const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + const mockWithTransaction = createOrgBillingTransactionMock(mockDb) + const organizationId = 'org-123' const creditsToConsume = 100 @@ -221,6 +178,7 @@ describe('Organization Billing', () => { organizationId, creditsToConsume, logger, + withTransaction: mockWithTransaction, }) expect(result.consumed).toBe(100) @@ -230,6 +188,8 @@ describe('Organization Billing', () => { describe('grantOrganizationCredits', () => { it('should create organization credit grant', async () => { + const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + const organizationId = 'org-123' const userId = 'user-123' const amount = 1000 @@ -245,24 +205,24 @@ describe('Organization Billing', () => { operationId, description, logger, + conn: mockDb, }), ).resolves.toBeUndefined() }) it('should handle duplicate operation IDs gracefully', async () => { // Mock database constraint error - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ - insert: () => ({ - values: () => { - const error = new Error('Duplicate key') - ;(error as any).code = '23505' - ;(error as any).constraint = 'credit_ledger_pkey' - throw error - }, - }), + const mockDb = createOrgBillingDbMock({ + grants: mockGrants, + insert: () => ({ + values: () => { + const error = new Error('Duplicate key') + ;(error as any).code = '23505' + ;(error as any).constraint = 'credit_ledger_pkey' + throw error + }, }), - })) + }) const organizationId = 'org-123' const userId = 'user-123' @@ -279,6 +239,7 @@ describe('Organization Billing', () => { operationId, description, logger, + conn: mockDb, }), ).resolves.toBeUndefined() }) diff --git a/packages/billing/src/__tests__/usage-service.test.ts b/packages/billing/src/__tests__/usage-service.test.ts index 89603b1ff..e0addbdd8 100644 --- a/packages/billing/src/__tests__/usage-service.test.ts +++ b/packages/billing/src/__tests__/usage-service.test.ts @@ -1,10 +1,13 @@ -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { afterEach, describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it, mock } from 'bun:test' + +import { getUserUsageData } from '../usage-service' import type { Logger } from '@codebuff/common/types/contracts/logger' +import type { + TriggerMonthlyResetFn, + CheckAutoTopupFn, + CalculateUsageBalanceFn, +} from '../usage-service' const logger: Logger = { debug: () => {}, @@ -25,35 +28,30 @@ const mockBalance = { describe('usage-service', () => { afterEach(() => { - clearMockedModules() + mock.restore() }) describe('getUserUsageData', () => { describe('autoTopupEnabled field', () => { it('should include autoTopupEnabled: true when triggerMonthlyResetAndGrant returns true', async () => { - await mockModule('@codebuff/billing/grant-credits', () => ({ - triggerMonthlyResetAndGrant: async () => ({ - quotaResetDate: futureDate, - autoTopupEnabled: true, - }), - })) - - await mockModule('@codebuff/billing/auto-topup', () => ({ - checkAndTriggerAutoTopup: async () => undefined, - })) - - await mockModule('@codebuff/billing/balance-calculator', () => ({ - calculateUsageAndBalance: async () => ({ - usageThisCycle: 100, - balance: mockBalance, - }), - })) - - const { getUserUsageData } = await import('@codebuff/billing/usage-service') + const mockTriggerMonthlyReset: TriggerMonthlyResetFn = async () => ({ + quotaResetDate: futureDate, + autoTopupEnabled: true, + }) + + const mockCheckAutoTopup: CheckAutoTopupFn = async () => undefined + + const mockCalculateUsageBalance: CalculateUsageBalanceFn = async () => ({ + usageThisCycle: 100, + balance: mockBalance, + }) const result = await getUserUsageData({ userId: 'user-123', logger, + triggerMonthlyReset: mockTriggerMonthlyReset, + checkAutoTopup: mockCheckAutoTopup, + calculateUsageBalance: mockCalculateUsageBalance, }) expect(result.autoTopupEnabled).toBe(true) @@ -63,58 +61,48 @@ describe('usage-service', () => { }) it('should include autoTopupEnabled: false when triggerMonthlyResetAndGrant returns false', async () => { - await mockModule('@codebuff/billing/grant-credits', () => ({ - triggerMonthlyResetAndGrant: async () => ({ - quotaResetDate: futureDate, - autoTopupEnabled: false, - }), - })) - - await mockModule('@codebuff/billing/auto-topup', () => ({ - checkAndTriggerAutoTopup: async () => undefined, - })) - - await mockModule('@codebuff/billing/balance-calculator', () => ({ - calculateUsageAndBalance: async () => ({ - usageThisCycle: 100, - balance: mockBalance, - }), - })) - - const { getUserUsageData } = await import('@codebuff/billing/usage-service') + const mockTriggerMonthlyReset: TriggerMonthlyResetFn = async () => ({ + quotaResetDate: futureDate, + autoTopupEnabled: false, + }) + + const mockCheckAutoTopup: CheckAutoTopupFn = async () => undefined + + const mockCalculateUsageBalance: CalculateUsageBalanceFn = async () => ({ + usageThisCycle: 100, + balance: mockBalance, + }) const result = await getUserUsageData({ userId: 'user-123', logger, + triggerMonthlyReset: mockTriggerMonthlyReset, + checkAutoTopup: mockCheckAutoTopup, + calculateUsageBalance: mockCalculateUsageBalance, }) expect(result.autoTopupEnabled).toBe(false) }) it('should include autoTopupTriggered: true when auto top-up was triggered', async () => { - await mockModule('@codebuff/billing/grant-credits', () => ({ - triggerMonthlyResetAndGrant: async () => ({ - quotaResetDate: futureDate, - autoTopupEnabled: true, - }), - })) - - await mockModule('@codebuff/billing/auto-topup', () => ({ - checkAndTriggerAutoTopup: async () => 500, // Returns amount when triggered - })) - - await mockModule('@codebuff/billing/balance-calculator', () => ({ - calculateUsageAndBalance: async () => ({ - usageThisCycle: 100, - balance: mockBalance, - }), - })) - - const { getUserUsageData } = await import('@codebuff/billing/usage-service') + const mockTriggerMonthlyReset: TriggerMonthlyResetFn = async () => ({ + quotaResetDate: futureDate, + autoTopupEnabled: true, + }) + + const mockCheckAutoTopup: CheckAutoTopupFn = async () => 500 // Returns amount when triggered + + const mockCalculateUsageBalance: CalculateUsageBalanceFn = async () => ({ + usageThisCycle: 100, + balance: mockBalance, + }) const result = await getUserUsageData({ userId: 'user-123', logger, + triggerMonthlyReset: mockTriggerMonthlyReset, + checkAutoTopup: mockCheckAutoTopup, + calculateUsageBalance: mockCalculateUsageBalance, }) expect(result.autoTopupTriggered).toBe(true) @@ -122,61 +110,51 @@ describe('usage-service', () => { }) it('should include autoTopupTriggered: false when auto top-up was not triggered', async () => { - await mockModule('@codebuff/billing/grant-credits', () => ({ - triggerMonthlyResetAndGrant: async () => ({ - quotaResetDate: futureDate, - autoTopupEnabled: true, - }), - })) - - await mockModule('@codebuff/billing/auto-topup', () => ({ - checkAndTriggerAutoTopup: async () => undefined, // Returns undefined when not triggered - })) - - await mockModule('@codebuff/billing/balance-calculator', () => ({ - calculateUsageAndBalance: async () => ({ - usageThisCycle: 100, - balance: mockBalance, - }), - })) - - const { getUserUsageData } = await import('@codebuff/billing/usage-service') + const mockTriggerMonthlyReset: TriggerMonthlyResetFn = async () => ({ + quotaResetDate: futureDate, + autoTopupEnabled: true, + }) + + const mockCheckAutoTopup: CheckAutoTopupFn = async () => undefined // Returns undefined when not triggered + + const mockCalculateUsageBalance: CalculateUsageBalanceFn = async () => ({ + usageThisCycle: 100, + balance: mockBalance, + }) const result = await getUserUsageData({ userId: 'user-123', logger, + triggerMonthlyReset: mockTriggerMonthlyReset, + checkAutoTopup: mockCheckAutoTopup, + calculateUsageBalance: mockCalculateUsageBalance, }) expect(result.autoTopupTriggered).toBe(false) }) it('should continue and return data even when auto top-up check fails', async () => { - await mockModule('@codebuff/billing/grant-credits', () => ({ - triggerMonthlyResetAndGrant: async () => ({ - quotaResetDate: futureDate, - autoTopupEnabled: true, - }), - })) - - await mockModule('@codebuff/billing/auto-topup', () => ({ - checkAndTriggerAutoTopup: async () => { - throw new Error('Payment failed') - }, - })) - - await mockModule('@codebuff/billing/balance-calculator', () => ({ - calculateUsageAndBalance: async () => ({ - usageThisCycle: 100, - balance: mockBalance, - }), - })) - - const { getUserUsageData } = await import('@codebuff/billing/usage-service') + const mockTriggerMonthlyReset: TriggerMonthlyResetFn = async () => ({ + quotaResetDate: futureDate, + autoTopupEnabled: true, + }) + + const mockCheckAutoTopup: CheckAutoTopupFn = async () => { + throw new Error('Payment failed') + } + + const mockCalculateUsageBalance: CalculateUsageBalanceFn = async () => ({ + usageThisCycle: 100, + balance: mockBalance, + }) // Should not throw const result = await getUserUsageData({ userId: 'user-123', logger, + triggerMonthlyReset: mockTriggerMonthlyReset, + checkAutoTopup: mockCheckAutoTopup, + calculateUsageBalance: mockCalculateUsageBalance, }) expect(result.autoTopupTriggered).toBe(false) diff --git a/packages/billing/src/credit-delegation.ts b/packages/billing/src/credit-delegation.ts index 5f1fa32b0..d174c6b2f 100644 --- a/packages/billing/src/credit-delegation.ts +++ b/packages/billing/src/credit-delegation.ts @@ -1,3 +1,4 @@ +import type { OptionalFields } from '@codebuff/common/types/function-params' import { failure, success } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -10,6 +11,12 @@ import { extractOwnerAndRepo, } from './org-billing' +// Minimal structural type for database connection +// This type is intentionally permissive to allow mock injection in tests +export type CreditDelegationDbConn = { + select: (fields?: any) => any +} + import type { ConsumeCreditsWithFallbackFn } from '@codebuff/common/types/contracts/billing' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ParamsOf } from '@codebuff/common/types/function-params' @@ -33,12 +40,19 @@ export interface CreditDelegationResult { * Finds the organization associated with a repository for a given user. * Uses owner/repo comparison for better matching. */ -export async function findOrganizationForRepository(params: { - userId: string - repositoryUrl: string - logger: Logger -}): Promise { - const { userId, repositoryUrl, logger } = params +export async function findOrganizationForRepository( + params: OptionalFields< + { + userId: string + repositoryUrl: string + logger: Logger + conn: CreditDelegationDbConn + }, + 'conn' + >, +): Promise { + const { conn = db, ...rest } = params + const { userId, repositoryUrl, logger } = rest try { const normalizedUrl = normalizeRepositoryUrl(repositoryUrl) @@ -53,7 +67,7 @@ export async function findOrganizationForRepository(params: { } // First, check if user is a member of any organizations - const userOrganizations = await db + const userOrganizations = await conn .select({ orgId: schema.orgMember.org_id, orgName: schema.org.name, @@ -73,7 +87,7 @@ export async function findOrganizationForRepository(params: { // Check each organization for matching repositories for (const userOrg of userOrganizations) { - const orgRepos = await db + const orgRepos = await conn .select({ repoUrl: schema.orgRepo.repo_url, repoName: schema.orgRepo.repo_name, @@ -142,13 +156,20 @@ export async function findOrganizationForRepository(params: { /** * Consumes credits with organization delegation if applicable. */ -export async function consumeCreditsWithDelegation(params: { - userId: string - repositoryUrl: string | null - creditsToConsume: number - logger: Logger -}): Promise { - const { userId, repositoryUrl, creditsToConsume, logger } = params +export async function consumeCreditsWithDelegation( + params: OptionalFields< + { + userId: string + repositoryUrl: string | null + creditsToConsume: number + logger: Logger + conn: CreditDelegationDbConn + }, + 'conn' + >, +): Promise { + const { conn = db, ...rest } = params + const { userId, repositoryUrl, creditsToConsume, logger } = rest // If no repository URL, fall back to personal credits if (!repositoryUrl) { @@ -159,7 +180,7 @@ export async function consumeCreditsWithDelegation(params: { return { success: false, error: 'No repository URL provided' } } - const withRepoUrl = { ...params, repositoryUrl } + const withRepoUrl = { ...rest, repositoryUrl, conn } try { // Find organization for this repository @@ -176,7 +197,7 @@ export async function consumeCreditsWithDelegation(params: { // Consume credits from organization try { await consumeOrganizationCredits({ - ...params, + ...rest, organizationId: orgLookup.organizationId, }) diff --git a/packages/billing/src/grant-credits.ts b/packages/billing/src/grant-credits.ts index 3e89f93fc..2de3055b4 100644 --- a/packages/billing/src/grant-credits.ts +++ b/packages/billing/src/grant-credits.ts @@ -2,6 +2,7 @@ import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { GRANT_PRIORITIES } from '@codebuff/common/constants/grant-priorities' import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' +import type { OptionalFields } from '@codebuff/common/types/function-params' import { getNextQuotaReset } from '@codebuff/common/util/dates' import { withRetry } from '@codebuff/common/util/promise' import db from '@codebuff/internal/db' @@ -21,6 +22,13 @@ type DbTransaction = Parameters[0] extends ( ? T : never +// Minimal structural type for database connection +// This type is intentionally permissive to allow mock injection in tests +export type BillingDbConn = { + transaction: (callback: (tx: any) => Promise) => Promise + select: (fields?: any) => any +} + /** * Finds the amount of the most recent expired 'free' grant for a user. * Finds the amount of the most recent expired 'free' grant for a user, @@ -30,14 +38,21 @@ type DbTransaction = Parameters[0] extends ( * @param userId The ID of the user. * @returns The amount of the last expired free grant (capped at 2000) or the default. */ -export async function getPreviousFreeGrantAmount(params: { - userId: string - logger: Logger -}): Promise { - const { userId, logger } = params +export async function getPreviousFreeGrantAmount( + params: OptionalFields< + { + userId: string + logger: Logger + conn: BillingDbConn + }, + 'conn' + >, +): Promise { + const { conn = db, ...rest } = params + const { userId, logger } = rest const now = new Date() - const lastExpiredFreeGrant = await db + const lastExpiredFreeGrant = await conn .select({ principal: schema.creditLedger.principal, }) @@ -75,14 +90,21 @@ export async function getPreviousFreeGrantAmount(params: { * @param userId The ID of the user. * @returns The total referral bonus credits earned. */ -export async function calculateTotalReferralBonus(params: { - userId: string - logger: Logger -}): Promise { - const { userId, logger } = params +export async function calculateTotalReferralBonus( + params: OptionalFields< + { + userId: string + logger: Logger + conn: BillingDbConn + }, + 'conn' + >, +): Promise { + const { conn = db, ...rest } = params + const { userId, logger } = rest try { - const result = await db + const result = await conn .select({ totalCredits: sql`COALESCE(SUM(${schema.referral.credits}), 0)`, }) @@ -349,18 +371,25 @@ export async function revokeGrantByOperationId(params: { * @param userId The ID of the user * @returns The effective quota reset date (either existing or new) */ -export interface MonthlyResetResult { +export type MonthlyResetResult = { quotaResetDate: Date autoTopupEnabled: boolean } -export async function triggerMonthlyResetAndGrant(params: { - userId: string - logger: Logger -}): Promise { - const { userId, logger } = params +export async function triggerMonthlyResetAndGrant( + params: OptionalFields< + { + userId: string + logger: Logger + conn: BillingDbConn + }, + 'conn' + >, +): Promise { + const { conn = db, ...rest } = params + const { userId, logger } = rest - return await db.transaction(async (tx) => { + return await conn.transaction(async (tx) => { const now = new Date() // Get user's current reset date and auto top-up status @@ -389,8 +418,8 @@ export async function triggerMonthlyResetAndGrant(params: { // Calculate grant amounts separately const [freeGrantAmount, referralBonus] = await Promise.all([ - getPreviousFreeGrantAmount(params), - calculateTotalReferralBonus(params), + getPreviousFreeGrantAmount({ ...rest, conn }), + calculateTotalReferralBonus({ ...rest, conn }), ]) // Generate a deterministic operation ID based on userId and reset date to minute precision @@ -406,7 +435,7 @@ export async function triggerMonthlyResetAndGrant(params: { // Always grant free credits - use grantCreditOperation with tx to keep everything in the same transaction await grantCreditOperation({ - ...params, + ...rest, amount: freeGrantAmount, type: 'free', description: 'Monthly free credits', @@ -418,7 +447,7 @@ export async function triggerMonthlyResetAndGrant(params: { // Only grant referral credits if there are any if (referralBonus > 0) { await grantCreditOperation({ - ...params, + ...rest, amount: referralBonus, type: 'referral', description: 'Monthly referral bonus', diff --git a/packages/billing/src/org-billing.ts b/packages/billing/src/org-billing.ts index 15ed98045..76328b750 100644 --- a/packages/billing/src/org-billing.ts +++ b/packages/billing/src/org-billing.ts @@ -9,6 +9,21 @@ import { and, asc, gt, isNull, or, eq } from 'drizzle-orm' import { consumeFromOrderedGrants } from './balance-calculator' +// Minimal structural type for database connection +// This type is intentionally permissive to allow mock injection in tests +export type OrgBillingDbConn = { + select: (fields?: any) => any + insert: (table?: any) => any + update: (table?: any) => any +} + +// Type for transaction wrapper function (permissive for mock injection in tests) +export type WithTransactionFn = (params: { + callback: (tx: OrgBillingDbConn) => Promise + context: Record + logger: Logger +}) => Promise + import type { CreditBalance, CreditUsageAndBalance, @@ -18,8 +33,8 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { OptionalFields } from '@codebuff/common/types/function-params' import type { GrantType } from '@codebuff/internal/db/schema' -// Add a minimal structural type that both `db` and `tx` satisfy -type DbConn = Pick +// Minimal structural type that both `db` and `tx` satisfy (permissive for mock injection) +type DbConn = OrgBillingDbConn /** * Syncs organization billing cycle with Stripe subscription and returns the current cycle start date. @@ -267,18 +282,25 @@ export async function calculateOrganizationUsageAndBalance( /** * Consumes credits from organization grants in priority order. */ -export async function consumeOrganizationCredits(params: { - organizationId: string - creditsToConsume: number - logger: Logger -}): Promise { - const { organizationId, creditsToConsume, logger } = params +export async function consumeOrganizationCredits( + params: OptionalFields< + { + organizationId: string + creditsToConsume: number + logger: Logger + withTransaction: WithTransactionFn + }, + 'withTransaction' + >, +): Promise { + const { withTransaction = withSerializableTransaction, ...rest } = params + const { organizationId, creditsToConsume, logger } = rest - return await withSerializableTransaction({ + return await withTransaction({ callback: async (tx) => { const now = new Date() const activeGrants = await getOrderedActiveOrganizationGrants({ - ...params, + ...rest, now, conn: tx, }) @@ -319,13 +341,15 @@ export async function grantOrganizationCredits( description: string expiresAt: Date | null logger: Logger + conn: OrgBillingDbConn }, - 'description' | 'expiresAt' + 'description' | 'expiresAt' | 'conn' >, ): Promise { const withDefaults = { description: 'Organization credit purchase', expiresAt: null, + conn: db, ...params, } const { @@ -336,12 +360,13 @@ export async function grantOrganizationCredits( description, expiresAt, logger, + conn, } = withDefaults const now = new Date() try { - await db.insert(schema.creditLedger).values({ + await conn.insert(schema.creditLedger).values({ operation_id: operationId, user_id: userId, org_id: organizationId, diff --git a/packages/billing/src/usage-service.ts b/packages/billing/src/usage-service.ts index 04bc659a6..8d6f84ae9 100644 --- a/packages/billing/src/usage-service.ts +++ b/packages/billing/src/usage-service.ts @@ -1,3 +1,4 @@ +import type { OptionalFields } from '@codebuff/common/types/function-params' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { eq, and, desc, gte, sql } from 'drizzle-orm' @@ -10,9 +11,29 @@ import { syncOrganizationBillingCycle, } from './org-billing' -import type { CreditBalance } from './balance-calculator' +import type { CreditBalance, CreditUsageAndBalance } from './balance-calculator' +import type { MonthlyResetResult } from './grant-credits' import type { Logger } from '@codebuff/common/types/contracts/logger' +// Types for dependency injection in tests +export type TriggerMonthlyResetFn = (params: { + userId: string + logger: Logger +}) => Promise + +export type CheckAutoTopupFn = (params: { + userId: string + logger: Logger +}) => Promise + +export type CalculateUsageBalanceFn = (params: { + userId: string + quotaResetDate: Date + now: Date + isPersonalContext: boolean + logger: Logger +}) => Promise + export interface UserUsageData { usageThisCycle: number balance: CreditBalance @@ -44,23 +65,38 @@ export interface OrganizationUsageData { * Gets comprehensive user usage data including balance, usage, and auto-topup handling. * This consolidates logic from web/src/app/api/user/usage/route.ts */ -export async function getUserUsageData(params: { - userId: string - logger: Logger -}): Promise { - const { userId, logger } = params +export async function getUserUsageData( + params: OptionalFields< + { + userId: string + logger: Logger + // Injectable dependencies for testing + triggerMonthlyReset: TriggerMonthlyResetFn + checkAutoTopup: CheckAutoTopupFn + calculateUsageBalance: CalculateUsageBalanceFn + }, + 'triggerMonthlyReset' | 'checkAutoTopup' | 'calculateUsageBalance' + >, +): Promise { + const { + triggerMonthlyReset = triggerMonthlyResetAndGrant, + checkAutoTopup = checkAndTriggerAutoTopup, + calculateUsageBalance = calculateUsageAndBalance, + ...rest + } = params + const { userId, logger } = rest try { const now = new Date() // Check if we need to reset quota and grant new credits // This also returns autoTopupEnabled to avoid a separate query const { quotaResetDate, autoTopupEnabled } = - await triggerMonthlyResetAndGrant(params) + await triggerMonthlyReset(rest) // Check if we need to trigger auto top-up let autoTopupTriggered = false try { - const topupAmount = await checkAndTriggerAutoTopup(params) + const topupAmount = await checkAutoTopup(rest) autoTopupTriggered = topupAmount !== undefined } catch (error) { logger.error( @@ -72,8 +108,8 @@ export async function getUserUsageData(params: { // Use the canonical balance calculation function with the effective reset date // Pass isPersonalContext: true to exclude organization credits from personal usage - const { usageThisCycle, balance } = await calculateUsageAndBalance({ - ...params, + const { usageThisCycle, balance } = await calculateUsageBalance({ + ...rest, quotaResetDate, now, isPersonalContext: true, // isPersonalContext: true to exclude organization credits diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index 1a654333e..3adf6cd1e 100644 --- a/packages/internal/src/utils/__tests__/version-utils.test.ts +++ b/packages/internal/src/utils/__tests__/version-utils.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it, afterEach, mock } from 'bun:test' -import * as versionUtils from '../version-utils' +import { + createVersionQueryDbMock, + createExistsQueryDbMock, +} from '@codebuff/common/testing/fixtures/database' -import type { CodebuffPgDatabase } from '../../db/types' +import * as versionUtils from '../version-utils' const { versionOne, @@ -124,18 +127,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 = createVersionQueryDbMock([]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -146,20 +138,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 = createVersionQueryDbMock([{ major: 1, minor: 2, patch: 3 }]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -170,20 +149,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 = createVersionQueryDbMock([{ major: null, minor: null, patch: null }]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -196,19 +162,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 = createVersionQueryDbMock([{ major: 1, minor: 2, patch: 3 }]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -219,17 +173,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 = createVersionQueryDbMock([]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -241,19 +185,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 = createVersionQueryDbMock([{ major: 2, minor: 0, patch: 0 }]) await expect( determineNextVersion({ @@ -268,19 +200,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 = createVersionQueryDbMock([{ major: 1, minor: 5, patch: 0 }]) await expect( determineNextVersion({ @@ -295,17 +215,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 = createVersionQueryDbMock([]) await expect( determineNextVersion({ @@ -322,14 +232,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 = createExistsQueryDbMock([{ id: 'test-agent' }]) const result = await versionExists({ agentId: 'test-agent', @@ -341,14 +244,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 = createExistsQueryDbMock([]) const result = await versionExists({ agentId: 'test-agent', diff --git a/scripts/init-worktree.ts b/scripts/init-worktree.ts index 6e42d7f4e..d46ef2d7a 100644 --- a/scripts/init-worktree.ts +++ b/scripts/init-worktree.ts @@ -463,6 +463,10 @@ async function main(): Promise { console.log('Installing dependencies with bun...') await runCommand('bun', ['install'], worktreePath) + // Build the SDK (required for CLI tests to resolve @codebuff/sdk) + console.log('Building SDK...') + await runCommand('bun', ['run', 'build'], join(worktreePath, 'sdk')) + console.log(`✅ Worktree '${args.name}' created and set up successfully!`) console.log(`📁 Location: ${worktreePath}`) console.log(`🌿 Based on: ${baseBranch} (HEAD)`) diff --git a/sdk/src/__tests__/client.test.ts b/sdk/src/__tests__/client.test.ts index 333f5c75e..a1b6b064b 100644 --- a/sdk/src/__tests__/client.test.ts +++ b/sdk/src/__tests__/client.test.ts @@ -1,11 +1,13 @@ +import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' import { describe, expect, test, mock, afterEach } from 'bun:test' + import { CodebuffClient } from '../client' describe('CodebuffClient', () => { const originalFetch = globalThis.fetch const setFetchMock = (mockFetch: ReturnType) => { - globalThis.fetch = mockFetch as unknown as typeof fetch + globalThis.fetch = wrapMockAsFetch(mockFetch) } afterEach(() => { diff --git a/sdk/src/__tests__/code-search.test.ts b/sdk/src/__tests__/code-search.test.ts index b368ae41e..c2fce0dc7 100644 --- a/sdk/src/__tests__/code-search.test.ts +++ b/sdk/src/__tests__/code-search.test.ts @@ -1,24 +1,49 @@ import { EventEmitter } from 'events' -import { - clearMockedModules, - mockModule, -} from '@codebuff/common/testing/mock-modules' -import { describe, expect, it, mock, beforeEach, afterEach } from 'bun:test' +import { describe, expect, it, beforeEach, afterEach, spyOn } from 'bun:test' -import { codeSearch } from '../tools/code-search' +import { codeSearchWithSpawn, type SpawnFn } from '../tools/code-search' import type { ChildProcess } from 'child_process' -// Helper to create a mock child process -function createMockChildProcess() { - const mockProcess = new EventEmitter() as ChildProcess & { - stdout: EventEmitter - stderr: EventEmitter +// Helper to create a mock child process with proper ChildProcess shape +function createMockChildProcess(): ChildProcess { + const proc = new EventEmitter() as ChildProcess + const stdout = new EventEmitter() + const stderr = new EventEmitter() + + Object.defineProperty(proc, 'stdout', { value: stdout, writable: false }) + Object.defineProperty(proc, 'stderr', { value: stderr, writable: false }) + Object.defineProperty(proc, 'stdin', { value: null, writable: false }) + Object.defineProperty(proc, 'stdio', { value: [null, stdout, stderr], writable: false }) + Object.defineProperty(proc, 'pid', { value: 12345, writable: false }) + Object.defineProperty(proc, 'killed', { value: false, writable: true }) + Object.defineProperty(proc, 'connected', { value: false, writable: false }) + ;(proc as any).kill = () => true + ;(proc as any).disconnect = () => {} + ;(proc as any).unref = () => proc + ;(proc as any).ref = () => proc + + return proc +} + +/** Creates a typed mock spawn function that captures calls and returns controlled processes */ +function createMockSpawn() { + let currentProcess: ChildProcess = createMockChildProcess() + const calls: Array<{ command: string; args: string[]; options: any }> = [] + + const spawn: SpawnFn = (command, args, options) => { + calls.push({ command, args: [...args], options }) + currentProcess = createMockChildProcess() + return currentProcess + } + + return { + spawn, + get process() { return currentProcess }, + get calls() { return calls }, + get lastCall() { return calls[calls.length - 1] }, } - mockProcess.stdout = new EventEmitter() as any - mockProcess.stderr = new EventEmitter() as any - return mockProcess } // Helper to create ripgrep JSON match output @@ -54,25 +79,17 @@ function createRgJsonContext( } describe('codeSearch', () => { - let mockSpawn: ReturnType - let mockProcess: ReturnType - - beforeEach(async () => { - mockProcess = createMockChildProcess() - mockSpawn = mock(() => mockProcess) - await mockModule('child_process', () => ({ - spawn: mockSpawn, - })) - }) + let mockSpawn: ReturnType - afterEach(() => { - mock.restore() - clearMockedModules() + beforeEach(() => { + mockSpawn = createMockSpawn() }) + afterEach(() => {}) + describe('basic search', () => { it('should parse standard ripgrep output without context flags', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', }) @@ -84,8 +101,8 @@ describe('codeSearch', () => { createRgJsonMatch('file2.ts', 10, 'import React from "react"'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') @@ -97,7 +114,7 @@ describe('codeSearch', () => { describe('context flags handling', () => { it('should correctly parse output with -A flag (after context)', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import.*env', flags: '-A 2', @@ -113,8 +130,8 @@ describe('codeSearch', () => { createRgJsonContext('other.ts', 7, 'const port = env.PORT'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') @@ -124,7 +141,7 @@ describe('codeSearch', () => { expect(value.stdout).toContain('import { env } from "./config"') expect(value.stdout).toContain('import env from "process"') - // Should contain context lines (this is the bug - they're currently missing) + // Should contain context lines expect(value.stdout).toContain('const apiUrl = env.API_URL') expect(value.stdout).toContain('const apiKey = env.API_KEY') expect(value.stdout).toContain('const nodeEnv = env.NODE_ENV') @@ -132,7 +149,7 @@ describe('codeSearch', () => { }) it('should correctly parse output with -B flag (before context)', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'export', flags: '-B 2', @@ -148,8 +165,8 @@ describe('codeSearch', () => { createRgJsonMatch('utils.ts', 10, 'export function helper() {}'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -165,7 +182,7 @@ describe('codeSearch', () => { }) it('should correctly parse output with -C flag (context before and after)', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'TODO', flags: '-C 1', @@ -178,8 +195,8 @@ describe('codeSearch', () => { createRgJsonContext('code.ts', 7, ' return null'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -193,7 +210,7 @@ describe('codeSearch', () => { }) it('should handle -A flag with multiple matches in same file', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-A 1', @@ -206,8 +223,8 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 4, ''), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -221,7 +238,7 @@ describe('codeSearch', () => { }) it('should handle -B flag at start of file', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-B 2', @@ -230,8 +247,8 @@ describe('codeSearch', () => { // First line match has no before context const output = createRgJsonMatch('file.ts', 1, 'import foo from "foo"') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -241,7 +258,7 @@ describe('codeSearch', () => { }) it('should skip separator lines between result groups', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', flags: '-A 1', @@ -252,8 +269,8 @@ describe('codeSearch', () => { createRgJsonMatch('file2.ts', 5, 'another test'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -265,7 +282,7 @@ describe('codeSearch', () => { describe('edge cases with context lines', () => { it('should handle filenames with hyphens correctly', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-A 1', @@ -276,8 +293,8 @@ describe('codeSearch', () => { createRgJsonMatch('other-file.ts', 5, 'import bar'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -290,7 +307,7 @@ describe('codeSearch', () => { }) it('should handle filenames with multiple hyphens and underscores', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', flags: '-A 1', @@ -302,8 +319,8 @@ describe('codeSearch', () => { 'test content', ) - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -314,7 +331,7 @@ describe('codeSearch', () => { }) it('should not accumulate entire file content (regression test)', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import.*env', flags: '-A 2', @@ -326,8 +343,8 @@ describe('codeSearch', () => { createRgJsonMatch('other.ts', 1, 'import env'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -343,7 +360,7 @@ describe('codeSearch', () => { describe('result limiting with context lines', () => { it('should respect maxResults per file with context lines', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', flags: '-A 1', @@ -361,8 +378,8 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 16, 'context 4'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -383,7 +400,7 @@ describe('codeSearch', () => { }) it('should respect globalMaxResults with context lines', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', flags: '-A 1', @@ -401,8 +418,8 @@ describe('codeSearch', () => { createRgJsonContext('file2.ts', 6, 'context 4'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -418,7 +435,7 @@ describe('codeSearch', () => { }) it('should not count context lines toward maxResults limit', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'match', flags: '-A 2 -B 2', @@ -433,8 +450,8 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 5, 'context after 2'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -452,7 +469,7 @@ describe('codeSearch', () => { describe('malformed output handling', () => { it('should skip lines without separator', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', }) @@ -463,8 +480,8 @@ describe('codeSearch', () => { createRgJsonMatch('file.ts', 2, 'another valid line'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -475,13 +492,13 @@ describe('codeSearch', () => { }) it('should handle empty output', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'nonexistent', }) - mockProcess.stdout.emit('data', Buffer.from('')) - mockProcess.emit('close', 1) + mockSpawn.process.stdout!.emit('data', Buffer.from('')) + mockSpawn.process.emit('close', 1) const result = await searchPromise const value = result[0].value as any @@ -495,15 +512,15 @@ describe('codeSearch', () => { it('should handle patterns starting with hyphen (regression test)', async () => { // Bug: Patterns starting with '-' were misparsed as flags // Fix: Added '--' separator before pattern in args - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: '-foo', }) const output = createRgJsonMatch('file.ts', 1, 'const x = -foo') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -515,7 +532,7 @@ describe('codeSearch', () => { it('should strip trailing newlines from line text (regression test)', async () => { // Bug: JSON lineText includes trailing \n, causing blank lines // Fix: Strip \r?\n from lineText - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', }) @@ -530,8 +547,8 @@ describe('codeSearch', () => { }, }) - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -544,7 +561,7 @@ describe('codeSearch', () => { it('should process multiple JSON objects in remainder at close (regression test)', async () => { // Bug: Only processed one JSON object in remainder // Fix: Loop through all complete lines in remainder - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', }) @@ -557,8 +574,8 @@ describe('codeSearch', () => { // Send as one chunk without trailing newline to simulate remainder scenario const output = `${match1}\n${match2}\n${match3}` - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -572,7 +589,7 @@ describe('codeSearch', () => { it('should enforce output size limit during streaming (regression test)', async () => { // Bug: Output size only checked at end, could exceed limit // Fix: Check estimatedOutputLen during streaming and stop early - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', maxOutputStringLength: 500, // Small limit @@ -581,13 +598,15 @@ describe('codeSearch', () => { // Generate many matches that would exceed the limit const matches: string[] = [] for (let i = 0; i < 50; i++) { - matches.push(createRgJsonMatch('file.ts', i, `test line ${i} with some content`)) + matches.push( + createRgJsonMatch('file.ts', i, `test line ${i} with some content`), + ) } const output = matches.join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) // Process won't get to close because it should kill early - mockProcess.emit('close', 0) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -600,7 +619,7 @@ describe('codeSearch', () => { it('should handle non-UTF8 paths using path.bytes (regression test)', async () => { // Bug: Only handled path.text, not path.bytes for non-UTF8 paths // Fix: Check both path.text and path.bytes - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', }) @@ -615,8 +634,8 @@ describe('codeSearch', () => { }, }) - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -629,7 +648,7 @@ describe('codeSearch', () => { describe('glob pattern handling', () => { it('should handle -g flag with glob patterns like *.ts', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-g *.ts', @@ -640,23 +659,23 @@ describe('codeSearch', () => { createRgJsonMatch('file.ts', 5, 'import { baz } from "qux"'), ].join('\n') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') const value = result[0].value as any expect(value.stdout).toContain('file.ts:') - + // Verify the args passed to spawn include the glob flag correctly - expect(mockSpawn).toHaveBeenCalled() - const spawnArgs = mockSpawn.mock.calls[0][1] as string[] + expect(mockSpawn.calls.length).toBeGreaterThan(0) + const spawnArgs = mockSpawn.lastCall.args expect(spawnArgs).toContain('-g') expect(spawnArgs).toContain('*.ts') }) it('should handle -g flag with multiple glob patterns', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-g *.ts -g *.tsx', @@ -664,25 +683,27 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') const value = result[0].value as any expect(value.stdout).toContain('file.tsx:') - + // Verify both glob patterns are passed correctly - const spawnArgs = mockSpawn.mock.calls[0][1] as string[] + const spawnArgs = mockSpawn.lastCall.args // Should have two -g flags, each followed by its pattern - const gFlagIndices = spawnArgs.map((arg, i) => arg === '-g' ? i : -1).filter(i => i !== -1) + const gFlagIndices = spawnArgs + .map((arg, i) => (arg === '-g' ? i : -1)) + .filter((i) => i !== -1) expect(gFlagIndices.length).toBe(2) expect(spawnArgs[gFlagIndices[0] + 1]).toBe('*.ts') expect(spawnArgs[gFlagIndices[1] + 1]).toBe('*.tsx') }) it('should not deduplicate flag-argument pairs', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'import', flags: '-g *.ts -i -g *.tsx', @@ -690,28 +711,28 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) + + await searchPromise - const result = await searchPromise - // Verify flags are preserved in order without deduplication - const spawnArgs = mockSpawn.mock.calls[0][1] as string[] + const spawnArgs = mockSpawn.lastCall.args const flagsSection = spawnArgs.slice(0, spawnArgs.indexOf('--')) expect(flagsSection).toContain('-g') expect(flagsSection).toContain('*.ts') expect(flagsSection).toContain('-i') expect(flagsSection).toContain('*.tsx') - + // Count -g flags - should be 2, not deduplicated to 1 - const gCount = flagsSection.filter(arg => arg === '-g').length + const gCount = flagsSection.filter((arg) => arg === '-g').length expect(gCount).toBe(2) }) }) describe('timeout handling', () => { it('should timeout after specified seconds', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', timeoutSeconds: 1, @@ -722,7 +743,7 @@ describe('codeSearch', () => { await new Promise((resolve) => setTimeout(resolve, 1100)) // Manually trigger the timeout by emitting close - mockProcess.emit('close', null) + mockSpawn.process.emit('close', null) const result = await searchPromise const value = result[0].value as any @@ -733,7 +754,7 @@ describe('codeSearch', () => { describe('cwd parameter handling', () => { it('should handle cwd: "." correctly', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', cwd: '.', @@ -741,8 +762,8 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.ts', 1, 'test content') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -753,14 +774,14 @@ describe('codeSearch', () => { expect(value.stdout).toContain('test content') // Verify spawn was called with correct cwd - expect(mockSpawn).toHaveBeenCalled() - const spawnOptions = mockSpawn.mock.calls[0][2] as any + expect(mockSpawn.calls.length).toBeGreaterThan(0) + const spawnOptions = mockSpawn.lastCall.options // When cwd is '.', it should resolve to the project root expect(spawnOptions.cwd).toBe('/test/project') }) it('should handle cwd: "subdir" correctly', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', cwd: 'subdir', @@ -768,8 +789,8 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.ts', 1, 'test content') - mockProcess.stdout.emit('data', Buffer.from(output)) - mockProcess.emit('close', 0) + mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.emit('close', 0) const result = await searchPromise const value = result[0].value as any @@ -778,13 +799,13 @@ describe('codeSearch', () => { expect(value.stdout).toContain('file.ts:') // Verify spawn was called with correct cwd - expect(mockSpawn).toHaveBeenCalled() - const spawnOptions = mockSpawn.mock.calls[0][2] as any + expect(mockSpawn.calls.length).toBeGreaterThan(0) + const spawnOptions = mockSpawn.lastCall.options expect(spawnOptions.cwd).toBe('/test/project/subdir') }) it('should reject cwd outside project directory', async () => { - const searchPromise = codeSearch({ + const searchPromise = codeSearchWithSpawn(mockSpawn.spawn, { projectPath: '/test/project', pattern: 'test', cwd: '../outside', diff --git a/sdk/src/tools/code-search.ts b/sdk/src/tools/code-search.ts index e246ab83f..9f3fa11f9 100644 --- a/sdk/src/tools/code-search.ts +++ b/sdk/src/tools/code-search.ts @@ -1,4 +1,4 @@ -import { spawn } from 'child_process' +import { spawn as nodeSpawn } from 'child_process' import * as fs from 'fs' import * as path from 'path' @@ -18,25 +18,30 @@ const INCLUDED_HIDDEN_DIRS = [ '.husky', // Git hooks ] -export function codeSearch({ - projectPath, - pattern, - flags, - cwd, - maxResults = 15, - globalMaxResults = 250, - maxOutputStringLength = 20_000, - timeoutSeconds = 10, -}: { - projectPath: string - pattern: string - flags?: string - cwd?: string - maxResults?: number - globalMaxResults?: number - maxOutputStringLength?: number - timeoutSeconds?: number -}): Promise> { +export type SpawnFn = typeof nodeSpawn + +export function codeSearchWithSpawn( + spawn: SpawnFn, + { + projectPath, + pattern, + flags, + cwd, + maxResults = 15, + globalMaxResults = 250, + maxOutputStringLength = 20_000, + timeoutSeconds = 10, + }: { + projectPath: string + pattern: string + flags?: string + cwd?: string + maxResults?: number + globalMaxResults?: number + maxOutputStringLength?: number + timeoutSeconds?: number + }, +): Promise> { return new Promise((resolve) => { let isResolved = false @@ -383,3 +388,16 @@ export function codeSearch({ }) }) } + +export function codeSearch(params: { + projectPath: string + pattern: string + flags?: string + cwd?: string + maxResults?: number + globalMaxResults?: number + maxOutputStringLength?: number + timeoutSeconds?: number +}): Promise> { + return codeSearchWithSpawn(nodeSpawn, params) +} From 0940b5d4cd101f4526468f9a8ae9d83a5cec0ee4 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 15 Dec 2025 16:14:12 -0800 Subject: [PATCH 02/17] chore(billing): remove stale May 22nd migration cap TODO The 2000 credit cap was a temporary migration safeguard that is no longer needed. --- packages/billing/src/grant-credits.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/billing/src/grant-credits.ts b/packages/billing/src/grant-credits.ts index 2de3055b4..768848831 100644 --- a/packages/billing/src/grant-credits.ts +++ b/packages/billing/src/grant-credits.ts @@ -68,13 +68,12 @@ export async function getPreviousFreeGrantAmount( .limit(1) if (lastExpiredFreeGrant.length > 0) { - // TODO: remove this once it's past May 22nd, after all users have been migrated over - const cappedAmount = Math.min(lastExpiredFreeGrant[0].principal, 2000) + const amount = lastExpiredFreeGrant[0].principal logger.debug( - { userId, amount: lastExpiredFreeGrant[0].principal }, + { userId, amount }, 'Found previous expired free grant amount.', ) - return cappedAmount + return amount } else { logger.debug( { userId, defaultAmount: DEFAULT_FREE_CREDITS_GRANT }, From 2962068afa388a7b1def7754376f2ef0cba64099 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Mon, 15 Dec 2025 16:22:29 -0800 Subject: [PATCH 03/17] refactor: use barrel imports for testing fixtures Update imports to use @codebuff/common/testing/fixtures barrel file instead of individual module paths. --- packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts | 2 +- packages/agent-runtime/src/__tests__/main-prompt.test.ts | 2 +- packages/agent-runtime/src/__tests__/n-parameter.test.ts | 2 +- packages/agent-runtime/src/__tests__/read-docs-tool.test.ts | 2 +- .../agent-runtime/src/__tests__/run-agent-step-tools.test.ts | 2 +- .../agent-runtime/src/__tests__/run-programmatic-step.test.ts | 2 +- packages/agent-runtime/src/__tests__/web-search-tool.test.ts | 2 +- packages/internal/src/utils/__tests__/version-utils.test.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index cb01c79b4..35bc41fce 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -4,7 +4,7 @@ import { mockAnalytics, mockBigQuery, mockRandomUUID, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/main-prompt.test.ts b/packages/agent-runtime/src/__tests__/main-prompt.test.ts index f23af5d8a..118ed4367 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -4,7 +4,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockBigQuery, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { AgentTemplateTypes, diff --git a/packages/agent-runtime/src/__tests__/n-parameter.test.ts b/packages/agent-runtime/src/__tests__/n-parameter.test.ts index 28e7b1472..e8833a757 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -3,7 +3,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockRandomUUID, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts index 480e07524..4346951ac 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -4,7 +4,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockBigQuery, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index af8ceaee7..f62a395c2 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -4,7 +4,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockBigQuery, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index 8a54aa95d..315fb4721 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -3,7 +3,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockRandomUUID, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { diff --git a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts index 90535c572..46d0a1cf8 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -4,7 +4,7 @@ import { TEST_USER_ID } from '@codebuff/common/old-constants' import { mockAnalytics, mockBigQuery, -} from '@codebuff/common/testing/fixtures/agent-runtime' +} from '@codebuff/common/testing/fixtures' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { success } from '@codebuff/common/util/error' diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index 3adf6cd1e..ac89e50f0 100644 --- a/packages/internal/src/utils/__tests__/version-utils.test.ts +++ b/packages/internal/src/utils/__tests__/version-utils.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, afterEach, mock } from 'bun:test' import { createVersionQueryDbMock, createExistsQueryDbMock, -} from '@codebuff/common/testing/fixtures/database' +} from '@codebuff/common/testing/fixtures' import * as versionUtils from '../version-utils' From cf50c04f6ff8aecd50cd02db9b3ddd76f9a2b849 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 13:27:54 -0800 Subject: [PATCH 04/17] fix: resolve typecheck issues --- common/src/testing/fixtures/agent-runtime.ts | 8 ++--- .../src/utils/__tests__/version-utils.test.ts | 34 +++++++++++++------ sdk/src/__tests__/code-search.test.ts | 23 +++++++++---- sdk/src/tools/code-search.ts | 13 ++++++- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index 0d26fc8bc..da616f361 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -143,9 +143,9 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< * Matches the shape of @codebuff/common/analytics. */ type AnalyticsModule = { - initAnalytics: (...args: unknown[]) => void - trackEvent: (...args: unknown[]) => void - flushAnalytics?: (...args: unknown[]) => Promise + initAnalytics: (...args: any[]) => void + trackEvent: (...args: any[]) => void + flushAnalytics?: (...args: any[]) => Promise } /** @@ -153,7 +153,7 @@ type AnalyticsModule = { * Matches the shape of @codebuff/bigquery. */ type BigQueryModule = { - insertTrace: (...args: unknown[]) => Promise + insertTrace: (...args: any[]) => Promise } /** diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index ac89e50f0..0985ded73 100644 --- a/packages/internal/src/utils/__tests__/version-utils.test.ts +++ b/packages/internal/src/utils/__tests__/version-utils.test.ts @@ -7,6 +7,8 @@ import { import * as versionUtils from '../version-utils' +import type { CodebuffPgDatabase } from '../../db/types' + const { versionOne, parseVersion, @@ -127,7 +129,7 @@ describe('version-utils', () => { describe('getLatestAgentVersion', () => { it('should return version 0.0.0 when no agent exists', async () => { - const mockDb = createVersionQueryDbMock([]) + const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -138,7 +140,9 @@ describe('version-utils', () => { }) it('should return latest version when agent exists', async () => { - const mockDb = createVersionQueryDbMock([{ major: 1, minor: 2, patch: 3 }]) + const mockDb = createVersionQueryDbMock([ + { major: 1, minor: 2, patch: 3 }, + ]) as unknown as CodebuffPgDatabase const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -149,7 +153,9 @@ describe('version-utils', () => { }) it('should handle null values in database response', async () => { - const mockDb = createVersionQueryDbMock([{ major: null, minor: null, patch: null }]) + const mockDb = createVersionQueryDbMock([ + { major: null, minor: null, patch: null }, + ]) as unknown as CodebuffPgDatabase const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -162,7 +168,9 @@ describe('version-utils', () => { describe('determineNextVersion', () => { it('should increment patch of latest version when no version provided', async () => { - const mockDb = createVersionQueryDbMock([{ major: 1, minor: 2, patch: 3 }]) + const mockDb = createVersionQueryDbMock([ + { major: 1, minor: 2, patch: 3 }, + ]) as unknown as CodebuffPgDatabase const result = await determineNextVersion({ agentId: 'test-agent', @@ -173,7 +181,7 @@ describe('version-utils', () => { }) it('should use provided version when higher than latest', async () => { - const mockDb = createVersionQueryDbMock([]) + const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase const result = await determineNextVersion({ agentId: 'test-agent', @@ -185,7 +193,9 @@ describe('version-utils', () => { }) it('should throw error when provided version is not greater than latest', async () => { - const mockDb = createVersionQueryDbMock([{ major: 2, minor: 0, patch: 0 }]) + const mockDb = createVersionQueryDbMock([ + { major: 2, minor: 0, patch: 0 }, + ]) as unknown as CodebuffPgDatabase await expect( determineNextVersion({ @@ -200,7 +210,9 @@ describe('version-utils', () => { }) it('should throw error when provided version equals latest', async () => { - const mockDb = createVersionQueryDbMock([{ major: 1, minor: 5, patch: 0 }]) + const mockDb = createVersionQueryDbMock([ + { major: 1, minor: 5, patch: 0 }, + ]) as unknown as CodebuffPgDatabase await expect( determineNextVersion({ @@ -215,7 +227,7 @@ describe('version-utils', () => { }) it('should throw error for invalid provided version', async () => { - const mockDb = createVersionQueryDbMock([]) + const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase await expect( determineNextVersion({ @@ -232,7 +244,9 @@ describe('version-utils', () => { describe('versionExists', () => { it('should return true when version exists', async () => { - const mockDb = createExistsQueryDbMock([{ id: 'test-agent' }]) + const mockDb = createExistsQueryDbMock([ + { id: 'test-agent' }, + ]) as unknown as CodebuffPgDatabase const result = await versionExists({ agentId: 'test-agent', @@ -244,7 +258,7 @@ describe('version-utils', () => { }) it('should return false when version does not exist', async () => { - const mockDb = createExistsQueryDbMock([]) + const mockDb = createExistsQueryDbMock([]) as unknown as CodebuffPgDatabase const result = await versionExists({ agentId: 'test-agent', diff --git a/sdk/src/__tests__/code-search.test.ts b/sdk/src/__tests__/code-search.test.ts index c2fce0dc7..8739d4c63 100644 --- a/sdk/src/__tests__/code-search.test.ts +++ b/sdk/src/__tests__/code-search.test.ts @@ -1,21 +1,30 @@ import { EventEmitter } from 'events' +import { PassThrough } from 'stream' import { describe, expect, it, beforeEach, afterEach, spyOn } from 'bun:test' import { codeSearchWithSpawn, type SpawnFn } from '../tools/code-search' -import type { ChildProcess } from 'child_process' +import type { ChildProcessByStdio } from 'child_process' +import type { Readable } from 'stream' // Helper to create a mock child process with proper ChildProcess shape -function createMockChildProcess(): ChildProcess { - const proc = new EventEmitter() as ChildProcess - const stdout = new EventEmitter() - const stderr = new EventEmitter() +function createMockChildProcess(): ChildProcessByStdio { + const proc = new EventEmitter() as unknown as ChildProcessByStdio< + null, + Readable, + Readable + > + const stdout: Readable = new PassThrough() + const stderr: Readable = new PassThrough() Object.defineProperty(proc, 'stdout', { value: stdout, writable: false }) Object.defineProperty(proc, 'stderr', { value: stderr, writable: false }) Object.defineProperty(proc, 'stdin', { value: null, writable: false }) - Object.defineProperty(proc, 'stdio', { value: [null, stdout, stderr], writable: false }) + Object.defineProperty(proc, 'stdio', { + value: [null, stdout, stderr, undefined, undefined], + writable: false, + }) Object.defineProperty(proc, 'pid', { value: 12345, writable: false }) Object.defineProperty(proc, 'killed', { value: false, writable: true }) Object.defineProperty(proc, 'connected', { value: false, writable: false }) @@ -29,7 +38,7 @@ function createMockChildProcess(): ChildProcess { /** Creates a typed mock spawn function that captures calls and returns controlled processes */ function createMockSpawn() { - let currentProcess: ChildProcess = createMockChildProcess() + let currentProcess = createMockChildProcess() const calls: Array<{ command: string; args: string[]; options: any }> = [] const spawn: SpawnFn = (command, args, options) => { diff --git a/sdk/src/tools/code-search.ts b/sdk/src/tools/code-search.ts index 9f3fa11f9..c378b19af 100644 --- a/sdk/src/tools/code-search.ts +++ b/sdk/src/tools/code-search.ts @@ -5,7 +5,14 @@ import * as path from 'path' import { formatCodeSearchOutput } from '../../../common/src/util/format-code-search' import { getBundledRgPath } from '../native/ripgrep' +import type { + ChildProcessByStdio, + SpawnOptionsWithStdioTuple, + StdioNull, + StdioPipe, +} from 'child_process' import type { CodebuffToolOutput } from '../../../common/src/tools/list' +import type { Readable } from 'stream' // Hidden directories to include in code search by default. // These are searched in addition to '.' to ensure important config/workflow files are discoverable. @@ -18,7 +25,11 @@ const INCLUDED_HIDDEN_DIRS = [ '.husky', // Git hooks ] -export type SpawnFn = typeof nodeSpawn +export type SpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithStdioTuple, +) => ChildProcessByStdio export function codeSearchWithSpawn( spawn: SpawnFn, From e97ce7486f6125abceb4fb614d58dff96e321d3f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 14:09:35 -0800 Subject: [PATCH 05/17] fix(internal): add postgres dependency --- bun.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 09c811287..065b0c7ee 100644 --- a/bun.lock +++ b/bun.lock @@ -668,7 +668,7 @@ "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="], - "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.0", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-N8Jx6PaYzcTRNzirReJCtADVoq4z7+1KQ4E70jTg/koQiMoUSN1kbNjPOqpPbhMFhfU1/l7ixspPl8dNY+FoUg=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], @@ -1014,7 +1014,7 @@ "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@1.30.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "1.30.1", "@opentelemetry/core": "1.30.1", "@opentelemetry/propagator-b3": "1.30.1", "@opentelemetry/propagator-jaeger": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "semver": "^7.5.2" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.37.0", "", {}, "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA=="], "@opentui/core": ["@opentui/core@0.1.56", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.56", "@opentui/core-darwin-x64": "0.1.56", "@opentui/core-linux-arm64": "0.1.56", "@opentui/core-linux-x64": "0.1.56", "@opentui/core-win32-arm64": "0.1.56", "@opentui/core-win32-x64": "0.1.56", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-TI5cSCPYythHIQYpAEdXyZhewGACn2TfnfC1qZmrSyEq33zFo4W7zpQ4EZNpy9xZJFCI+elAUVJFARwhudp9EQ=="], @@ -1790,7 +1790,7 @@ "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], - "comment-json": ["comment-json@4.5.0", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-aKl8CwoMKxVTfAK4dFN4v54AEvuUh9pzmgVIBeK6gBomLwMgceQUKKWHzJdW1u1VQXQuwnJ7nJGWYYMTl5U4yg=="], + "comment-json": ["comment-json@4.4.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg=="], "compare-func": ["compare-func@2.0.0", "", { "dependencies": { "array-ify": "^1.0.0", "dot-prop": "^5.1.0" } }, "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA=="], @@ -3074,7 +3074,7 @@ "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], - "oo-ascii-tree": ["oo-ascii-tree@1.121.0", "", {}, "sha512-Dwzge50NT4bUxynVLtn/eFnl5Vv+8thNDVhw2MFZf6t5DmtIWKCDdQGUrIhN6PMEloDXVvPIW//oZtooSkp79g=="], + "oo-ascii-tree": ["oo-ascii-tree@1.118.0", "", {}, "sha512-ATGzZ+AxeHuGdNlniQNn9xvaVDo8IfET84Xep0XS33KXr19EZum7VpzBuKtcfNM/NQ7uk1d4ePXMqyiHeA9Dxw=="], "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], @@ -3916,7 +3916,7 @@ "zdog": ["zdog@1.1.3", "", {}, "sha512-raRj6r0gPzopFm5XWBJZr/NuV4EEnT4iE+U3dp5FV5pCb588Gmm3zLIp/j9yqqcMiHH8VNQlerLTgOqL7krh6w=="], - "zod": ["zod@4.2.0", "", {}, "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw=="], + "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "zod-from-json-schema": ["zod-from-json-schema@0.4.2", "", { "dependencies": { "zod": "^3.25.25" } }, "sha512-U+SIzUUT7P6w1UNAz81Sj0Vko77eQPkZ8LbJeXqQbwLmq1MZlrjB3Gj4LuebqJW25/CzS9WA8SjTgR5lvuv+zA=="], From 9ad460f5ab1aad10c6bdb32649e2fba267ac94a7 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 17:12:40 -0800 Subject: [PATCH 06/17] Remove unsafe casts in tests --- .../integration/api-integration.test.ts | 8 +- .../usage-refresh-on-completion.test.ts | 32 +-- .../hooks/__tests__/use-usage-query.test.ts | 33 ++- cli/src/hooks/use-usage-query.ts | 2 +- cli/src/utils/__tests__/codebuff-api.test.ts | 84 +++----- common/src/testing/fixtures/billing.ts | 10 +- common/src/testing/fixtures/fetch.ts | 121 ++++++----- common/src/testing/fixtures/index.ts | 1 + .../src/__tests__/loop-agent-steps.test.ts | 16 -- .../__tests__/run-agent-step-tools.test.ts | 12 -- .../__tests__/run-programmatic-step.test.ts | 117 +++++----- .../billing/src/__tests__/org-billing.test.ts | 18 +- .../src/utils/__tests__/version-utils.test.ts | 22 +- packages/internal/src/utils/version-utils.ts | 57 ++++- sdk/src/__tests__/client.test.ts | 22 +- sdk/src/__tests__/code-search.test.ts | 204 ++++++++++-------- sdk/src/tools/code-search.ts | 18 +- 17 files changed, 421 insertions(+), 356 deletions(-) diff --git a/cli/src/__tests__/integration/api-integration.test.ts b/cli/src/__tests__/integration/api-integration.test.ts index d40fd6271..1ad4ae00a 100644 --- a/cli/src/__tests__/integration/api-integration.test.ts +++ b/cli/src/__tests__/integration/api-integration.test.ts @@ -1,4 +1,4 @@ -import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' +import { wrapMockAsFetch, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { AuthenticationError, NetworkError, @@ -42,9 +42,9 @@ describe('API Integration', () => { }) as LoggerMocks const setFetchMock = ( - impl: Parameters[0], - ): ReturnType => { - const fetchMock = mock(impl) + impl: FetchCallFn, + ): ReturnType> => { + const fetchMock = mock(impl) globalThis.fetch = wrapMockAsFetch(fetchMock) return fetchMock } diff --git a/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts b/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts index 80366e247..5b647305b 100644 --- a/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts +++ b/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts @@ -1,4 +1,4 @@ -import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' +import { wrapMockAsFetch, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { QueryClient } from '@tanstack/react-query' import { describe, @@ -54,7 +54,7 @@ describe('Usage Refresh on SDK Completion', () => { // Mock successful API response globalThis.fetch = wrapMockAsFetch( - mock(async () => + mock(async () => new Response( JSON.stringify({ type: 'usage-response', @@ -82,10 +82,7 @@ describe('Usage Refresh on SDK Completion', () => { expect(useChatStore.getState().inputMode).toBe('usage') // Spy on invalidateQueries - const invalidateSpy = mock( - queryClient.invalidateQueries.bind(queryClient), - ) - queryClient.invalidateQueries = invalidateSpy as any + const invalidateSpy = spyOn(queryClient, 'invalidateQueries') // Simulate SDK run completion triggering invalidation const isUsageMode = useChatStore.getState().inputMode === 'usage' @@ -103,10 +100,7 @@ describe('Usage Refresh on SDK Completion', () => { test('should invalidate multiple times for sequential runs', () => { useChatStore.getState().setInputMode('usage') - const invalidateSpy = mock( - queryClient.invalidateQueries.bind(queryClient), - ) - queryClient.invalidateQueries = invalidateSpy as any + const invalidateSpy = spyOn(queryClient, 'invalidateQueries') // Simulate three sequential SDK runs for (let i = 0; i < 3; i++) { @@ -125,10 +119,7 @@ describe('Usage Refresh on SDK Completion', () => { useChatStore.getState().setInputMode('default') expect(useChatStore.getState().inputMode).toBe('default') - const invalidateSpy = mock( - queryClient.invalidateQueries.bind(queryClient), - ) - queryClient.invalidateQueries = invalidateSpy as any + const invalidateSpy = spyOn(queryClient, 'invalidateQueries') // Simulate SDK run completion check const isUsageMode = useChatStore.getState().inputMode === 'usage' @@ -147,10 +138,7 @@ describe('Usage Refresh on SDK Completion', () => { // User closes banner before run completes useChatStore.getState().setInputMode('default') - const invalidateSpy = mock( - queryClient.invalidateQueries.bind(queryClient), - ) - queryClient.invalidateQueries = invalidateSpy as any + const invalidateSpy = spyOn(queryClient, 'invalidateQueries') // Simulate run completion const isUsageMode = useChatStore.getState().inputMode === 'usage' @@ -167,8 +155,8 @@ describe('Usage Refresh on SDK Completion', () => { // Even if banner is visible in store, query won't run if enabled=false useChatStore.getState().setInputMode('usage') - const fetchMock = mock(globalThis.fetch) - globalThis.fetch = fetchMock as any + const fetchMock = mock(async () => new Response('')) + globalThis.fetch = wrapMockAsFetch(fetchMock) // Query with enabled=false won't execute // (This would be the behavior when useUsageQuery({ enabled: false }) is called) @@ -182,8 +170,8 @@ describe('Usage Refresh on SDK Completion', () => { getAuthTokenSpy.mockReturnValue(undefined) useChatStore.getState().setInputMode('usage') - const fetchMock = mock(globalThis.fetch) - globalThis.fetch = fetchMock as any + const fetchMock = mock(async () => new Response('')) + globalThis.fetch = wrapMockAsFetch(fetchMock) // Query won't execute without auth token expect(fetchMock).not.toHaveBeenCalled() diff --git a/cli/src/hooks/__tests__/use-usage-query.test.ts b/cli/src/hooks/__tests__/use-usage-query.test.ts index 5158e58c9..14040e654 100644 --- a/cli/src/hooks/__tests__/use-usage-query.test.ts +++ b/cli/src/hooks/__tests__/use-usage-query.test.ts @@ -1,4 +1,4 @@ -import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' +import { wrapMockAsFetch, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { @@ -12,7 +12,7 @@ import { } from 'bun:test' import React from 'react' -import type { ClientEnv } from '@codebuff/common/types/contracts/env' +import type { Logger } from '@codebuff/common/types/contracts/logger' import { useChatStore } from '../../state/chat-store' import * as authModule from '../../utils/auth' @@ -46,7 +46,7 @@ describe('fetchUsageData', () => { } globalThis.fetch = wrapMockAsFetch( - mock( + mock( async () => new Response(JSON.stringify(mockResponse), { status: 200, @@ -62,17 +62,17 @@ describe('fetchUsageData', () => { test('should throw error on failed request', async () => { globalThis.fetch = wrapMockAsFetch( - mock(async () => new Response('Error', { status: 500 })), + mock(async () => new Response('Error', { status: 500 })), ) - const mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), + const mockLogger: Logger = { + error: mock(() => {}), + warn: mock(() => {}), + info: mock(() => {}), + debug: mock(() => {}), } await expect( - fetchUsageData({ authToken: 'test-token', logger: mockLogger as any }), + fetchUsageData({ authToken: 'test-token', logger: mockLogger }), ).rejects.toThrow('Failed to fetch usage: 500') }) @@ -80,9 +80,7 @@ describe('fetchUsageData', () => { await expect( fetchUsageData({ authToken: 'test-token', - clientEnv: { - NEXT_PUBLIC_CODEBUFF_APP_URL: undefined, - } as unknown as ClientEnv, + clientEnv: { NEXT_PUBLIC_CODEBUFF_APP_URL: undefined }, }), ).rejects.toThrow('NEXT_PUBLIC_CODEBUFF_APP_URL is not set') }) @@ -131,7 +129,7 @@ describe('useUsageQuery', () => { } globalThis.fetch = wrapMockAsFetch( - mock( + mock( async () => new Response(JSON.stringify(mockResponse), { status: 200, @@ -153,7 +151,7 @@ describe('useUsageQuery', () => { getAuthTokenSpy = spyOn(authModule, 'getAuthToken').mockReturnValue( 'test-token', ) - const fetchMock = mock(async () => new Response('{}')) + const fetchMock = mock(async () => new Response('{}')) globalThis.fetch = wrapMockAsFetch(fetchMock) const { result } = renderHook(() => useUsageQuery({ enabled: false }), { @@ -170,7 +168,7 @@ describe('useUsageQuery', () => { getAuthTokenSpy = spyOn(authModule, 'getAuthToken').mockReturnValue( undefined, ) - const fetchMock = mock(async () => new Response('{}')) + const fetchMock = mock(async () => new Response('{}')) globalThis.fetch = wrapMockAsFetch(fetchMock) renderHook(() => useUsageQuery(), { @@ -200,8 +198,7 @@ describe('useRefreshUsage', () => { }) test.skip('should invalidate usage queries', async () => { - const invalidateSpy = mock(queryClient.invalidateQueries.bind(queryClient)) - queryClient.invalidateQueries = invalidateSpy as any + const invalidateSpy = spyOn(queryClient, 'invalidateQueries') const { result } = renderHook(() => useRefreshUsage(), { wrapper: createWrapper(), diff --git a/cli/src/hooks/use-usage-query.ts b/cli/src/hooks/use-usage-query.ts index 986f39094..ac9838f89 100644 --- a/cli/src/hooks/use-usage-query.ts +++ b/cli/src/hooks/use-usage-query.ts @@ -28,7 +28,7 @@ interface UsageResponse { interface FetchUsageParams { authToken: string logger?: Logger - clientEnv?: ClientEnv + clientEnv?: Partial> } /** diff --git a/cli/src/utils/__tests__/codebuff-api.test.ts b/cli/src/utils/__tests__/codebuff-api.test.ts index a1cc914cc..da97961dc 100644 --- a/cli/src/utils/__tests__/codebuff-api.test.ts +++ b/cli/src/utils/__tests__/codebuff-api.test.ts @@ -1,16 +1,16 @@ -import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' +import { wrapMockAsFetch, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { describe, test, expect, mock, beforeEach } from 'bun:test' import { createCodebuffApiClient } from '../codebuff-api' -// Type for mocked fetch function -type MockFetch = (url: string, options?: RequestInit) => Promise +const toUrlString = (input: RequestInfo | URL): string => + input instanceof Request ? input.url : String(input) describe('createCodebuffApiClient', () => { - let mockFetch: ReturnType> + let mockFetch: ReturnType> beforeEach(() => { - mockFetch = mock(() => + mockFetch = mock(() => Promise.resolve({ ok: true, status: 200, @@ -46,8 +46,8 @@ describe('createCodebuffApiClient', () => { await client.get('/api/v1/test', { retry: false }) expect(mockFetch).toHaveBeenCalledTimes(1) - const [url] = mockFetch.mock.calls[0] as [string, RequestInit | undefined] - expect(url).toBe('https://test.api/api/v1/test') + const [request] = mockFetch.mock.calls[0] + expect(toUrlString(request)).toBe('https://test.api/api/v1/test') }) test('should add query parameters', async () => { @@ -61,8 +61,10 @@ describe('createCodebuffApiClient', () => { retry: false, }) - const [url] = mockFetch.mock.calls[0] as [string, RequestInit | undefined] - expect(url).toBe('https://test.api/api/v1/me?fields=id%2Cemail') + const [request] = mockFetch.mock.calls[0] + expect(toUrlString(request)).toBe( + 'https://test.api/api/v1/me?fields=id%2Cemail', + ) }) test('should include Authorization header when authToken provided', async () => { @@ -74,10 +76,7 @@ describe('createCodebuffApiClient', () => { await client.get('/api/v1/test', { retry: false }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({ Authorization: 'Bearer my-token', }) @@ -92,10 +91,7 @@ describe('createCodebuffApiClient', () => { await client.get('/api/v1/test', { includeAuth: false, retry: false }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({}) }) }) @@ -109,10 +105,7 @@ describe('createCodebuffApiClient', () => { await client.post('/api/v1/test', { key: 'value' }, { retry: false }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.method).toBe('POST') expect(options?.headers).toEqual({ 'Content-Type': 'application/json', @@ -133,10 +126,7 @@ describe('createCodebuffApiClient', () => { { includeCookie: true, includeAuth: false, retry: false }, ) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({ 'Content-Type': 'application/json', Cookie: 'next-auth.session-token=my-token;', @@ -153,10 +143,7 @@ describe('createCodebuffApiClient', () => { await client.put('/api/v1/test', { key: 'value' }, { retry: false }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.method).toBe('PUT') expect(options?.headers).toEqual({ 'Content-Type': 'application/json', @@ -173,10 +160,7 @@ describe('createCodebuffApiClient', () => { await client.patch('/api/v1/test', { key: 'value' }, { retry: false }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.method).toBe('PATCH') }) }) @@ -190,11 +174,8 @@ describe('createCodebuffApiClient', () => { await client.delete('/api/v1/test/123', { retry: false }) - const [url, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] - expect(url).toBe('https://test.api/api/v1/test/123') + const [request, options] = mockFetch.mock.calls[0] + expect(toUrlString(request)).toBe('https://test.api/api/v1/test/123') expect(options?.method).toBe('DELETE') expect(options?.body).toBeUndefined() }) @@ -203,7 +184,7 @@ describe('createCodebuffApiClient', () => { describe('response handling', () => { test('should return ok response with data', async () => { const responseData = { id: 'user-123', email: 'test@example.com' } - const mockSuccessFetch = mock(() => + const mockSuccessFetch = mock(() => Promise.resolve({ ok: true, status: 200, @@ -226,7 +207,7 @@ describe('createCodebuffApiClient', () => { }) test('should return error response with message', async () => { - const mockErrorFetch = mock(() => + const mockErrorFetch = mock(() => Promise.resolve({ ok: false, status: 401, @@ -252,7 +233,7 @@ describe('createCodebuffApiClient', () => { test('should handle non-JSON error responses', async () => { // This partial Response mock is acceptable - it tests a specific error path // where json() rejects and we fall back to text() - const mockErrorFetch = mock(() => + const mockErrorFetch = mock(() => Promise.resolve({ ok: false, status: 500, @@ -277,7 +258,7 @@ describe('createCodebuffApiClient', () => { }) test('should handle 204 No Content responses', async () => { - const mockNoContentFetch = mock(() => + const mockNoContentFetch = mock(() => Promise.resolve({ ok: true, status: 204, @@ -300,7 +281,7 @@ describe('createCodebuffApiClient', () => { describe('retry logic', () => { test('should retry on 500 errors', async () => { let callCount = 0 - const mockRetryFetch = mock(() => { + const mockRetryFetch = mock(() => { callCount++ if (callCount < 3) { return Promise.resolve({ @@ -334,7 +315,7 @@ describe('createCodebuffApiClient', () => { }) test('should not retry on 400 errors', async () => { - const mockBadRequestFetch = mock(() => + const mockBadRequestFetch = mock(() => Promise.resolve({ ok: false, status: 400, @@ -357,7 +338,7 @@ describe('createCodebuffApiClient', () => { }) test('should respect retry: false option', async () => { - const mockServerErrorFetch = mock(() => + const mockServerErrorFetch = mock(() => Promise.resolve({ ok: false, status: 500, @@ -380,7 +361,7 @@ describe('createCodebuffApiClient', () => { test('should retry on network errors', async () => { let callCount = 0 - const mockNetworkErrorFetch = mock(() => { + const mockNetworkErrorFetch = mock(() => { callCount++ if (callCount < 2) { return Promise.reject(new Error('Network error: fetch failed')) @@ -409,8 +390,8 @@ describe('createCodebuffApiClient', () => { test('should pass abort signal to fetch', async () => { let receivedSignal: AbortSignal | null | undefined - const mockFetchWithSignal = mock( - async (_url: string, options?: RequestInit) => { + const mockFetchWithSignal = mock( + async (_input: RequestInfo | URL, options?: RequestInit) => { receivedSignal = options?.signal return { ok: true, @@ -433,7 +414,7 @@ describe('createCodebuffApiClient', () => { }) test('should handle abort error from fetch', async () => { - const mockAbortFetch = mock(() => { + const mockAbortFetch = mock(() => { const error = new Error('The operation was aborted') error.name = 'AbortError' return Promise.reject(error) @@ -464,10 +445,7 @@ describe('createCodebuffApiClient', () => { retry: false, }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({ 'X-Custom-Header': 'custom-value', Authorization: 'Bearer my-token', diff --git a/common/src/testing/fixtures/billing.ts b/common/src/testing/fixtures/billing.ts index 6aad77d9e..26b491e8b 100644 --- a/common/src/testing/fixtures/billing.ts +++ b/common/src/testing/fixtures/billing.ts @@ -100,12 +100,12 @@ export function createGrantCreditsDbMock( insert: () => ({ values: () => Promise.resolve(), }), - select: () => createChainableQuery([] as never[]), + select: () => createChainableQuery([]), }) return { transaction: async (callback) => callback(createTx()), - select: () => createChainableQuery([] as never[]), + select: () => createChainableQuery([]), } } @@ -254,7 +254,7 @@ export interface CreditDelegationDbConn { innerJoin?: () => { where: () => Promise } - where: () => Promise + where: () => Promise } } } @@ -292,7 +292,7 @@ export function createCreditDelegationDbMock( innerJoin: () => ({ where: () => Promise.resolve(userOrganizations), }), - where: () => Promise.resolve([] as never[]), + where: () => Promise.resolve([]), }), } } @@ -309,7 +309,7 @@ export function createCreditDelegationDbMock( // Default: return empty array return { from: () => ({ - where: () => Promise.resolve([] as never[]), + where: () => Promise.resolve([]), }), } }, diff --git a/common/src/testing/fixtures/fetch.ts b/common/src/testing/fixtures/fetch.ts index c6ebcae18..c4b9bdb80 100644 --- a/common/src/testing/fixtures/fetch.ts +++ b/common/src/testing/fixtures/fetch.ts @@ -13,13 +13,22 @@ import { mock } from 'bun:test' */ export type FetchFn = typeof globalThis.fetch +/** + * Call signature for fetch without additional properties (e.g. `preconnect`). + * This is the type `bun:test` can easily mock. + */ +export type FetchCallFn = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise + /** * Type for a mock fetch function that can be used in place of globalThis.fetch. * Includes the mock utilities from bun:test. */ export type MockFetchFn = FetchFn & { mock: { - calls: Array<[RequestInfo | URL, RequestInit | undefined]> + calls: Array> } } @@ -55,6 +64,13 @@ const DEFAULT_STATUS_TEXT: Record = { 503: 'Service Unavailable', } +function withPreconnect( + mockFn: ReturnType>, +): MockFetchFn { + const preconnect: FetchFn['preconnect'] = () => {} + return Object.assign(mockFn, { preconnect }) +} + /** * Creates a typed mock fetch function that returns a configured Response. * @@ -76,7 +92,9 @@ const DEFAULT_STATUS_TEXT: Record = { * agentRuntimeImpl.fetch = mockFetch * ``` */ -export function createMockFetch(config: MockFetchResponseConfig = {}): FetchFn { +export function createMockFetch( + config: MockFetchResponseConfig = {}, +): MockFetchFn { const { status = 200, statusText = DEFAULT_STATUS_TEXT[status] ?? '', @@ -94,23 +112,15 @@ export function createMockFetch(config: MockFetchResponseConfig = {}): FetchFn { responseHeaders['Content-Type'] = 'application/json' } - const mockFn = mock( - (_input: RequestInfo | URL, _init?: RequestInit): Promise => { - return Promise.resolve( - new Response(responseBody, { - status, - statusText, - headers: responseHeaders, - }), - ) - }, + return withPreconnect( + mock(async () => { + return new Response(responseBody, { + status, + statusText, + headers: responseHeaders, + }) + }), ) - - // Add preconnect stub to match fetch interface - const fetchFn = mockFn as unknown as FetchFn - fetchFn.preconnect = () => {} - - return fetchFn } /** @@ -123,18 +133,12 @@ export function createMockFetch(config: MockFetchResponseConfig = {}): FetchFn { * agentRuntimeImpl.fetch = mockFetch * ``` */ -export function createMockFetchError(error: Error): FetchFn { - const mockFn = mock( - (_input: RequestInfo | URL, _init?: RequestInit): Promise => { - return Promise.reject(error) - }, +export function createMockFetchError(error: Error): MockFetchFn { + return withPreconnect( + mock(async () => { + throw error + }), ) - - // Add preconnect stub to match fetch interface - const fetchFn = mockFn as unknown as FetchFn - fetchFn.preconnect = () => {} - - return fetchFn } /** @@ -155,14 +159,8 @@ export function createMockFetchError(error: Error): FetchFn { */ export function createMockFetchCustom( implementation: (input: RequestInfo | URL, init?: RequestInit) => Promise, -): FetchFn { - const mockFn = mock(implementation) - - // Add preconnect stub to match fetch interface - const fetchFn = mockFn as unknown as FetchFn - fetchFn.preconnect = () => {} - - return fetchFn +): MockFetchFn { + return withPreconnect(mock(implementation)) } /** @@ -172,26 +170,37 @@ export function createMockFetchCustom( * @example * ```ts * const mockFetch = createMockFetchPartial({ - * ok: true, * status: 200, * json: () => Promise.resolve({ id: 'test' }), * }) * ``` */ export function createMockFetchPartial( - response: Partial & { ok: boolean; status: number }, -): FetchFn { - const mockFn = mock( - (_input: RequestInfo | URL, _init?: RequestInit): Promise => { - return Promise.resolve(response as Response) - }, + response: { + status: number + statusText?: string + headers?: HeadersInit + body?: BodyInit | null + json?: Response['json'] + text?: Response['text'] + }, +): MockFetchFn { + const { status, statusText, headers, body } = response + const json = response.json + const text = response.text + + return withPreconnect( + mock(async () => { + const res = new Response(body ?? '', { status, statusText, headers }) + if (json) { + Object.defineProperty(res, 'json', { value: json }) + } + if (text) { + Object.defineProperty(res, 'text', { value: text }) + } + return res + }), ) - - // Add preconnect stub to match fetch interface - const fetchFn = mockFn as unknown as FetchFn - fetchFn.preconnect = () => {} - - return fetchFn } /** @@ -200,15 +209,15 @@ export function createMockFetchPartial( * * @example * ```ts - * const mockFn = mock(() => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) } as Response)) + * const mockFn = mock(() => + * Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) } as Response), + * ) * const typedFetch = wrapMockAsFetch(mockFn) * // Now you can use typedFetch without casting and still access mockFn.mock.calls * ``` */ export function wrapMockAsFetch( - mockFn: ReturnType, -): FetchFn { - const fetchFn = mockFn as unknown as FetchFn - fetchFn.preconnect = () => {} - return fetchFn + mockFn: ReturnType>, +): MockFetchFn { + return withPreconnect(mockFn) } diff --git a/common/src/testing/fixtures/index.ts b/common/src/testing/fixtures/index.ts index 723229547..8dba9a055 100644 --- a/common/src/testing/fixtures/index.ts +++ b/common/src/testing/fixtures/index.ts @@ -54,6 +54,7 @@ export { createMockFetchPartial, wrapMockAsFetch, type FetchFn, + type FetchCallFn, type MockFetchFn, type MockFetchResponseConfig, } from './fetch' diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 35bc41fce..73e6d8e26 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -9,7 +9,6 @@ import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-run import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import * as bigquery from '@codebuff/bigquery' -import db from '@codebuff/internal/db' import { afterAll, afterEach, @@ -65,21 +64,6 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => llmCallCount = 0 - // Setup spies for database operations - spyOn(db, 'insert').mockReturnValue({ - values: mock(() => { - return Promise.resolve({ id: 'test-run-id' }) - }), - } as any) - - spyOn(db, 'update').mockReturnValue({ - set: mock(() => ({ - where: mock(() => { - return Promise.resolve() - }), - })), - } as any) - agentRuntimeImpl.promptAiSdkStream = async function* ({}) { llmCallCount++ yield { type: 'text' as const, text: 'LLM response\n\n' } diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index f62a395c2..210e104eb 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -8,7 +8,6 @@ import { import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' -import db from '@codebuff/internal/db' import { afterAll, afterEach, @@ -68,17 +67,6 @@ describe('runAgentStep - set_output tool', () => { stepPrompt: 'Test agent step prompt', } - // Setup spies for database operations - spyOn(db, 'insert').mockReturnValue({ - values: mock(() => Promise.resolve({ id: 'test-run-id' })), - } as any) - - spyOn(db, 'update').mockReturnValue({ - set: mock(() => ({ - where: mock(() => Promise.resolve()), - })), - } as any) - // Mock analytics and tracing mockAnalytics(analytics) mockBigQuery(bigquery) diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index 315fb4721..638d496f7 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -21,6 +21,7 @@ import { spyOn, } from 'bun:test' import { cloneDeep } from 'lodash' +import { z } from 'zod/v4' import { clearAgentGeneratorCache, @@ -856,15 +857,11 @@ describe('runProgrammaticStep', () => { const schemaTemplate = { ...mockTemplate, outputMode: 'structured_output' as const, - outputSchema: { - type: 'object', - properties: { - message: { type: 'string' }, - status: { type: 'string', enum: ['success', 'error'] }, - count: { type: 'number' }, - }, - required: ['message', 'status'], - }, + outputSchema: z.object({ + message: z.string(), + status: z.enum(['success', 'error']), + count: z.number().optional(), + }), toolNames: ['set_output', 'end_turn'], } @@ -889,7 +886,7 @@ describe('runProgrammaticStep', () => { ...mockParams, template: schemaTemplate, localAgentTemplates: { 'test-agent': schemaTemplate }, - } as any) + }) expect(result.endTurn).toBe(true) expect(result.agentState.output).toEqual({ @@ -904,14 +901,10 @@ describe('runProgrammaticStep', () => { const schemaTemplate = { ...mockTemplate, outputMode: 'structured_output' as const, - outputSchema: { - type: 'object', - properties: { - message: { type: 'string' }, - status: { type: 'string', enum: ['success', 'error'] }, - }, - required: ['message', 'status'], - }, + outputSchema: z.object({ + message: z.string(), + status: z.enum(['success', 'error']), + }), toolNames: ['set_output', 'end_turn'], } @@ -939,7 +932,7 @@ describe('runProgrammaticStep', () => { ...mockParams, template: schemaTemplate, localAgentTemplates: { 'test-agent': schemaTemplate }, - } as any) + }) // Should end turn (validation may fail but execution continues) expect(result.endTurn).toBe(true) @@ -1413,8 +1406,23 @@ describe('runProgrammaticStep', () => { if (options.toolName === 'set_output') { options.agentState.output = options.input } else if (options.toolName === 'add_subgoal') { - options.agentState.agentContext[options.input.id as any] = { - ...options.input, + const id = options.input['id'] + const objective = options.input['objective'] + const plan = options.input['plan'] + const status = options.input['status'] + + if (typeof id !== 'string') return + + options.agentState.agentContext[id] = { + objective: typeof objective === 'string' ? objective : undefined, + plan: typeof plan === 'string' ? plan : undefined, + status: + status === 'NOT_STARTED' || + status === 'IN_PROGRESS' || + status === 'COMPLETE' || + status === 'ABORTED' + ? status + : undefined, logs: [], } } @@ -1446,9 +1454,10 @@ describe('runProgrammaticStep', () => { describe('yield value validation', () => { it('should reject invalid yield values', async () => { - const mockGenerator = (function* () { - yield { invalid: 'value' } as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - intentionally invalid yield value to test runtime validation + yield { invalid: 'value' } + })() mockTemplate.handleSteps = () => mockGenerator @@ -1464,9 +1473,10 @@ describe('runProgrammaticStep', () => { }) it('should reject yield values with wrong types', async () => { - const mockGenerator = (function* () { - yield { type: 'STEP_TEXT', text: 123 } as any // text should be string - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - text should be string to satisfy schema + yield { type: 'STEP_TEXT', text: 123 } + })() mockTemplate.handleSteps = () => mockGenerator @@ -1481,10 +1491,11 @@ describe('runProgrammaticStep', () => { ) }) - it('should reject GENERATE_N with non-positive n', async () => { - const mockGenerator = (function* () { - yield { type: 'GENERATE_N', n: 0 } as any - })() as StepGenerator + it('should reject GENERATE_N with non-positive n', async () => { + const mockGenerator = (function* (): StepGenerator { + // Intentionally invalid per runtime schema: n must be positive + yield { type: 'GENERATE_N', n: 0 } + })() mockTemplate.handleSteps = () => mockGenerator @@ -1499,10 +1510,11 @@ describe('runProgrammaticStep', () => { ) }) - it('should reject GENERATE_N with negative n', async () => { - const mockGenerator = (function* () { - yield { type: 'GENERATE_N', n: -5 } as any - })() as StepGenerator + it('should reject GENERATE_N with negative n', async () => { + const mockGenerator = (function* (): StepGenerator { + // Intentionally invalid per runtime schema: n must be positive + yield { type: 'GENERATE_N', n: -5 } + })() mockTemplate.handleSteps = () => mockGenerator @@ -1607,9 +1619,10 @@ describe('runProgrammaticStep', () => { }) it('should reject random string values', async () => { - const mockGenerator = (function* () { - yield 'INVALID_STEP' as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - intentionally invalid yield value to test runtime validation + yield 'INVALID_STEP' + })() mockTemplate.handleSteps = () => mockGenerator @@ -1622,9 +1635,10 @@ describe('runProgrammaticStep', () => { }) it('should reject null yield values', async () => { - const mockGenerator = (function* () { - yield null as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - intentionally invalid yield value to test runtime validation + yield null + })() mockTemplate.handleSteps = () => mockGenerator @@ -1637,9 +1651,10 @@ describe('runProgrammaticStep', () => { }) it('should reject undefined yield values', async () => { - const mockGenerator = (function* () { - yield undefined as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - intentionally invalid yield value to test runtime validation + yield undefined + })() mockTemplate.handleSteps = () => mockGenerator @@ -1652,9 +1667,10 @@ describe('runProgrammaticStep', () => { }) it('should reject tool call without toolName', async () => { - const mockGenerator = (function* () { - yield { input: { paths: ['test.txt'] } } as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - tool calls must include toolName + yield { input: { paths: ['test.txt'] } } + })() mockTemplate.handleSteps = () => mockGenerator @@ -1667,9 +1683,10 @@ describe('runProgrammaticStep', () => { }) it('should reject tool call without input', async () => { - const mockGenerator = (function* () { - yield { toolName: 'read_files' } as any - })() as StepGenerator + const mockGenerator = (function* (): StepGenerator { + // @ts-expect-error - tool calls must include input + yield { toolName: 'read_files' } + })() mockTemplate.handleSteps = () => mockGenerator diff --git a/packages/billing/src/__tests__/org-billing.test.ts b/packages/billing/src/__tests__/org-billing.test.ts index 3f4ac8f54..5ee08eab2 100644 --- a/packages/billing/src/__tests__/org-billing.test.ts +++ b/packages/billing/src/__tests__/org-billing.test.ts @@ -45,6 +45,16 @@ const mockGrants: OrgBillingGrant[] = [ const logger = testLogger +class PgUniqueViolationError extends Error { + code = '23505' + constraint: string + + constructor(message: string, constraint: string) { + super(message) + this.constraint = constraint + } +} + describe('Organization Billing', () => { afterEach(() => { mock.restore() @@ -216,10 +226,10 @@ describe('Organization Billing', () => { grants: mockGrants, insert: () => ({ values: () => { - const error = new Error('Duplicate key') - ;(error as any).code = '23505' - ;(error as any).constraint = 'credit_ledger_pkey' - throw error + throw new PgUniqueViolationError( + 'Duplicate key', + 'credit_ledger_pkey', + ) }, }), }) diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index 0985ded73..e26230ce1 100644 --- a/packages/internal/src/utils/__tests__/version-utils.test.ts +++ b/packages/internal/src/utils/__tests__/version-utils.test.ts @@ -7,8 +7,6 @@ import { import * as versionUtils from '../version-utils' -import type { CodebuffPgDatabase } from '../../db/types' - const { versionOne, parseVersion, @@ -129,7 +127,7 @@ describe('version-utils', () => { describe('getLatestAgentVersion', () => { it('should return version 0.0.0 when no agent exists', async () => { - const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase + const mockDb = createVersionQueryDbMock([]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -142,7 +140,7 @@ describe('version-utils', () => { it('should return latest version when agent exists', async () => { const mockDb = createVersionQueryDbMock([ { major: 1, minor: 2, patch: 3 }, - ]) as unknown as CodebuffPgDatabase + ]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -155,7 +153,7 @@ describe('version-utils', () => { it('should handle null values in database response', async () => { const mockDb = createVersionQueryDbMock([ { major: null, minor: null, patch: null }, - ]) as unknown as CodebuffPgDatabase + ]) const result = await getLatestAgentVersion({ agentId: 'test-agent', @@ -170,7 +168,7 @@ describe('version-utils', () => { it('should increment patch of latest version when no version provided', async () => { const mockDb = createVersionQueryDbMock([ { major: 1, minor: 2, patch: 3 }, - ]) as unknown as CodebuffPgDatabase + ]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -181,7 +179,7 @@ describe('version-utils', () => { }) it('should use provided version when higher than latest', async () => { - const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase + const mockDb = createVersionQueryDbMock([]) const result = await determineNextVersion({ agentId: 'test-agent', @@ -195,7 +193,7 @@ describe('version-utils', () => { it('should throw error when provided version is not greater than latest', async () => { const mockDb = createVersionQueryDbMock([ { major: 2, minor: 0, patch: 0 }, - ]) as unknown as CodebuffPgDatabase + ]) await expect( determineNextVersion({ @@ -212,7 +210,7 @@ describe('version-utils', () => { it('should throw error when provided version equals latest', async () => { const mockDb = createVersionQueryDbMock([ { major: 1, minor: 5, patch: 0 }, - ]) as unknown as CodebuffPgDatabase + ]) await expect( determineNextVersion({ @@ -227,7 +225,7 @@ describe('version-utils', () => { }) it('should throw error for invalid provided version', async () => { - const mockDb = createVersionQueryDbMock([]) as unknown as CodebuffPgDatabase + const mockDb = createVersionQueryDbMock([]) await expect( determineNextVersion({ @@ -246,7 +244,7 @@ describe('version-utils', () => { it('should return true when version exists', async () => { const mockDb = createExistsQueryDbMock([ { id: 'test-agent' }, - ]) as unknown as CodebuffPgDatabase + ]) const result = await versionExists({ agentId: 'test-agent', @@ -258,7 +256,7 @@ describe('version-utils', () => { }) it('should return false when version does not exist', async () => { - const mockDb = createExistsQueryDbMock([]) as unknown as CodebuffPgDatabase + const mockDb = createExistsQueryDbMock([]) 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..0c7403e06 100644 --- a/packages/internal/src/utils/version-utils.ts +++ b/packages/internal/src/utils/version-utils.ts @@ -2,10 +2,51 @@ import { and, desc, eq } from 'drizzle-orm' import * as schema from '@codebuff/internal/db/schema' -import type { CodebuffPgDatabase } from '../db/types' - export type Version = { major: number; minor: number; patch: number } +type LatestAgentVersionRow = { + major: number | null + minor: number | null + patch: number | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isNumberOrNull(value: unknown): value is number | null { + return value === null || typeof value === 'number' +} + +function isLatestAgentVersionRow(value: unknown): value is LatestAgentVersionRow { + if (!isRecord(value)) return false + return ( + isNumberOrNull(value.major) && + isNumberOrNull(value.minor) && + isNumberOrNull(value.patch) + ) +} + +export type LatestAgentVersionDb = { + select(selection: Record): { + from(table: unknown): { + where(condition: unknown): { + orderBy(...orderBy: unknown[]): { + limit(n: number): PromiseLike + } + } + } + } +} + +export type VersionExistsDb = { + select(): { + from(table: unknown): { + where(condition: unknown): PromiseLike + } + } +} + export function versionOne(): Version { return { major: 0, minor: 0, patch: 1 } } @@ -54,11 +95,11 @@ export function isGreater(v1: Version, v2: Version): boolean { export async function getLatestAgentVersion(params: { agentId: string publisherId: string - db: CodebuffPgDatabase + db: LatestAgentVersionDb }): Promise { const { agentId, publisherId, db } = params - const latestAgent = await db + const latestAgentRaw = await db .select({ major: schema.agentConfig.major, minor: schema.agentConfig.minor, @@ -79,6 +120,10 @@ export async function getLatestAgentVersion(params: { .limit(1) .then((rows) => rows[0]) + const latestAgent = isLatestAgentVersionRow(latestAgentRaw) + ? latestAgentRaw + : undefined + return { major: latestAgent?.major ?? 0, minor: latestAgent?.minor ?? 0, @@ -96,7 +141,7 @@ export async function determineNextVersion(params: { agentId: string publisherId: string providedVersion?: string - db: CodebuffPgDatabase + db: LatestAgentVersionDb }): Promise { const { agentId, publisherId, providedVersion, db } = params @@ -137,7 +182,7 @@ export async function versionExists(params: { agentId: string version: Version publisherId: string - db: CodebuffPgDatabase + db: VersionExistsDb }): Promise { const { agentId, version, publisherId, db } = params diff --git a/sdk/src/__tests__/client.test.ts b/sdk/src/__tests__/client.test.ts index a1b6b064b..20c68967a 100644 --- a/sdk/src/__tests__/client.test.ts +++ b/sdk/src/__tests__/client.test.ts @@ -1,4 +1,4 @@ -import { wrapMockAsFetch } from '@codebuff/common/testing/fixtures' +import { wrapMockAsFetch, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { describe, expect, test, mock, afterEach } from 'bun:test' import { CodebuffClient } from '../client' @@ -6,7 +6,7 @@ import { CodebuffClient } from '../client' describe('CodebuffClient', () => { const originalFetch = globalThis.fetch - const setFetchMock = (mockFetch: ReturnType) => { + const setFetchMock = (mockFetch: ReturnType>) => { globalThis.fetch = wrapMockAsFetch(mockFetch) } @@ -16,7 +16,7 @@ describe('CodebuffClient', () => { describe('checkConnection', () => { test('returns true when healthz responds with status ok', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'ok' }), @@ -33,7 +33,7 @@ describe('CodebuffClient', () => { }) test('returns false when response is not ok', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: false, json: () => Promise.resolve({ status: 'ok' }), @@ -50,7 +50,7 @@ describe('CodebuffClient', () => { }) test('returns false when status is not ok', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: 'error' }), @@ -66,7 +66,7 @@ describe('CodebuffClient', () => { }) test('returns false when response is not valid JSON', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.reject(new Error('Invalid JSON')), @@ -82,7 +82,9 @@ describe('CodebuffClient', () => { }) test('returns false when fetch throws an error', async () => { - const mockFetch = mock(() => Promise.reject(new Error('Network error'))) + const mockFetch = mock(() => + Promise.reject(new Error('Network error')), + ) setFetchMock(mockFetch) @@ -93,7 +95,7 @@ describe('CodebuffClient', () => { }) test('returns false when response body is not an object', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve('not an object'), @@ -109,7 +111,7 @@ describe('CodebuffClient', () => { }) test('returns false when response body is null', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve(null), @@ -125,7 +127,7 @@ describe('CodebuffClient', () => { }) test('returns false when response body has no status field', async () => { - const mockFetch = mock(() => + const mockFetch = mock(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ message: 'healthy' }), diff --git a/sdk/src/__tests__/code-search.test.ts b/sdk/src/__tests__/code-search.test.ts index 8739d4c63..124787dd1 100644 --- a/sdk/src/__tests__/code-search.test.ts +++ b/sdk/src/__tests__/code-search.test.ts @@ -5,41 +5,35 @@ import { describe, expect, it, beforeEach, afterEach, spyOn } from 'bun:test' import { codeSearchWithSpawn, type SpawnFn } from '../tools/code-search' -import type { ChildProcessByStdio } from 'child_process' import type { Readable } from 'stream' -// Helper to create a mock child process with proper ChildProcess shape -function createMockChildProcess(): ChildProcessByStdio { - const proc = new EventEmitter() as unknown as ChildProcessByStdio< - null, - Readable, - Readable - > - const stdout: Readable = new PassThrough() - const stderr: Readable = new PassThrough() - - Object.defineProperty(proc, 'stdout', { value: stdout, writable: false }) - Object.defineProperty(proc, 'stderr', { value: stderr, writable: false }) - Object.defineProperty(proc, 'stdin', { value: null, writable: false }) - Object.defineProperty(proc, 'stdio', { - value: [null, stdout, stderr, undefined, undefined], - writable: false, - }) - Object.defineProperty(proc, 'pid', { value: 12345, writable: false }) - Object.defineProperty(proc, 'killed', { value: false, writable: true }) - Object.defineProperty(proc, 'connected', { value: false, writable: false }) - ;(proc as any).kill = () => true - ;(proc as any).disconnect = () => {} - ;(proc as any).unref = () => proc - ;(proc as any).ref = () => proc - - return proc +class MockSpawnedProcess extends EventEmitter { + stdout: Readable + stderr: Readable + + constructor() { + super() + this.stdout = new PassThrough() + this.stderr = new PassThrough() + } + + kill(_signal?: NodeJS.Signals | number): boolean { + return true + } +} + +function createMockChildProcess() { + return new MockSpawnedProcess() } /** Creates a typed mock spawn function that captures calls and returns controlled processes */ function createMockSpawn() { let currentProcess = createMockChildProcess() - const calls: Array<{ command: string; args: string[]; options: any }> = [] + const calls: Array<{ + command: Parameters[0] + args: string[] + options: Parameters[2] + }> = [] const spawn: SpawnFn = (command, args, options) => { calls.push({ command, args: [...args], options }) @@ -90,6 +84,27 @@ function createRgJsonContext( describe('codeSearch', () => { let mockSpawn: ReturnType + type CodeSearchOutput = Awaited> + type CodeSearchValue = CodeSearchOutput[0]['value'] + + function assertHasStdout( + value: CodeSearchValue, + ): asserts value is Extract { + if (!('stdout' in value)) { + throw new Error( + `Expected stdout but got errorMessage: ${value.errorMessage}`, + ) + } + } + + function assertHasErrorMessage( + value: CodeSearchValue, + ): asserts value is Extract { + if (!('errorMessage' in value)) { + throw new Error('Expected errorMessage') + } + } + beforeEach(() => { mockSpawn = createMockSpawn() }) @@ -110,12 +125,13 @@ describe('codeSearch', () => { createRgJsonMatch('file2.ts', 10, 'import React from "react"'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file1.ts:') expect(value.stdout).toContain('file2.ts:') }) @@ -139,12 +155,13 @@ describe('codeSearch', () => { createRgJsonContext('other.ts', 7, 'const port = env.PORT'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should contain match lines expect(value.stdout).toContain('import { env } from "./config"') @@ -174,11 +191,12 @@ describe('codeSearch', () => { createRgJsonMatch('utils.ts', 10, 'export function helper() {}'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should contain match lines expect(value.stdout).toContain('export const main = () => {}') @@ -204,11 +222,12 @@ describe('codeSearch', () => { createRgJsonContext('code.ts', 7, ' return null'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should contain match line expect(value.stdout).toContain('TODO: implement this') @@ -232,11 +251,12 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 4, ''), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should contain all matches expect(value.stdout).toContain('import foo from "foo"') @@ -256,11 +276,12 @@ describe('codeSearch', () => { // First line match has no before context const output = createRgJsonMatch('file.ts', 1, 'import foo from "foo"') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should still work with match at file start expect(value.stdout).toContain('import foo from "foo"') @@ -278,11 +299,12 @@ describe('codeSearch', () => { createRgJsonMatch('file2.ts', 5, 'another test'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should not contain '--' separator expect(value.stdout).not.toContain('--') @@ -302,11 +324,12 @@ describe('codeSearch', () => { createRgJsonMatch('other-file.ts', 5, 'import bar'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Files are formatted with filename on its own line followed by content expect(value.stdout).toContain('my-file.ts:') @@ -328,11 +351,12 @@ describe('codeSearch', () => { 'test content', ) - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should parse correctly despite multiple hyphens in filename expect(value.stdout).toContain('my-complex_file-name.ts:') @@ -352,11 +376,12 @@ describe('codeSearch', () => { createRgJsonMatch('other.ts', 1, 'import env'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Output should be reasonably sized, not including entire file expect(value.stdout.length).toBeLessThan(2000) @@ -387,11 +412,12 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 16, 'context 4'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should be limited to 2 match results per file (context lines don't count toward limit) // Count how many 'test' matches are in the output @@ -427,11 +453,12 @@ describe('codeSearch', () => { createRgJsonContext('file2.ts', 6, 'context 4'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should be limited globally to 3 match results (context lines don't count) const matches = (value.stdout.match(/test \d/g) || []).length @@ -459,11 +486,12 @@ describe('codeSearch', () => { createRgJsonContext('file.ts', 5, 'context after 2'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should include the match expect(value.stdout).toContain('match line') @@ -489,11 +517,12 @@ describe('codeSearch', () => { createRgJsonMatch('file.ts', 2, 'another valid line'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should still process valid lines expect(value.stdout).toContain('valid line') @@ -506,11 +535,12 @@ describe('codeSearch', () => { pattern: 'nonexistent', }) - mockSpawn.process.stdout!.emit('data', Buffer.from('')) + mockSpawn.process.stdout.emit('data', Buffer.from('')) mockSpawn.process.emit('close', 1) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // formatCodeSearchOutput returns 'No results' for empty input expect(value.stdout).toBe('No results') @@ -528,11 +558,12 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.ts', 1, 'const x = -foo') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.ts:') expect(value.stdout).toContain('-foo') @@ -556,11 +587,12 @@ describe('codeSearch', () => { }, }) - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should not have double newlines or blank lines expect(value.stdout).not.toContain('\n\n\n') @@ -583,11 +615,12 @@ describe('codeSearch', () => { // Send as one chunk without trailing newline to simulate remainder scenario const output = `${match1}\n${match2}\n${match3}` - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // All three matches should be processed expect(value.stdout).toContain('file1.ts:') @@ -613,12 +646,13 @@ describe('codeSearch', () => { } const output = matches.join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) // Process won't get to close because it should kill early mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should have stopped early and included size limit message expect(value.stdout).toContain('Output size limit reached') @@ -643,11 +677,12 @@ describe('codeSearch', () => { }, }) - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) // Should handle path.bytes expect(value.stdout).toContain('file-with-bytes.ts:') @@ -668,12 +703,13 @@ describe('codeSearch', () => { createRgJsonMatch('file.ts', 5, 'import { baz } from "qux"'), ].join('\n') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.ts:') // Verify the args passed to spawn include the glob flag correctly @@ -692,12 +728,13 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise expect(result[0].type).toBe('json') - const value = result[0].value as any + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.tsx:') // Verify both glob patterns are passed correctly @@ -720,7 +757,7 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.tsx', 1, 'import React from "react"') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) await searchPromise @@ -755,7 +792,8 @@ describe('codeSearch', () => { mockSpawn.process.emit('close', null) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasErrorMessage(value) expect(value.errorMessage).toContain('timed out') }) @@ -771,14 +809,12 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.ts', 1, 'test content') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any - - // Should work correctly and not have an error - expect(value.errorMessage).toBeUndefined() + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.ts:') expect(value.stdout).toContain('test content') @@ -798,13 +834,12 @@ describe('codeSearch', () => { const output = createRgJsonMatch('file.ts', 1, 'test content') - mockSpawn.process.stdout!.emit('data', Buffer.from(output)) + mockSpawn.process.stdout.emit('data', Buffer.from(output)) mockSpawn.process.emit('close', 0) const result = await searchPromise - const value = result[0].value as any - - expect(value.errorMessage).toBeUndefined() + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.ts:') // Verify spawn was called with correct cwd @@ -821,7 +856,8 @@ describe('codeSearch', () => { }) const result = await searchPromise - const value = result[0].value as any + const value = result[0].value + assertHasErrorMessage(value) expect(value.errorMessage).toContain('outside the project directory') }) diff --git a/sdk/src/tools/code-search.ts b/sdk/src/tools/code-search.ts index c378b19af..e1a1af767 100644 --- a/sdk/src/tools/code-search.ts +++ b/sdk/src/tools/code-search.ts @@ -6,7 +6,6 @@ import { formatCodeSearchOutput } from '../../../common/src/util/format-code-sea import { getBundledRgPath } from '../native/ripgrep' import type { - ChildProcessByStdio, SpawnOptionsWithStdioTuple, StdioNull, StdioPipe, @@ -29,7 +28,18 @@ export type SpawnFn = ( command: string, args: readonly string[], options: SpawnOptionsWithStdioTuple, -) => ChildProcessByStdio +) => SpawnedProcess + +export type SpawnedProcess = { + stdout: Readable + stderr: Readable + kill: (signal?: NodeJS.Signals | number) => boolean + once: { + (event: 'close', listener: (code: number | null) => void): unknown + (event: 'error', listener: (error: Error) => void): unknown + } + removeAllListeners: (event?: string | symbol) => unknown +} export function codeSearchWithSpawn( spawn: SpawnFn, @@ -120,7 +130,9 @@ export function codeSearchWithSpawn( let estimatedOutputLen = 0 let killedForLimit = false - const settle = (payload: any) => { + type CodeSearchValue = CodebuffToolOutput<'code_search'>[0]['value'] + + const settle = (payload: CodeSearchValue) => { if (isResolved) return isResolved = true From 3d9f775a8712443c82e42b98bf1797444c098320 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 16 Dec 2025 23:24:05 -0800 Subject: [PATCH 07/17] refactor(billing): replace test-oriented db conn types --- common/src/testing/fixtures/billing.ts | 300 ++++++++---------- common/src/testing/fixtures/index.ts | 19 +- .../src/__tests__/credit-delegation.test.ts | 22 +- .../src/__tests__/grant-credits.test.ts | 22 +- .../billing/src/__tests__/org-billing.test.ts | 39 +-- packages/billing/src/balance-calculator.ts | 59 +++- packages/billing/src/credit-delegation.ts | 103 ++++-- packages/billing/src/grant-credits.ts | 247 ++++++++------ packages/billing/src/org-billing.ts | 155 +++++---- 9 files changed, 535 insertions(+), 431 deletions(-) diff --git a/common/src/testing/fixtures/billing.ts b/common/src/testing/fixtures/billing.ts index 26b491e8b..9f90ac23d 100644 --- a/common/src/testing/fixtures/billing.ts +++ b/common/src/testing/fixtures/billing.ts @@ -10,30 +10,6 @@ import type { Logger } from '../../types/contracts/logger' // Re-export the test logger for convenience export { testLogger } from './agent-runtime' -/** - * Chainable query builder mock - matches Drizzle's query builder pattern - */ -type ChainableQuery = { - from: () => ChainableQuery - where: () => ChainableQuery - orderBy: () => ChainableQuery - limit: () => TResult - innerJoin: () => ChainableQuery - then: (cb: (result: TResult) => TNext) => TNext -} - -function createChainableQuery(result: TResult): ChainableQuery { - const chain: ChainableQuery = { - from: () => chain, - where: () => chain, - orderBy: () => chain, - limit: () => result, - innerJoin: () => chain, - then: (cb) => cb(result), - } - return chain -} - // ============================================================================ // Grant Credits Mock (packages/billing/src/grant-credits.ts) // ============================================================================ @@ -43,69 +19,95 @@ export interface GrantCreditsMockOptions { next_quota_reset: Date | null auto_topup_enabled: boolean | null } | null + previousExpiredFreeGrantPrincipal?: number | null + totalReferralBonusCredits?: number +} + +export interface CreditLedgerGrant { + type: 'free' | 'referral' | 'purchase' | 'admin' | 'organization' + created_at: Date + expires_at: Date | null + operation_id: string + user_id: string + principal: number + balance: number + description: string | null + priority: number + org_id: string | null } /** - * Database connection shape for grant-credits module. - * Structurally matches BillingDbConn from grant-credits.ts + * Minimal data access interface for grant-credits module. + * Structurally matches GrantCreditsStore from packages/billing/src/grant-credits.ts */ -export interface GrantCreditsDbConn { - transaction: (callback: (tx: GrantCreditsTx) => Promise) => Promise - select: () => ChainableQuery +export interface GrantCreditsTxStore { + getMonthlyResetUser(params: { userId: string }): Promise + updateUserNextQuotaReset(params: { + userId: string + nextQuotaReset: Date + }): Promise + getMostRecentExpiredFreeGrantPrincipal(params: { + userId: string + now: Date + }): Promise + getTotalReferralBonusCredits(params: { userId: string }): Promise + listActiveCreditGrants(params: { + userId: string + now: Date + }): Promise + updateCreditLedgerBalance(params: { + operationId: string + balance: number + }): Promise + insertCreditLedgerEntry(values: Record): Promise } -export interface GrantCreditsTx { - query: { - user: { - findFirst: () => Promise - } - } - update: () => { set: () => { where: () => Promise } } - insert: () => { values: () => Promise } - select: () => ChainableQuery +/** + * Store interface for grant-credits. + */ +export interface GrantCreditsStore extends GrantCreditsTxStore { + withTransaction(callback: (tx: GrantCreditsTxStore) => Promise): Promise } /** - * Creates a typed mock database for grant-credits tests. + * Creates a typed mock store for grant-credits tests. * * @example * ```ts - * const mockDb = createGrantCreditsDbMock({ + * const mockStore = createGrantCreditsStoreMock({ * user: { next_quota_reset: futureDate, auto_topup_enabled: true }, * }) * * const result = await triggerMonthlyResetAndGrant({ * userId: 'user-123', * logger, - * conn: mockDb, + * store: mockStore, * }) * ``` */ -export function createGrantCreditsDbMock( +export function createGrantCreditsStoreMock( options: GrantCreditsMockOptions, -): GrantCreditsDbConn { - const { user } = options +): GrantCreditsStore { + const { + user, + previousExpiredFreeGrantPrincipal = null, + totalReferralBonusCredits = 0, + } = options - const createTx = (): GrantCreditsTx => ({ - query: { - user: { - findFirst: async () => user, - }, - }, - update: () => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - }), - insert: () => ({ - values: () => Promise.resolve(), - }), - select: () => createChainableQuery([]), - }) + const txStore: GrantCreditsTxStore = { + getMonthlyResetUser: async () => user, + updateUserNextQuotaReset: async () => {}, + getMostRecentExpiredFreeGrantPrincipal: async () => + previousExpiredFreeGrantPrincipal, + getTotalReferralBonusCredits: async () => totalReferralBonusCredits, + listActiveCreditGrants: async () => [], + updateCreditLedgerBalance: async () => {}, + insertCreditLedgerEntry: async () => {}, + } return { - transaction: async (callback) => callback(createTx()), - select: () => createChainableQuery([]), + ...txStore, + withTransaction: async (callback) => callback(txStore), } } @@ -116,7 +118,7 @@ export function createGrantCreditsDbMock( export interface OrgBillingGrant { operation_id: string user_id: string - organization_id: string + org_id: string principal: number balance: number type: 'organization' @@ -128,99 +130,79 @@ export interface OrgBillingGrant { export interface OrgBillingMockOptions { grants?: OrgBillingGrant[] - insert?: () => { values: () => Promise } - update?: () => { set: () => { where: () => Promise } } + insertCreditLedgerEntry?: (values: Record) => Promise + updateCreditLedgerBalance?: (params: { + operationId: string + balance: number + }) => Promise } /** - * Database connection shape for org-billing module. - * Structurally matches OrgBillingDbConn from org-billing.ts + * Transaction store interface for org-billing. + * Structurally matches OrgBillingTxStore from packages/billing/src/org-billing.ts */ -export interface OrgBillingDbConn { - select: () => { - from: () => { - where: () => { - orderBy: () => OrgBillingGrant[] - } - } - } - insert: () => { values: () => Promise } - update: () => { set: () => { where: () => Promise } } +export interface OrgBillingTxStore { + listOrderedActiveOrganizationGrants(params: { + organizationId: string + now: Date + }): Promise + insertCreditLedgerEntry(values: Record): Promise + updateCreditLedgerBalance(params: { + operationId: string + balance: number + }): Promise } /** - * Transaction wrapper function type for org-billing. + * Store interface for org-billing. + * Structurally matches OrgBillingStore from packages/billing/src/org-billing.ts */ -export type OrgBillingWithTransactionFn = (params: { - callback: (tx: OrgBillingDbConn) => Promise - context: Record - logger: Logger -}) => Promise +export interface OrgBillingStore extends OrgBillingTxStore { + withTransaction(params: { + callback: (tx: OrgBillingTxStore) => Promise + context: Record + logger: Logger + }): Promise +} /** - * Creates a typed mock database for org-billing tests. + * Creates a typed mock store for org-billing tests. * * @example * ```ts - * const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + * const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) * * const result = await calculateOrganizationUsageAndBalance({ * organizationId: 'org-123', * quotaResetDate: new Date(), * now: new Date(), * logger, - * conn: mockDb, + * store: mockStore, * }) * ``` */ -export function createOrgBillingDbMock( +export function createOrgBillingStoreMock( options?: OrgBillingMockOptions, -): OrgBillingDbConn { - const { grants = [], insert, update } = options ?? {} +): OrgBillingStore { + const { grants = [], insertCreditLedgerEntry, updateCreditLedgerBalance } = + options ?? {} - return { - select: () => ({ - from: () => ({ - where: () => ({ - orderBy: () => grants, - }), + const txStore: OrgBillingTxStore = { + listOrderedActiveOrganizationGrants: async () => grants, + insertCreditLedgerEntry: + insertCreditLedgerEntry ?? + (async () => { + return }), - }), - insert: - insert ?? - (() => ({ - values: () => Promise.resolve(), - })), - update: - update ?? - (() => ({ - set: () => ({ - where: () => Promise.resolve(), - }), - })), + updateCreditLedgerBalance: async (params) => { + await updateCreditLedgerBalance?.(params) + }, } -} -/** - * Creates a mock transaction wrapper that immediately calls the callback. - * - * @example - * ```ts - * const mockDb = createOrgBillingDbMock({ grants: mockGrants }) - * const mockWithTransaction = createOrgBillingTransactionMock(mockDb) - * - * await consumeOrganizationCredits({ - * organizationId: 'org-123', - * creditsToConsume: 100, - * logger, - * withTransaction: mockWithTransaction, - * }) - * ``` - */ -export function createOrgBillingTransactionMock( - mockDb: OrgBillingDbConn, -): OrgBillingWithTransactionFn { - return async ({ callback }) => callback(mockDb) + return { + ...txStore, + withTransaction: async ({ callback }) => callback(txStore), + } } // ============================================================================ @@ -245,27 +227,22 @@ export interface CreditDelegationMockOptions { } /** - * Database connection shape for credit-delegation module. - * Structurally matches CreditDelegationDbConn from credit-delegation.ts + * Minimal data access interface for credit-delegation module. + * Structurally matches CreditDelegationStore from packages/billing/src/credit-delegation.ts */ -export interface CreditDelegationDbConn { - select: (fields: Record) => { - from: () => { - innerJoin?: () => { - where: () => Promise - } - where: () => Promise - } - } +export interface CreditDelegationStore { + listUserOrganizations(params: { userId: string }): Promise + listActiveOrganizationRepos(params: { + organizationId: string + }): Promise } /** - * Creates a typed mock database for credit-delegation tests. - * The select function inspects the fields to determine which data to return. + * Creates a typed mock data store for credit-delegation tests. * * @example * ```ts - * const mockDb = createCreditDelegationDbMock({ + * const mockStore = createCreditDelegationStoreMock({ * userOrganizations: [{ orgId: 'org-123', orgName: 'Test Org', orgSlug: 'test-org' }], * orgRepos: [{ repoUrl: 'https://github.com/test/repo', repoName: 'repo', isActive: true }], * }) @@ -274,44 +251,17 @@ export interface CreditDelegationDbConn { * userId: 'user-123', * repositoryUrl: 'https://github.com/test/repo', * logger, - * conn: mockDb, + * store: mockStore, * }) * ``` */ -export function createCreditDelegationDbMock( +export function createCreditDelegationStoreMock( options?: CreditDelegationMockOptions, -): CreditDelegationDbConn { +): CreditDelegationStore { const { userOrganizations = [], orgRepos = [] } = options ?? {} return { - select: (fields: Record) => { - // Return user organizations when querying for orgId/orgName fields - if ('orgId' in fields && 'orgName' in fields) { - return { - from: () => ({ - innerJoin: () => ({ - where: () => Promise.resolve(userOrganizations), - }), - where: () => Promise.resolve([]), - }), - } - } - - // Return org repos when querying for repoUrl field - if ('repoUrl' in fields) { - return { - from: () => ({ - where: () => Promise.resolve(orgRepos), - }), - } - } - - // Default: return empty array - return { - from: () => ({ - where: () => Promise.resolve([]), - }), - } - }, + listUserOrganizations: async () => userOrganizations, + listActiveOrganizationRepos: async () => orgRepos.filter((repo) => repo.isActive), } } diff --git a/common/src/testing/fixtures/index.ts b/common/src/testing/fixtures/index.ts index 8dba9a055..fcf67a1f8 100644 --- a/common/src/testing/fixtures/index.ts +++ b/common/src/testing/fixtures/index.ts @@ -4,7 +4,7 @@ * Re-exports all test fixtures for cleaner imports: * @example * ```ts - * import { testLogger, createMockFetch, createGrantCreditsDbMock } from '@codebuff/common/testing/fixtures' + * import { testLogger, createMockFetch, createGrantCreditsStoreMock } from '@codebuff/common/testing/fixtures' * ``` */ @@ -22,21 +22,20 @@ export { // Billing database mock fixtures export { - createGrantCreditsDbMock, - createOrgBillingDbMock, - createOrgBillingTransactionMock, - createCreditDelegationDbMock, + createGrantCreditsStoreMock, + createOrgBillingStoreMock, + createCreditDelegationStoreMock, type GrantCreditsMockOptions, - type GrantCreditsDbConn, - type GrantCreditsTx, + type GrantCreditsStore, + type GrantCreditsTxStore, type OrgBillingGrant, type OrgBillingMockOptions, - type OrgBillingDbConn, - type OrgBillingWithTransactionFn, + type OrgBillingStore, + type OrgBillingTxStore, type UserOrganization, type OrgRepo, type CreditDelegationMockOptions, - type CreditDelegationDbConn, + type CreditDelegationStore, } from './billing' // Database mock fixtures diff --git a/packages/billing/src/__tests__/credit-delegation.test.ts b/packages/billing/src/__tests__/credit-delegation.test.ts index 96579c9f4..ec8a43eb2 100644 --- a/packages/billing/src/__tests__/credit-delegation.test.ts +++ b/packages/billing/src/__tests__/credit-delegation.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' import { - createCreditDelegationDbMock, + createCreditDelegationStoreMock, testLogger, } from '@codebuff/common/testing/fixtures' @@ -19,7 +19,7 @@ describe('Credit Delegation', () => { describe('findOrganizationForRepository', () => { it('should find organization for matching repository', async () => { - const mockDb = createCreditDelegationDbMock({ + const mockStore = createCreditDelegationStoreMock({ userOrganizations: [ { orgId: 'org-123', @@ -43,7 +43,7 @@ describe('Credit Delegation', () => { userId, repositoryUrl, logger, - conn: mockDb, + store: mockStore, }) expect(result.found).toBe(true) @@ -52,7 +52,7 @@ describe('Credit Delegation', () => { }) it('should return not found for non-matching repository', async () => { - const mockDb = createCreditDelegationDbMock({ + const mockStore = createCreditDelegationStoreMock({ userOrganizations: [ { orgId: 'org-123', @@ -76,14 +76,14 @@ describe('Credit Delegation', () => { userId, repositoryUrl, logger, - conn: mockDb, + store: mockStore, }) expect(result.found).toBe(false) }) it('should return not found when user has no organizations', async () => { - const mockDb = createCreditDelegationDbMock({ + const mockStore = createCreditDelegationStoreMock({ userOrganizations: [], orgRepos: [], }) @@ -95,7 +95,7 @@ describe('Credit Delegation', () => { userId, repositoryUrl, logger, - conn: mockDb, + store: mockStore, }) expect(result.found).toBe(false) @@ -104,7 +104,7 @@ describe('Credit Delegation', () => { describe('consumeCreditsWithDelegation', () => { it('should fail when no repository URL provided', async () => { - const mockDb = createCreditDelegationDbMock() + const mockStore = createCreditDelegationStoreMock() const userId = 'user-123' const repositoryUrl = null @@ -115,7 +115,7 @@ describe('Credit Delegation', () => { repositoryUrl, creditsToConsume, logger, - conn: mockDb, + store: mockStore, }) expect(result.success).toBe(false) @@ -123,7 +123,7 @@ describe('Credit Delegation', () => { }) it('should fail when no organization found for repository', async () => { - const mockDb = createCreditDelegationDbMock({ + const mockStore = createCreditDelegationStoreMock({ userOrganizations: [], orgRepos: [], }) @@ -137,7 +137,7 @@ describe('Credit Delegation', () => { repositoryUrl, creditsToConsume, logger, - conn: mockDb, + store: mockStore, }) expect(result.success).toBe(false) diff --git a/packages/billing/src/__tests__/grant-credits.test.ts b/packages/billing/src/__tests__/grant-credits.test.ts index 3a2849756..53c9eca5c 100644 --- a/packages/billing/src/__tests__/grant-credits.test.ts +++ b/packages/billing/src/__tests__/grant-credits.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' import { - createGrantCreditsDbMock, + createGrantCreditsStoreMock, testLogger, } from '@codebuff/common/testing/fixtures' @@ -20,7 +20,7 @@ describe('grant-credits', () => { describe('triggerMonthlyResetAndGrant', () => { describe('autoTopupEnabled return value', () => { it('should return autoTopupEnabled: true when user has auto_topup_enabled: true', async () => { - const mockDb = createGrantCreditsDbMock({ + const mockStore = createGrantCreditsStoreMock({ user: { next_quota_reset: futureDate, auto_topup_enabled: true, @@ -30,7 +30,7 @@ describe('grant-credits', () => { const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, - conn: mockDb, + store: mockStore, }) expect(result.autoTopupEnabled).toBe(true) @@ -38,7 +38,7 @@ describe('grant-credits', () => { }) it('should return autoTopupEnabled: false when user has auto_topup_enabled: false', async () => { - const mockDb = createGrantCreditsDbMock({ + const mockStore = createGrantCreditsStoreMock({ user: { next_quota_reset: futureDate, auto_topup_enabled: false, @@ -48,14 +48,14 @@ describe('grant-credits', () => { const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, - conn: mockDb, + store: mockStore, }) expect(result.autoTopupEnabled).toBe(false) }) it('should default autoTopupEnabled to false when user has auto_topup_enabled: null', async () => { - const mockDb = createGrantCreditsDbMock({ + const mockStore = createGrantCreditsStoreMock({ user: { next_quota_reset: futureDate, auto_topup_enabled: null, @@ -65,14 +65,14 @@ describe('grant-credits', () => { const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, - conn: mockDb, + store: mockStore, }) expect(result.autoTopupEnabled).toBe(false) }) it('should throw error when user is not found', async () => { - const mockDb = createGrantCreditsDbMock({ + const mockStore = createGrantCreditsStoreMock({ user: null, }) @@ -80,7 +80,7 @@ describe('grant-credits', () => { triggerMonthlyResetAndGrant({ userId: 'nonexistent-user', logger, - conn: mockDb, + store: mockStore, }), ).rejects.toThrow('User nonexistent-user not found') }) @@ -88,7 +88,7 @@ describe('grant-credits', () => { describe('quota reset behavior', () => { it('should return existing reset date when it is in the future', async () => { - const mockDb = createGrantCreditsDbMock({ + const mockStore = createGrantCreditsStoreMock({ user: { next_quota_reset: futureDate, auto_topup_enabled: false, @@ -98,7 +98,7 @@ describe('grant-credits', () => { const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, - conn: mockDb, + store: mockStore, }) expect(result.quotaResetDate).toEqual(futureDate) diff --git a/packages/billing/src/__tests__/org-billing.test.ts b/packages/billing/src/__tests__/org-billing.test.ts index 5ee08eab2..9c2ef6add 100644 --- a/packages/billing/src/__tests__/org-billing.test.ts +++ b/packages/billing/src/__tests__/org-billing.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, mock } from 'bun:test' import { - createOrgBillingDbMock, - createOrgBillingTransactionMock, + createOrgBillingStoreMock, testLogger, type OrgBillingGrant, } from '@codebuff/common/testing/fixtures' @@ -20,7 +19,7 @@ const mockGrants: OrgBillingGrant[] = [ { operation_id: 'org-grant-1', user_id: '', - organization_id: 'org-123', + org_id: 'org-123', principal: 1000, balance: 800, type: 'organization', @@ -32,7 +31,7 @@ const mockGrants: OrgBillingGrant[] = [ { operation_id: 'org-grant-2', user_id: '', - organization_id: 'org-123', + org_id: 'org-123', principal: 500, balance: -100, // Debt type: 'organization', @@ -62,7 +61,7 @@ describe('Organization Billing', () => { describe('calculateOrganizationUsageAndBalance', () => { it('should calculate balance correctly with positive and negative balances', async () => { - const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) const organizationId = 'org-123' const quotaResetDate = new Date('2024-01-01') const now = new Date('2024-06-01') @@ -72,7 +71,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, - conn: mockDb, + store: mockStore, }) // Total positive balance: 800 @@ -88,7 +87,7 @@ describe('Organization Billing', () => { it('should handle organization with no grants', async () => { // Mock empty grants - const mockDb = createOrgBillingDbMock({ grants: [] }) + const mockStore = createOrgBillingStoreMock({ grants: [] }) const organizationId = 'org-empty' const quotaResetDate = new Date('2024-01-01') @@ -99,7 +98,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, - conn: mockDb, + store: mockStore, }) expect(result.balance.totalRemaining).toBe(0) @@ -178,8 +177,7 @@ describe('Organization Billing', () => { describe('consumeOrganizationCredits', () => { it('should consume credits from organization grants', async () => { - const mockDb = createOrgBillingDbMock({ grants: mockGrants }) - const mockWithTransaction = createOrgBillingTransactionMock(mockDb) + const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) const organizationId = 'org-123' const creditsToConsume = 100 @@ -188,7 +186,7 @@ describe('Organization Billing', () => { organizationId, creditsToConsume, logger, - withTransaction: mockWithTransaction, + store: mockStore, }) expect(result.consumed).toBe(100) @@ -198,7 +196,7 @@ describe('Organization Billing', () => { describe('grantOrganizationCredits', () => { it('should create organization credit grant', async () => { - const mockDb = createOrgBillingDbMock({ grants: mockGrants }) + const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) const organizationId = 'org-123' const userId = 'user-123' @@ -215,23 +213,18 @@ describe('Organization Billing', () => { operationId, description, logger, - conn: mockDb, + store: mockStore, }), ).resolves.toBeUndefined() }) it('should handle duplicate operation IDs gracefully', async () => { // Mock database constraint error - const mockDb = createOrgBillingDbMock({ + const mockStore = createOrgBillingStoreMock({ grants: mockGrants, - insert: () => ({ - values: () => { - throw new PgUniqueViolationError( - 'Duplicate key', - 'credit_ledger_pkey', - ) - }, - }), + insertCreditLedgerEntry: async () => { + throw new PgUniqueViolationError('Duplicate key', 'credit_ledger_pkey') + }, }) const organizationId = 'org-123' @@ -249,7 +242,7 @@ describe('Organization Billing', () => { operationId, description, logger, - conn: mockDb, + store: mockStore, }), ).resolves.toBeUndefined() }) diff --git a/packages/billing/src/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index 23007f42f..b45da96db 100644 --- a/packages/billing/src/balance-calculator.ts +++ b/packages/billing/src/balance-calculator.ts @@ -102,21 +102,25 @@ export async function updateGrantBalance(params: { // ) } +type UpdateGrantBalanceFn = (params: { + userId: string + grant: typeof schema.creditLedger.$inferSelect + consumed: number + newBalance: number +}) => Promise + /** * Consumes credits from a list of ordered grants. + * Allows callers to provide a custom balance update implementation (e.g. for dependency injection). */ -export async function consumeFromOrderedGrants( - params: { - userId: string - creditsToConsume: number - grants: (typeof schema.creditLedger.$inferSelect)[] - logger: Logger - } & ParamsExcluding< - typeof updateGrantBalance, - 'grant' | 'consumed' | 'newBalance' - >, -): Promise { - const { userId, creditsToConsume, grants, logger } = params +export async function consumeFromOrderedGrantsWithUpdater(params: { + userId: string + creditsToConsume: number + grants: (typeof schema.creditLedger.$inferSelect)[] + logger: Logger + updateGrantBalance: UpdateGrantBalanceFn +}): Promise { + const { userId, creditsToConsume, grants, logger, updateGrantBalance } = params let remainingToConsume = creditsToConsume let consumed = 0 @@ -132,7 +136,7 @@ export async function consumeFromOrderedGrants( consumed += repayAmount await updateGrantBalance({ - ...params, + userId, grant, consumed: -repayAmount, newBalance, @@ -161,7 +165,7 @@ export async function consumeFromOrderedGrants( } await updateGrantBalance({ - ...params, + userId, grant, consumed: consumeFromThisGrant, newBalance, @@ -175,7 +179,7 @@ export async function consumeFromOrderedGrants( if (lastGrant.balance <= 0) { const newBalance = lastGrant.balance - remainingToConsume await updateGrantBalance({ - ...params, + userId, grant: lastGrant, consumed: remainingToConsume, newBalance, @@ -198,6 +202,31 @@ export async function consumeFromOrderedGrants( return { consumed, fromPurchased } } +/** + * Consumes credits from a list of ordered grants. + */ +export async function consumeFromOrderedGrants( + params: { + userId: string + creditsToConsume: number + grants: (typeof schema.creditLedger.$inferSelect)[] + logger: Logger + } & ParamsExcluding< + typeof updateGrantBalance, + 'grant' | 'consumed' | 'newBalance' + >, +): Promise { + const { userId, creditsToConsume, grants, tx, logger } = params + return consumeFromOrderedGrantsWithUpdater({ + userId, + creditsToConsume, + grants, + logger, + updateGrantBalance: ({ userId, grant, consumed, newBalance }) => + updateGrantBalance({ userId, grant, consumed, newBalance, tx, logger }), + }) +} + /** * Calculates both the current balance and usage in this cycle in a single query. * This is more efficient than calculating them separately. diff --git a/packages/billing/src/credit-delegation.ts b/packages/billing/src/credit-delegation.ts index d174c6b2f..ed2646af4 100644 --- a/packages/billing/src/credit-delegation.ts +++ b/packages/billing/src/credit-delegation.ts @@ -11,12 +11,60 @@ import { extractOwnerAndRepo, } from './org-billing' -// Minimal structural type for database connection -// This type is intentionally permissive to allow mock injection in tests -export type CreditDelegationDbConn = { - select: (fields?: any) => any +type UserOrganizationRow = { + orgId: string + orgName: string + orgSlug: string } +type OrganizationRepoRow = { + repoUrl: string + repoName: string + isActive: boolean +} + +/** + * Minimal data access interface for this module. + * Keeps production code well-typed without exposing Drizzle internals to callers/tests. + */ +export interface CreditDelegationStore { + listUserOrganizations(params: { userId: string }): Promise + listActiveOrganizationRepos(params: { + organizationId: string + }): Promise +} + +function createCreditDelegationStore(conn: typeof db): CreditDelegationStore { + return { + listUserOrganizations: async ({ userId }) => + conn + .select({ + orgId: schema.orgMember.org_id, + orgName: schema.org.name, + orgSlug: schema.org.slug, + }) + .from(schema.orgMember) + .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) + .where(eq(schema.orgMember.user_id, userId)), + listActiveOrganizationRepos: async ({ organizationId }) => + conn + .select({ + repoUrl: schema.orgRepo.repo_url, + repoName: schema.orgRepo.repo_name, + isActive: schema.orgRepo.is_active, + }) + .from(schema.orgRepo) + .where( + and( + eq(schema.orgRepo.org_id, organizationId), + eq(schema.orgRepo.is_active, true), + ), + ), + } +} + +const DEFAULT_STORE = createCreditDelegationStore(db) + import type { ConsumeCreditsWithFallbackFn } from '@codebuff/common/types/contracts/billing' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ParamsOf } from '@codebuff/common/types/function-params' @@ -46,12 +94,12 @@ export async function findOrganizationForRepository( userId: string repositoryUrl: string logger: Logger - conn: CreditDelegationDbConn + store: CreditDelegationStore }, - 'conn' + 'store' >, ): Promise { - const { conn = db, ...rest } = params + const { store = DEFAULT_STORE, ...rest } = params const { userId, repositoryUrl, logger } = rest try { @@ -67,15 +115,7 @@ export async function findOrganizationForRepository( } // First, check if user is a member of any organizations - const userOrganizations = await conn - .select({ - orgId: schema.orgMember.org_id, - orgName: schema.org.name, - orgSlug: schema.org.slug, // Select the slug - }) - .from(schema.orgMember) - .innerJoin(schema.org, eq(schema.orgMember.org_id, schema.org.id)) - .where(eq(schema.orgMember.user_id, userId)) + const userOrganizations = await store.listUserOrganizations({ userId }) if (userOrganizations.length === 0) { logger.debug( @@ -87,19 +127,9 @@ export async function findOrganizationForRepository( // Check each organization for matching repositories for (const userOrg of userOrganizations) { - const orgRepos = await conn - .select({ - repoUrl: schema.orgRepo.repo_url, - repoName: schema.orgRepo.repo_name, - isActive: schema.orgRepo.is_active, - }) - .from(schema.orgRepo) - .where( - and( - eq(schema.orgRepo.org_id, userOrg.orgId), - eq(schema.orgRepo.is_active, true), - ), - ) + const orgRepos = await store.listActiveOrganizationRepos({ + organizationId: userOrg.orgId, + }) // Check if any repository in this organization matches for (const orgRepo of orgRepos) { @@ -163,12 +193,12 @@ export async function consumeCreditsWithDelegation( repositoryUrl: string | null creditsToConsume: number logger: Logger - conn: CreditDelegationDbConn + store: CreditDelegationStore }, - 'conn' + 'store' >, ): Promise { - const { conn = db, ...rest } = params + const { store = DEFAULT_STORE, ...rest } = params const { userId, repositoryUrl, creditsToConsume, logger } = rest // If no repository URL, fall back to personal credits @@ -180,11 +210,14 @@ export async function consumeCreditsWithDelegation( return { success: false, error: 'No repository URL provided' } } - const withRepoUrl = { ...rest, repositoryUrl, conn } - try { // Find organization for this repository - const orgLookup = await findOrganizationForRepository(withRepoUrl) + const orgLookup = await findOrganizationForRepository({ + userId, + repositoryUrl, + logger, + store, + }) if (!orgLookup.found || !orgLookup.organizationId) { logger.debug( diff --git a/packages/billing/src/grant-credits.ts b/packages/billing/src/grant-credits.ts index 768848831..18b58e75e 100644 --- a/packages/billing/src/grant-credits.ts +++ b/packages/billing/src/grant-credits.ts @@ -16,17 +16,122 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { GrantType } from '@codebuff/internal/db/schema' type CreditGrantSelect = typeof schema.creditLedger.$inferSelect -type DbTransaction = Parameters[0] extends ( - tx: infer T, -) => any - ? T - : never - -// Minimal structural type for database connection -// This type is intentionally permissive to allow mock injection in tests -export type BillingDbConn = { - transaction: (callback: (tx: any) => Promise) => Promise - select: (fields?: any) => any + +type MonthlyResetUserRow = { + next_quota_reset: Date | null + auto_topup_enabled: boolean | null +} + +type GrantCreditsTxClient = Pick + +export interface GrantCreditsTxStore { + getMonthlyResetUser(params: { userId: string }): Promise + updateUserNextQuotaReset(params: { + userId: string + nextQuotaReset: Date + }): Promise + getMostRecentExpiredFreeGrantPrincipal(params: { + userId: string + now: Date + }): Promise + getTotalReferralBonusCredits(params: { userId: string }): Promise + listActiveCreditGrants(params: { + userId: string + now: Date + }): Promise + updateCreditLedgerBalance(params: { + operationId: string + balance: number + }): Promise + insertCreditLedgerEntry( + values: typeof schema.creditLedger.$inferInsert, + ): Promise +} + +export interface GrantCreditsStore extends GrantCreditsTxStore { + withTransaction(callback: (tx: GrantCreditsTxStore) => Promise): Promise +} + +function createGrantCreditsTxStore(conn: GrantCreditsTxClient): GrantCreditsTxStore { + return { + getMonthlyResetUser: async ({ userId }) => + (await conn.query.user.findFirst({ + where: eq(schema.user.id, userId), + columns: { + next_quota_reset: true, + auto_topup_enabled: true, + }, + })) ?? null, + updateUserNextQuotaReset: async ({ userId, nextQuotaReset }) => { + await conn + .update(schema.user) + .set({ next_quota_reset: nextQuotaReset }) + .where(eq(schema.user.id, userId)) + }, + getMostRecentExpiredFreeGrantPrincipal: async ({ userId, now }) => { + const result = await conn + .select({ + principal: schema.creditLedger.principal, + }) + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + eq(schema.creditLedger.type, 'free'), + lte(schema.creditLedger.expires_at, now), + ), + ) + .orderBy(desc(schema.creditLedger.expires_at)) + .limit(1) + + return result[0]?.principal ?? null + }, + getTotalReferralBonusCredits: async ({ userId }) => { + const result = await conn + .select({ + totalCredits: sql`COALESCE(SUM(${schema.referral.credits}), 0)`, + }) + .from(schema.referral) + .where( + or( + eq(schema.referral.referrer_id, userId), + eq(schema.referral.referred_id, userId), + ), + ) + + return parseInt(result[0]?.totalCredits ?? '0') + }, + listActiveCreditGrants: async ({ userId, now }) => + conn + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.user_id, userId), + or( + isNull(schema.creditLedger.expires_at), + gt(schema.creditLedger.expires_at, now), + ), + ), + ), + updateCreditLedgerBalance: async ({ operationId, balance }) => { + await conn + .update(schema.creditLedger) + .set({ balance }) + .where(eq(schema.creditLedger.operation_id, operationId)) + }, + insertCreditLedgerEntry: async (values) => { + await conn.insert(schema.creditLedger).values(values) + }, + } +} + +const DEFAULT_TX_STORE = createGrantCreditsTxStore(db) + +const DEFAULT_STORE: GrantCreditsStore = { + ...DEFAULT_TX_STORE, + withTransaction: async (callback) => + db.transaction((tx) => callback(createGrantCreditsTxStore(tx))), } /** @@ -43,32 +148,17 @@ export async function getPreviousFreeGrantAmount( { userId: string logger: Logger - conn: BillingDbConn + store: GrantCreditsTxStore }, - 'conn' + 'store' >, ): Promise { - const { conn = db, ...rest } = params - const { userId, logger } = rest + const { store = DEFAULT_STORE, userId, logger } = params const now = new Date() - const lastExpiredFreeGrant = await conn - .select({ - principal: schema.creditLedger.principal, - }) - .from(schema.creditLedger) - .where( - and( - eq(schema.creditLedger.user_id, userId), - eq(schema.creditLedger.type, 'free'), - lte(schema.creditLedger.expires_at, now), // Grant has expired - ), - ) - .orderBy(desc(schema.creditLedger.expires_at)) // Most recent expiry first - .limit(1) + const amount = await store.getMostRecentExpiredFreeGrantPrincipal({ userId, now }) - if (lastExpiredFreeGrant.length > 0) { - const amount = lastExpiredFreeGrant[0].principal + if (amount !== null) { logger.debug( { userId, amount }, 'Found previous expired free grant amount.', @@ -94,28 +184,15 @@ export async function calculateTotalReferralBonus( { userId: string logger: Logger - conn: BillingDbConn + store: GrantCreditsTxStore }, - 'conn' + 'store' >, ): Promise { - const { conn = db, ...rest } = params - const { userId, logger } = rest + const { store = DEFAULT_STORE, userId, logger } = params try { - const result = await conn - .select({ - totalCredits: sql`COALESCE(SUM(${schema.referral.credits}), 0)`, - }) - .from(schema.referral) - .where( - or( - eq(schema.referral.referrer_id, userId), - eq(schema.referral.referred_id, userId), - ), - ) - - const totalBonus = parseInt(result[0]?.totalCredits ?? '0') + const totalBonus = await store.getTotalReferralBonusCredits({ userId }) logger.debug({ userId, totalBonus }, 'Calculated total referral bonus.') return totalBonus } catch (error) { @@ -137,7 +214,8 @@ export async function grantCreditOperation(params: { description: string expiresAt: Date | null operationId: string - tx?: DbTransaction + tx?: GrantCreditsTxClient + store?: GrantCreditsTxStore logger: Logger }) { const { @@ -148,10 +226,12 @@ export async function grantCreditOperation(params: { expiresAt, operationId, tx, + store, logger, } = params - const dbClient = tx || db + const effectiveStore = + store ?? (tx ? createGrantCreditsTxStore(tx) : DEFAULT_STORE) const now = new Date() @@ -165,19 +245,9 @@ export async function grantCreditOperation(params: { } // First check for any negative balances - const negativeGrants = await dbClient - .select() - .from(schema.creditLedger) - .where( - and( - eq(schema.creditLedger.user_id, userId), - or( - isNull(schema.creditLedger.expires_at), - gt(schema.creditLedger.expires_at, now), - ), - ), - ) - .then((grants) => grants.filter((g) => g.balance < 0)) + const negativeGrants = ( + await effectiveStore.listActiveCreditGrants({ userId, now }) + ).filter((grant) => grant.balance < 0) if (negativeGrants.length > 0) { const totalDebt = negativeGrants.reduce( @@ -185,15 +255,15 @@ export async function grantCreditOperation(params: { 0, ) for (const grant of negativeGrants) { - await dbClient - .update(schema.creditLedger) - .set({ balance: 0 }) - .where(eq(schema.creditLedger.operation_id, grant.operation_id)) + await effectiveStore.updateCreditLedgerBalance({ + operationId: grant.operation_id, + balance: 0, + }) } const remainingAmount = Math.max(0, amount - totalDebt) if (remainingAmount > 0) { try { - await dbClient.insert(schema.creditLedger).values({ + await effectiveStore.insertCreditLedgerEntry({ operation_id: operationId, user_id: userId, principal: amount, @@ -221,7 +291,7 @@ export async function grantCreditOperation(params: { } else { // No debt - create grant normally try { - await dbClient.insert(schema.creditLedger).values({ + await effectiveStore.insertCreditLedgerEntry({ operation_id: operationId, user_id: userId, principal: amount, @@ -380,25 +450,18 @@ export async function triggerMonthlyResetAndGrant( { userId: string logger: Logger - conn: BillingDbConn + store: GrantCreditsStore }, - 'conn' + 'store' >, ): Promise { - const { conn = db, ...rest } = params - const { userId, logger } = rest + const { store = DEFAULT_STORE, userId, logger } = params - return await conn.transaction(async (tx) => { + return await store.withTransaction(async (txStore) => { const now = new Date() // Get user's current reset date and auto top-up status - const user = await tx.query.user.findFirst({ - where: eq(schema.user.id, userId), - columns: { - next_quota_reset: true, - auto_topup_enabled: true, - }, - }) + const user = await txStore.getMonthlyResetUser({ userId }) if (!user) { throw new Error(`User ${userId} not found`) @@ -417,8 +480,8 @@ export async function triggerMonthlyResetAndGrant( // Calculate grant amounts separately const [freeGrantAmount, referralBonus] = await Promise.all([ - getPreviousFreeGrantAmount({ ...rest, conn }), - calculateTotalReferralBonus({ ...rest, conn }), + getPreviousFreeGrantAmount({ userId, logger, store: txStore }), + calculateTotalReferralBonus({ userId, logger, store: txStore }), ]) // Generate a deterministic operation ID based on userId and reset date to minute precision @@ -427,32 +490,34 @@ export async function triggerMonthlyResetAndGrant( const referralOperationId = `referral-${userId}-${timestamp}` // Update the user's next reset date - await tx - .update(schema.user) - .set({ next_quota_reset: newResetDate }) - .where(eq(schema.user.id, userId)) + await txStore.updateUserNextQuotaReset({ + userId, + nextQuotaReset: newResetDate, + }) // Always grant free credits - use grantCreditOperation with tx to keep everything in the same transaction await grantCreditOperation({ - ...rest, amount: freeGrantAmount, type: 'free', description: 'Monthly free credits', expiresAt: newResetDate, // Free credits expire at next reset operationId: freeOperationId, - tx, + userId, + logger, + store: txStore, }) // Only grant referral credits if there are any if (referralBonus > 0) { await grantCreditOperation({ - ...rest, amount: referralBonus, type: 'referral', description: 'Monthly referral bonus', expiresAt: newResetDate, // Referral credits expire at next reset operationId: referralOperationId, - tx, + userId, + logger, + store: txStore, }) } diff --git a/packages/billing/src/org-billing.ts b/packages/billing/src/org-billing.ts index 76328b750..703790600 100644 --- a/packages/billing/src/org-billing.ts +++ b/packages/billing/src/org-billing.ts @@ -7,22 +7,7 @@ import { env } from '@codebuff/internal/env' import { stripeServer } from '@codebuff/internal/util/stripe' import { and, asc, gt, isNull, or, eq } from 'drizzle-orm' -import { consumeFromOrderedGrants } from './balance-calculator' - -// Minimal structural type for database connection -// This type is intentionally permissive to allow mock injection in tests -export type OrgBillingDbConn = { - select: (fields?: any) => any - insert: (table?: any) => any - update: (table?: any) => any -} - -// Type for transaction wrapper function (permissive for mock injection in tests) -export type WithTransactionFn = (params: { - callback: (tx: OrgBillingDbConn) => Promise - context: Record - logger: Logger -}) => Promise +import { consumeFromOrderedGrantsWithUpdater } from './balance-calculator' import type { CreditBalance, @@ -33,8 +18,73 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' import type { OptionalFields } from '@codebuff/common/types/function-params' import type { GrantType } from '@codebuff/internal/db/schema' -// Minimal structural type that both `db` and `tx` satisfy (permissive for mock injection) -type DbConn = OrgBillingDbConn +type OrgBillingDbClient = Pick + +export interface OrgBillingTxStore { + listOrderedActiveOrganizationGrants(params: { + organizationId: string + now: Date + }): Promise<(typeof schema.creditLedger.$inferSelect)[]> + insertCreditLedgerEntry( + values: typeof schema.creditLedger.$inferInsert, + ): Promise + updateCreditLedgerBalance(params: { + operationId: string + balance: number + }): Promise +} + +export interface OrgBillingStore extends OrgBillingTxStore { + withTransaction(params: { + callback: (tx: OrgBillingTxStore) => Promise + context: Record + logger: Logger + }): Promise +} + +function createOrgBillingTxStore(conn: OrgBillingDbClient): OrgBillingTxStore { + return { + listOrderedActiveOrganizationGrants: async ({ organizationId, now }) => + conn + .select() + .from(schema.creditLedger) + .where( + and( + eq(schema.creditLedger.org_id, organizationId), + or( + isNull(schema.creditLedger.expires_at), + gt(schema.creditLedger.expires_at, now), + ), + ), + ) + .orderBy( + asc(schema.creditLedger.priority), + asc(schema.creditLedger.expires_at), + asc(schema.creditLedger.created_at), + ), + insertCreditLedgerEntry: async (values) => { + await conn.insert(schema.creditLedger).values(values) + }, + updateCreditLedgerBalance: async ({ operationId, balance }) => { + await conn + .update(schema.creditLedger) + .set({ balance }) + .where(eq(schema.creditLedger.operation_id, operationId)) + }, + } +} + +const DEFAULT_TX_STORE = createOrgBillingTxStore(db) + +const DEFAULT_STORE: OrgBillingStore = { + ...DEFAULT_TX_STORE, + withTransaction: async ({ callback, context, logger }) => + withSerializableTransaction({ + callback: (tx) => callback(createOrgBillingTxStore(tx)), + context, + logger, + }), +} /** * Syncs organization billing cycle with Stripe subscription and returns the current cycle start date. @@ -147,31 +197,13 @@ export async function getOrderedActiveOrganizationGrants( { organizationId: string now: Date - conn: DbConn + store: OrgBillingTxStore }, - 'conn' + 'store' >, ) { - const withDefaults = { conn: db, ...params } - const { organizationId, now, conn } = withDefaults - - return conn - .select() - .from(schema.creditLedger) - .where( - and( - eq(schema.creditLedger.org_id, organizationId), - or( - isNull(schema.creditLedger.expires_at), - gt(schema.creditLedger.expires_at, now), - ), - ), - ) - .orderBy( - asc(schema.creditLedger.priority), - asc(schema.creditLedger.expires_at), - asc(schema.creditLedger.created_at), - ) + const { store = DEFAULT_STORE, organizationId, now } = params + return store.listOrderedActiveOrganizationGrants({ organizationId, now }) } /** @@ -183,18 +215,18 @@ export async function calculateOrganizationUsageAndBalance( organizationId: string quotaResetDate: Date now: Date - conn: DbConn + store: OrgBillingTxStore logger: Logger }, - 'conn' | 'now' + 'store' | 'now' >, ): Promise { const withDefaults = { now: new Date(), - conn: db, + store: DEFAULT_STORE, ...params, } - const { organizationId, quotaResetDate, now, conn, logger } = withDefaults + const { organizationId, quotaResetDate, now, store, logger } = withDefaults // Get all relevant grants for the organization const grants = await getOrderedActiveOrganizationGrants(withDefaults) @@ -288,21 +320,20 @@ export async function consumeOrganizationCredits( organizationId: string creditsToConsume: number logger: Logger - withTransaction: WithTransactionFn + store: OrgBillingStore }, - 'withTransaction' + 'store' >, ): Promise { - const { withTransaction = withSerializableTransaction, ...rest } = params - const { organizationId, creditsToConsume, logger } = rest + const { store = DEFAULT_STORE, organizationId, creditsToConsume, logger } = + params - return await withTransaction({ - callback: async (tx) => { + return await store.withTransaction({ + callback: async (txStore) => { const now = new Date() - const activeGrants = await getOrderedActiveOrganizationGrants({ - ...rest, + const activeGrants = await txStore.listOrderedActiveOrganizationGrants({ + organizationId, now, - conn: tx, }) if (activeGrants.length === 0) { @@ -313,12 +344,16 @@ export async function consumeOrganizationCredits( throw new Error('No active organization grants found') } - const result = await consumeFromOrderedGrants({ + const result = await consumeFromOrderedGrantsWithUpdater({ userId: organizationId, creditsToConsume, grants: activeGrants, - tx, logger, + updateGrantBalance: ({ grant, newBalance }) => + txStore.updateCreditLedgerBalance({ + operationId: grant.operation_id, + balance: newBalance, + }), }) return result @@ -341,15 +376,15 @@ export async function grantOrganizationCredits( description: string expiresAt: Date | null logger: Logger - conn: OrgBillingDbConn + store: OrgBillingTxStore }, - 'description' | 'expiresAt' | 'conn' + 'description' | 'expiresAt' | 'store' >, ): Promise { const withDefaults = { description: 'Organization credit purchase', expiresAt: null, - conn: db, + store: DEFAULT_STORE, ...params, } const { @@ -360,13 +395,13 @@ export async function grantOrganizationCredits( description, expiresAt, logger, - conn, + store, } = withDefaults const now = new Date() try { - await conn.insert(schema.creditLedger).values({ + await store.insertCreditLedgerEntry({ operation_id: operationId, user_id: userId, org_id: organizationId, From 5f5272c2d63013e29eb5532adcea2c6435be6b5a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 17 Dec 2025 01:02:29 -0800 Subject: [PATCH 08/17] refactor: remove test-only user special-casing --- common/src/old-constants.ts | 2 -- common/src/testing/constants.ts | 1 + evals/scaffolding.ts | 3 +- .../src/__tests__/fast-rewrite.test.ts | 2 +- .../src/__tests__/loop-agent-steps.test.ts | 2 +- .../src/__tests__/main-prompt.test.ts | 2 +- .../src/__tests__/n-parameter.test.ts | 2 +- .../src/__tests__/process-file-block.test.ts | 2 +- .../prompt-caching-subagents.test.ts | 2 +- .../src/__tests__/propose-tools.test.ts | 2 +- .../src/__tests__/read-docs-tool.test.ts | 2 +- .../__tests__/run-agent-step-tools.test.ts | 2 +- .../__tests__/run-programmatic-step.test.ts | 2 +- .../spawn-agents-image-content.test.ts | 2 +- .../spawn-agents-message-history.test.ts | 2 +- .../spawn-agents-permissions.test.ts | 2 +- .../src/__tests__/subagent-streaming.test.ts | 2 +- .../src/__tests__/web-search-tool.test.ts | 2 +- packages/billing/src/balance-calculator.ts | 4 --- .../app/api/admin/relabel-for-user/route.ts | 10 +++--- .../[runId]/steps/__tests__/steps.test.ts | 34 ------------------- .../api/v1/agent-runs/[runId]/steps/_post.ts | 6 ---- .../agent-runs/__tests__/agent-runs.test.ts | 34 ------------------- web/src/app/api/v1/agent-runs/_post.ts | 6 ---- 24 files changed, 24 insertions(+), 106 deletions(-) create mode 100644 common/src/testing/constants.ts diff --git a/common/src/old-constants.ts b/common/src/old-constants.ts index 698edc605..61b9f13b4 100644 --- a/common/src/old-constants.ts +++ b/common/src/old-constants.ts @@ -311,8 +311,6 @@ export function supportsCacheControl(model: Model): boolean { return !nonCacheableModels.includes(model) } -export const TEST_USER_ID = 'test-user-id' - export function getModelFromShortName( modelName: string | undefined, ): Model | undefined { diff --git a/common/src/testing/constants.ts b/common/src/testing/constants.ts new file mode 100644 index 000000000..6dd039ae1 --- /dev/null +++ b/common/src/testing/constants.ts @@ -0,0 +1 @@ +export const TEST_USER_ID = 'test-user-id' diff --git a/evals/scaffolding.ts b/evals/scaffolding.ts index 199485686..c683c0823 100644 --- a/evals/scaffolding.ts +++ b/evals/scaffolding.ts @@ -5,8 +5,9 @@ import path from 'path' import { runAgentStep } from '@codebuff/agent-runtime/run-agent-step' import { assembleLocalAgentTemplates } from '@codebuff/agent-runtime/templates/agent-registry' import { getFileTokenScores } from '@codebuff/code-map/parse' -import { API_KEY_ENV_VAR, TEST_USER_ID } from '@codebuff/common/old-constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' +import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { generateCompactId } from '@codebuff/common/util/string' import { getSystemInfo } from '@codebuff/common/util/system-info' import { ToolHelpers } from '@codebuff/sdk' diff --git a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts index de2056d6a..16c78d53a 100644 --- a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts +++ b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts @@ -1,6 +1,6 @@ import path from 'path' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { afterAll, beforeEach, describe, expect, it } from 'bun:test' import { createPatch } from 'diff' diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 73e6d8e26..99f386f67 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -1,5 +1,5 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, diff --git a/packages/agent-runtime/src/__tests__/main-prompt.test.ts b/packages/agent-runtime/src/__tests__/main-prompt.test.ts index 118ed4367..e47870074 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -1,6 +1,6 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, diff --git a/packages/agent-runtime/src/__tests__/n-parameter.test.ts b/packages/agent-runtime/src/__tests__/n-parameter.test.ts index e8833a757..54fa3d06d 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -1,5 +1,5 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockRandomUUID, diff --git a/packages/agent-runtime/src/__tests__/process-file-block.test.ts b/packages/agent-runtime/src/__tests__/process-file-block.test.ts index 5cb00fd4d..e1166ccce 100644 --- a/packages/agent-runtime/src/__tests__/process-file-block.test.ts +++ b/packages/agent-runtime/src/__tests__/process-file-block.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { cleanMarkdownCodeBlock } from '@codebuff/common/util/file' import { beforeEach, describe, expect, it } from 'bun:test' diff --git a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts index 48e10960f..ffafaa9e8 100644 --- a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +++ b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/propose-tools.test.ts b/packages/agent-runtime/src/__tests__/propose-tools.test.ts index d404b3acb..09c3b0130 100644 --- a/packages/agent-runtime/src/__tests__/propose-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/propose-tools.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { diff --git a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts index 4346951ac..2b0249a74 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -1,6 +1,6 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index 210e104eb..2134184ea 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -1,6 +1,6 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index 638d496f7..a6ee30ed7 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -1,5 +1,5 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockRandomUUID, diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts index 0159390f9..ebc3daa13 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts index 41c98ea92..70381d7ce 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts index 3fe3107a8..d15823de0 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts index d65c9f10a..f038f8dc8 100644 --- a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +++ b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts @@ -1,4 +1,4 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' diff --git a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts index 46d0a1cf8..cb2923611 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -1,6 +1,6 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/old-constants' +import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, diff --git a/packages/billing/src/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index b45da96db..c701f9270 100644 --- a/packages/billing/src/balance-calculator.ts +++ b/packages/billing/src/balance-calculator.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { GrantTypeValues } from '@codebuff/common/types/grant' import { failure, getErrorObject, success } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' @@ -496,9 +495,6 @@ export async function consumeCreditsAndAddAgentStep(params: { tx, }) - if (userId === TEST_USER_ID) { - return { ...result, agentStepId: 'test-step-id' } - } } try { diff --git a/web/src/app/api/admin/relabel-for-user/route.ts b/web/src/app/api/admin/relabel-for-user/route.ts index 18ee10b0f..97dd710ec 100644 --- a/web/src/app/api/admin/relabel-for-user/route.ts +++ b/web/src/app/api/admin/relabel-for-user/route.ts @@ -14,7 +14,6 @@ import { import { finetunedVertexModels, models, - TEST_USER_ID, } from '@codebuff/common/old-constants' import { userMessage } from '@codebuff/common/util/messages' import { generateCompactId } from '@codebuff/common/util/string' @@ -61,6 +60,7 @@ export async function GET(req: NextRequest) { if (authResult instanceof NextResponse) { return authResult } + const adminUser = authResult const userId = req.nextUrl.searchParams.get('userId') if (!userId) { @@ -96,6 +96,7 @@ export async function POST(req: NextRequest) { if (authResult instanceof NextResponse) { return authResult } + const adminUser = authResult const userId = req.nextUrl.searchParams.get('userId') if (!userId) { @@ -129,7 +130,7 @@ export async function POST(req: NextRequest) { const results = await relabelUserTraces({ userId, limit, - promptContext: buildPromptContext(apiKey), + promptContext: buildPromptContext({ apiKey, adminUserId: adminUser.id }), }) return NextResponse.json({ @@ -519,14 +520,15 @@ function extractQueryFromMessages(messages: unknown): string { return match?.[1] ?? 'Unknown query' } -function buildPromptContext(apiKey: string) { +function buildPromptContext(params: { apiKey: string; adminUserId: string }) { + const { apiKey, adminUserId } = params return { apiKey, runId: `admin-relabel-${Date.now()}`, clientSessionId: STATIC_SESSION_ID, fingerprintId: STATIC_SESSION_ID, userInputId: STATIC_SESSION_ID, - userId: TEST_USER_ID, + userId: adminUserId, sendAction: async () => {}, trackEvent: async () => {}, logger, 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..4d9868641 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 @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { beforeEach, describe, expect, mock, test } from 'bun:test' import { NextRequest } from 'next/server' @@ -28,14 +27,6 @@ describe('agentRunsStepsPost', () => { ]), ) as any } - if (apiKey === 'test-key') { - return Object.fromEntries( - fields.map((field) => [ - field, - field === 'id' ? TEST_USER_ID : undefined, - ]), - ) as any - } return null } @@ -236,31 +227,6 @@ describe('agentRunsStepsPost', () => { expect(json.error).toBe('Unauthorized to add steps to this run') }) - test('returns test step ID for test user', async () => { - const req = new NextRequest( - 'http://localhost/api/v1/agent-runs/run-123/steps', - { - method: 'POST', - headers: { Authorization: 'Bearer test-key' }, - body: JSON.stringify({ stepNumber: 1 }), - }, - ) - - const response = await postAgentRunsSteps({ - req, - runId: 'run-123', - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - loggerWithContext: mockLoggerWithContext, - trackEvent: mockTrackEvent, - db: mockDb, - }) - - expect(response.status).toBe(200) - const json = await response.json() - expect(json.stepId).toBe('test-step-id') - }) - test('successfully adds agent step', async () => { 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..7d0d6b451 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 @@ -1,5 +1,4 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { getErrorObject } from '@codebuff/common/util/error' import * as schema from '@codebuff/internal/db/schema' import { eq } from 'drizzle-orm' @@ -108,11 +107,6 @@ export async function postAgentRunsSteps(params: { startTime, } = data - // Skip database insert for test user - if (userInfo.id === TEST_USER_ID) { - return NextResponse.json({ stepId: 'test-step-id' }) - } - // Verify the run belongs to the authenticated user const agentRun = await db .select({ user_id: schema.agentRun.user_id }) 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..4288fabd0 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 @@ -1,5 +1,4 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { NextRequest } from 'next/server' @@ -26,9 +25,6 @@ describe('/api/v1/agent-runs POST endpoint', () => { 'test-api-key-456': { id: 'user-456', }, - 'test-api-key-test': { - id: TEST_USER_ID, - }, } const mockGetUserInfoFromApiKey: GetUserInfoFromApiKeyFn = async ({ @@ -744,34 +740,4 @@ describe('/api/v1/agent-runs POST endpoint', () => { }) }) - describe('Test user handling', () => { - test('skips database update for test user on FINISH action', async () => { - const req = new NextRequest('http://localhost:3000/api/v1/agent-runs', { - method: 'POST', - headers: { Authorization: 'Bearer test-api-key-test' }, - body: JSON.stringify({ - action: 'FINISH', - runId: 'run-test', - status: 'completed', - totalSteps: 5, - directCredits: 100, - totalCredits: 150, - }), - }) - - const response = await postAgentRuns({ - req, - getUserInfoFromApiKey: mockGetUserInfoFromApiKey, - logger: mockLogger, - loggerWithContext: mockLoggerWithContext, - trackEvent: mockTrackEvent, - db: mockDb, - }) - - expect(response.status).toBe(200) - const body = await response.json() - expect(body).toEqual({ success: true }) - expect(mockDb.update).not.toHaveBeenCalled() - }) - }) }) diff --git a/web/src/app/api/v1/agent-runs/_post.ts b/web/src/app/api/v1/agent-runs/_post.ts index a74630d7d..25e460b9b 100644 --- a/web/src/app/api/v1/agent-runs/_post.ts +++ b/web/src/app/api/v1/agent-runs/_post.ts @@ -1,5 +1,4 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { getErrorObject } from '@codebuff/common/util/error' import * as schema from '@codebuff/internal/db/schema' import { eq } from 'drizzle-orm' @@ -117,11 +116,6 @@ async function handleFinishAction(params: { errorMessage, } = data - // Skip database update for test user - if (userId === TEST_USER_ID) { - return NextResponse.json({ success: true }) - } - try { await db .update(schema.agentRun) From c3c1d9e7d6ad36841ff4047ee267cb05e43e0382 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 17 Dec 2025 12:21:55 -0800 Subject: [PATCH 09/17] test: remove TEST_USER_ID constant --- common/src/testing/constants.ts | 1 - common/src/testing/fixtures/agent-runtime.ts | 2 +- evals/impl/agent-runtime.ts | 2 +- evals/scaffolding.ts | 3 +-- .../src/__tests__/fast-rewrite.test.ts | 3 +-- .../src/__tests__/loop-agent-steps.test.ts | 3 +-- .../src/__tests__/main-prompt.test.ts | 5 ++-- .../src/__tests__/n-parameter.test.ts | 25 +++++++++---------- .../src/__tests__/process-file-block.test.ts | 11 ++++---- .../prompt-caching-subagents.test.ts | 3 +-- .../src/__tests__/propose-tools.test.ts | 3 +-- .../src/__tests__/read-docs-tool.test.ts | 3 +-- .../__tests__/run-agent-step-tools.test.ts | 3 +-- .../__tests__/run-programmatic-step.test.ts | 3 +-- .../spawn-agents-image-content.test.ts | 3 +-- .../spawn-agents-message-history.test.ts | 3 +-- .../spawn-agents-permissions.test.ts | 3 +-- .../src/__tests__/subagent-streaming.test.ts | 3 +-- .../src/__tests__/web-search-tool.test.ts | 3 +-- .../src/openrouter-ai-sdk/chat/index.test.ts | 4 +-- 20 files changed, 36 insertions(+), 53 deletions(-) delete mode 100644 common/src/testing/constants.ts diff --git a/common/src/testing/constants.ts b/common/src/testing/constants.ts deleted file mode 100644 index 6dd039ae1..000000000 --- a/common/src/testing/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const TEST_USER_ID = 'test-user-id' diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/src/testing/fixtures/agent-runtime.ts index da616f361..bf897725d 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/src/testing/fixtures/agent-runtime.ts @@ -63,7 +63,7 @@ export const TEST_AGENT_RUNTIME_IMPL = Object.freeze< fields, }: GetUserInfoFromApiKeyInput) => { const user = { - id: 'test-user-id', + id: 'user-123', email: 'test-email', discord_id: 'test-discord-id', referral_code: 'ref-test-code', diff --git a/evals/impl/agent-runtime.ts b/evals/impl/agent-runtime.ts index 5dbf3f88e..6a4abf5c7 100644 --- a/evals/impl/agent-runtime.ts +++ b/evals/impl/agent-runtime.ts @@ -33,7 +33,7 @@ export const EVALS_AGENT_RUNTIME_IMPL = Object.freeze({ // Database getUserInfoFromApiKey: async () => ({ - id: 'test-user-id', + id: 'user-123', email: 'test-email', discord_id: 'test-discord-id', referral_code: 'ref-test-code', diff --git a/evals/scaffolding.ts b/evals/scaffolding.ts index c683c0823..5e2a31d80 100644 --- a/evals/scaffolding.ts +++ b/evals/scaffolding.ts @@ -7,7 +7,6 @@ import { assembleLocalAgentTemplates } from '@codebuff/agent-runtime/templates/a import { getFileTokenScores } from '@codebuff/code-map/parse' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { generateCompactId } from '@codebuff/common/util/string' import { getSystemInfo } from '@codebuff/common/util/system-info' import { ToolHelpers } from '@codebuff/sdk' @@ -257,7 +256,7 @@ export async function runAgentStepScaffolding( spawnParams: undefined, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: generateCompactId(), }) diff --git a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts index 16c78d53a..1800251dd 100644 --- a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts +++ b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts @@ -1,6 +1,5 @@ import path from 'path' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { afterAll, beforeEach, describe, expect, it } from 'bun:test' import { createPatch } from 'diff' @@ -34,7 +33,7 @@ describe.skip('rewriteWithOpenAI', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', runId: 'test-run-id', }) diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 99f386f67..464c92f86 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -1,5 +1,4 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, @@ -113,7 +112,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => spawnParams: undefined, fingerprintId: 'test-fingerprint', fileContext: mockFileContext, - userId: TEST_USER_ID, + userId: 'user-123', clientSessionId: 'test-session', ancestorRunIds: [], onResponseChunk: () => {}, diff --git a/packages/agent-runtime/src/__tests__/main-prompt.test.ts b/packages/agent-runtime/src/__tests__/main-prompt.test.ts index e47870074..bccb016cb 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -1,6 +1,5 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, @@ -97,7 +96,7 @@ describe('mainPrompt', () => { ...TEST_AGENT_RUNTIME_IMPL, repoId: undefined, repoUrl: undefined, - userId: TEST_USER_ID, + userId: 'user-123', clientSessionId: 'test-session', onResponseChunk: () => {}, localAgentTemplates: mockLocalAgentTemplates, @@ -414,7 +413,7 @@ describe('mainPrompt', () => { repoId: undefined, repoUrl: undefined, action, - userId: TEST_USER_ID, + userId: 'user-123', clientSessionId: 'test-session', onResponseChunk: () => {}, localAgentTemplates: mockLocalAgentTemplates, diff --git a/packages/agent-runtime/src/__tests__/n-parameter.test.ts b/packages/agent-runtime/src/__tests__/n-parameter.test.ts index 54fa3d06d..c0b293043 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -1,5 +1,4 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockRandomUUID, @@ -106,7 +105,7 @@ describe('n parameter and GENERATE_N functionality', () => { ancestorRunIds: [], repoId: undefined, repoUrl: undefined, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -244,7 +243,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test prompt', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-user-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -284,7 +283,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test prompt', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-user-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -349,7 +348,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test prompt', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-user-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -432,7 +431,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -528,7 +527,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -593,7 +592,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -632,7 +631,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -679,7 +678,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -723,7 +722,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -786,7 +785,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', @@ -834,7 +833,7 @@ describe('n parameter and GENERATE_N functionality', () => { template: mockTemplate, prompt: 'Test', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', diff --git a/packages/agent-runtime/src/__tests__/process-file-block.test.ts b/packages/agent-runtime/src/__tests__/process-file-block.test.ts index e1166ccce..4a9f52298 100644 --- a/packages/agent-runtime/src/__tests__/process-file-block.test.ts +++ b/packages/agent-runtime/src/__tests__/process-file-block.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { cleanMarkdownCodeBlock } from '@codebuff/common/util/file' import { beforeEach, describe, expect, it } from 'bun:test' @@ -59,7 +58,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -110,7 +109,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -144,7 +143,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -184,7 +183,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -232,7 +231,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() diff --git a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts index ffafaa9e8..0991970fe 100644 --- a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +++ b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' @@ -131,7 +130,7 @@ describe('Prompt Caching for Subagents with inheritParentSystemPrompt', () => { fingerprintId: 'test-fingerprint', fileContext: mockFileContext, localAgentTemplates: mockLocalAgentTemplates, - userId: TEST_USER_ID, + userId: 'user-123', clientSessionId: 'test-session', ancestorRunIds: [], onResponseChunk: () => {}, diff --git a/packages/agent-runtime/src/__tests__/propose-tools.test.ts b/packages/agent-runtime/src/__tests__/propose-tools.test.ts index 09c3b0130..3fac2be8f 100644 --- a/packages/agent-runtime/src/__tests__/propose-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/propose-tools.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { @@ -249,7 +248,7 @@ console.log(add(1, 2)); template: mockTemplate, prompt: 'Add a multiply function to src/utils.ts', toolCallParams: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-user-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', diff --git a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts index 2b0249a74..0d7db379e 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -1,6 +1,5 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, @@ -76,7 +75,7 @@ describe('read_docs tool with researcher agent (via web API facade)', () => { repoId: undefined, repoUrl: undefined, system: 'Test system prompt', - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index 2134184ea..131df4251 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -1,6 +1,5 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, @@ -118,7 +117,7 @@ describe('runAgentStep - set_output tool', () => { spawnParams: undefined, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', } }) diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index a6ee30ed7..b856eee22 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -1,5 +1,4 @@ import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockRandomUUID, @@ -123,7 +122,7 @@ describe('runProgrammaticStep', () => { template: mockTemplate, prompt: 'Test prompt', toolCallParams: { testParam: 'value' }, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-user-input', clientSessionId: 'test-session', fingerprintId: 'test-fingerprint', diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts index ebc3daa13..952df3000 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { @@ -82,7 +81,7 @@ describe('Spawn Agents Image Content Propagation', () => { signal: new AbortController().signal, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', writeToClient: () => {}, } diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts index 70381d7ce..e0273a183 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { @@ -69,7 +68,7 @@ describe('Spawn Agents Message History', () => { signal: new AbortController().signal, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', writeToClient: () => {}, } diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts index d15823de0..7bc60b19b 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' @@ -71,7 +70,7 @@ describe('Spawn Agents Permissions', () => { sendSubagentChunk: mockSendSubagentChunk, signal: new AbortController().signal, system: 'Test system prompt', - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', writeToClient: () => {}, } diff --git a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts index f038f8dc8..1d5dd1011 100644 --- a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +++ b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' @@ -71,7 +70,7 @@ describe('Subagent Streaming', () => { signal: new AbortController().signal, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', writeToClient: mockWriteToClient, } diff --git a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts index cb2923611..39608b9bf 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -1,6 +1,5 @@ import * as bigquery from '@codebuff/bigquery' import * as analytics from '@codebuff/common/analytics' -import { TEST_USER_ID } from '@codebuff/common/testing/constants' import { mockAnalytics, mockBigQuery, @@ -77,7 +76,7 @@ describe('web_search tool with researcher agent (via web API facade)', () => { spawnParams: undefined, system: 'Test system prompt', tools: {}, - userId: TEST_USER_ID, + userId: 'user-123', userInputId: 'test-input', } diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts index 6fa153a10..a4d7ddc54 100644 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts +++ b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts @@ -572,7 +572,7 @@ describe('doGenerate', () => { logitBias: { 50256: -100 }, logprobs: 2, parallelToolCalls: false, - user: 'test-user-id', + user: 'user-123', }) .doGenerate({ prompt: TEST_PROMPT, @@ -585,7 +585,7 @@ describe('doGenerate', () => { top_logprobs: 2, logit_bias: { 50256: -100 }, parallel_tool_calls: false, - user: 'test-user-id', + user: 'user-123', }) }) From 60ee0915aa73da3a4bcd88e324e80a17081684ae Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 17 Dec 2025 13:38:25 -0800 Subject: [PATCH 10/17] refactor(env): separate test-only env helpers --- cli/src/__tests__/utils/env.test.ts | 3 +- cli/src/testing/env.ts | 48 ++++++++++++ cli/src/utils/env.ts | 42 +--------- common/src/__tests__/env-ci.test.ts | 3 +- common/src/__tests__/env-process.test.ts | 9 +-- common/src/env-ci.ts | 13 ---- common/src/env-process.ts | 84 +------------------- common/src/env.ts | 37 +-------- common/src/testing/env-ci.ts | 19 +++++ common/src/testing/env-process.ts | 87 +++++++++++++++++++++ eslint.config.js | 98 ++++++++++++++++++++++++ knowledge.md | 7 +- scripts/check-env-architecture.ts | 20 ++--- sdk/src/__tests__/env.test.ts | 3 +- sdk/src/env.ts | 24 +----- sdk/src/testing/env.ts | 27 +++++++ 16 files changed, 303 insertions(+), 221 deletions(-) create mode 100644 cli/src/testing/env.ts create mode 100644 common/src/testing/env-ci.ts create mode 100644 common/src/testing/env-process.ts create mode 100644 sdk/src/testing/env.ts diff --git a/cli/src/__tests__/utils/env.test.ts b/cli/src/__tests__/utils/env.test.ts index 72490c2fa..7c6ad1896 100644 --- a/cli/src/__tests__/utils/env.test.ts +++ b/cli/src/__tests__/utils/env.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' -import { getCliEnv, createTestCliEnv } from '../../utils/env' +import { createTestCliEnv } from '../../testing/env' +import { getCliEnv } from '../../utils/env' describe('cli/utils/env', () => { describe('getCliEnv', () => { diff --git a/cli/src/testing/env.ts b/cli/src/testing/env.ts new file mode 100644 index 000000000..f79cd45ac --- /dev/null +++ b/cli/src/testing/env.ts @@ -0,0 +1,48 @@ +/** + * Test-only CLI env fixtures. + */ + +import { createTestBaseEnv } from '@codebuff/common/testing/env-process' + +import type { CliEnv } from '../types/env' + +/** + * Create a test CliEnv with optional overrides. + * Composes from createTestBaseEnv for DRY. + */ +export const createTestCliEnv = (overrides: Partial = {}): CliEnv => ({ + ...createTestBaseEnv(), + + // CLI-specific defaults + KITTY_WINDOW_ID: undefined, + SIXEL_SUPPORT: undefined, + ZED_NODE_ENV: undefined, + ZED_TERM: undefined, + ZED_SHELL: undefined, + COLORTERM: undefined, + VSCODE_THEME_KIND: undefined, + VSCODE_COLOR_THEME_KIND: undefined, + VSCODE_GIT_IPC_HANDLE: undefined, + VSCODE_PID: undefined, + VSCODE_CWD: undefined, + VSCODE_NLS_CONFIG: undefined, + CURSOR_PORT: undefined, + CURSOR: undefined, + JETBRAINS_REMOTE_RUN: undefined, + IDEA_INITIAL_DIRECTORY: undefined, + IDE_CONFIG_DIR: undefined, + JB_IDE_CONFIG_DIR: undefined, + VISUAL: undefined, + EDITOR: undefined, + CODEBUFF_CLI_EDITOR: undefined, + CODEBUFF_EDITOR: undefined, + OPEN_TUI_THEME: undefined, + OPENTUI_THEME: undefined, + CODEBUFF_IS_BINARY: undefined, + CODEBUFF_CLI_VERSION: undefined, + CODEBUFF_CLI_TARGET: undefined, + CODEBUFF_RG_PATH: undefined, + CODEBUFF_SCROLL_MULTIPLIER: undefined, + ...overrides, +}) + diff --git a/cli/src/utils/env.ts b/cli/src/utils/env.ts index c781e2c4c..f03582399 100644 --- a/cli/src/utils/env.ts +++ b/cli/src/utils/env.ts @@ -5,7 +5,7 @@ * process env with CLI-specific vars for terminal/IDE detection. */ -import { getBaseEnv, createTestBaseEnv } from '@codebuff/common/env-process' +import { getBaseEnv } from '@codebuff/common/env-process' import type { CliEnv } from '../types/env' @@ -66,43 +66,3 @@ export const getCliEnv = (): CliEnv => ({ * or when you need to set environment variables at runtime. */ export const getSystemProcessEnv = (): NodeJS.ProcessEnv => process.env - -/** - * Create a test CliEnv with optional overrides. - * Composes from createTestBaseEnv() for DRY. - */ -export const createTestCliEnv = (overrides: Partial = {}): CliEnv => ({ - ...createTestBaseEnv(), - - // CLI-specific defaults - KITTY_WINDOW_ID: undefined, - SIXEL_SUPPORT: undefined, - ZED_NODE_ENV: undefined, - ZED_TERM: undefined, - ZED_SHELL: undefined, - COLORTERM: undefined, - VSCODE_THEME_KIND: undefined, - VSCODE_COLOR_THEME_KIND: undefined, - VSCODE_GIT_IPC_HANDLE: undefined, - VSCODE_PID: undefined, - VSCODE_CWD: undefined, - VSCODE_NLS_CONFIG: undefined, - CURSOR_PORT: undefined, - CURSOR: undefined, - JETBRAINS_REMOTE_RUN: undefined, - IDEA_INITIAL_DIRECTORY: undefined, - IDE_CONFIG_DIR: undefined, - JB_IDE_CONFIG_DIR: undefined, - VISUAL: undefined, - EDITOR: undefined, - CODEBUFF_CLI_EDITOR: undefined, - CODEBUFF_EDITOR: undefined, - OPEN_TUI_THEME: undefined, - OPENTUI_THEME: undefined, - CODEBUFF_IS_BINARY: undefined, - CODEBUFF_CLI_VERSION: undefined, - CODEBUFF_CLI_TARGET: undefined, - CODEBUFF_RG_PATH: undefined, - CODEBUFF_SCROLL_MULTIPLIER: undefined, - ...overrides, -}) diff --git a/common/src/__tests__/env-ci.test.ts b/common/src/__tests__/env-ci.test.ts index 63e829e01..41ef2c6ac 100644 --- a/common/src/__tests__/env-ci.test.ts +++ b/common/src/__tests__/env-ci.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' -import { getCiEnv, ciEnv, isCI, createTestCiEnv } from '../env-ci' +import { getCiEnv, ciEnv, isCI } from '../env-ci' +import { createTestCiEnv } from '../testing/env-ci' describe('env-ci', () => { describe('getCiEnv', () => { diff --git a/common/src/__tests__/env-process.test.ts b/common/src/__tests__/env-process.test.ts index 2522b1df3..448d2cbd8 100644 --- a/common/src/__tests__/env-process.test.ts +++ b/common/src/__tests__/env-process.test.ts @@ -1,10 +1,7 @@ -import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { describe, test, expect, afterEach } from 'bun:test' -import { - getProcessEnv, - processEnv, - createTestProcessEnv, -} from '../env-process' +import { getProcessEnv, processEnv } from '../env-process' +import { createTestProcessEnv } from '../testing/env-process' describe('env-process', () => { describe('getProcessEnv', () => { diff --git a/common/src/env-ci.ts b/common/src/env-ci.ts index 188865324..4ace47712 100644 --- a/common/src/env-ci.ts +++ b/common/src/env-ci.ts @@ -33,16 +33,3 @@ export const isCI = (): boolean => { const env = getCiEnv() return env.CI === 'true' || env.CI === '1' || env.GITHUB_ACTIONS === 'true' } - -/** - * Create a test CiEnv with optional overrides. - */ -export const createTestCiEnv = (overrides: Partial = {}): CiEnv => ({ - CI: undefined, - GITHUB_ACTIONS: undefined, - RENDER: undefined, - IS_PULL_REQUEST: undefined, - CODEBUFF_GITHUB_TOKEN: undefined, - CODEBUFF_API_KEY: 'test-api-key', - ...overrides, -}) diff --git a/common/src/env-process.ts b/common/src/env-process.ts index f92460768..1653acd2d 100644 --- a/common/src/env-process.ts +++ b/common/src/env-process.ts @@ -6,9 +6,8 @@ * environment variables like SHELL, HOME, TERM, etc. * * Usage: - * - Import `getBaseProcessEnv` for base OS-level vars only + * - Import `getBaseEnv` for base OS-level vars only * - Import `getProcessEnv` for the full ProcessEnv (base + extensions) - * - In tests, use `createTestBaseProcessEnv` or `createTestProcessEnv` */ import type { BaseEnv, ProcessEnv } from './types/contracts/env' @@ -34,30 +33,6 @@ export const getBaseEnv = (): BaseEnv => ({ PATH: process.env.PATH, }) -/** - * Create test defaults for BaseEnv. - * Package-specific test helpers should spread this. - */ -export const createTestBaseEnv = ( - overrides: Partial = {}, -): BaseEnv => ({ - SHELL: undefined, - COMSPEC: undefined, - HOME: '/home/test', - USERPROFILE: undefined, - APPDATA: undefined, - XDG_CONFIG_HOME: undefined, - TERM: 'xterm-256color', - TERM_PROGRAM: undefined, - TERM_BACKGROUND: undefined, - TERMINAL_EMULATOR: undefined, - COLORFGBG: undefined, - NODE_ENV: 'test', - NODE_PATH: undefined, - PATH: '/usr/bin', - ...overrides, -}) - /** * Get full process environment values (base + all extensions). * Returns a snapshot of the current process.env values for the ProcessEnv type. @@ -117,60 +92,3 @@ export const getProcessEnv = (): ProcessEnv => ({ * Use this for production code, inject mocks in tests. */ export const processEnv: ProcessEnv = getProcessEnv() - -/** - * Create a test ProcessEnv with optional overrides. - * Composes from createTestBaseProcessEnv for DRY. - */ -export const createTestProcessEnv = ( - overrides: Partial = {}, -): ProcessEnv => ({ - ...createTestBaseEnv(), - - // Terminal-specific - KITTY_WINDOW_ID: undefined, - SIXEL_SUPPORT: undefined, - ZED_NODE_ENV: undefined, - - // VS Code family detection - VSCODE_THEME_KIND: undefined, - VSCODE_COLOR_THEME_KIND: undefined, - VSCODE_GIT_IPC_HANDLE: undefined, - VSCODE_PID: undefined, - VSCODE_CWD: undefined, - VSCODE_NLS_CONFIG: undefined, - - // Cursor editor detection - CURSOR_PORT: undefined, - CURSOR: undefined, - - // JetBrains IDE detection - JETBRAINS_REMOTE_RUN: undefined, - IDEA_INITIAL_DIRECTORY: undefined, - IDE_CONFIG_DIR: undefined, - JB_IDE_CONFIG_DIR: undefined, - - // Editor preferences - VISUAL: undefined, - EDITOR: undefined, - CODEBUFF_CLI_EDITOR: undefined, - CODEBUFF_EDITOR: undefined, - - // Theme preferences - OPEN_TUI_THEME: undefined, - OPENTUI_THEME: undefined, - - // Codebuff CLI-specific - CODEBUFF_IS_BINARY: undefined, - CODEBUFF_CLI_VERSION: undefined, - CODEBUFF_CLI_TARGET: undefined, - CODEBUFF_RG_PATH: undefined, - CODEBUFF_WASM_DIR: undefined, - - // Build/CI flags - VERBOSE: undefined, - OVERRIDE_TARGET: undefined, - OVERRIDE_PLATFORM: undefined, - OVERRIDE_ARCH: undefined, - ...overrides, -}) diff --git a/common/src/env.ts b/common/src/env.ts index 0175fb324..bdb1f53c7 100644 --- a/common/src/env.ts +++ b/common/src/env.ts @@ -1,45 +1,12 @@ -import { - clientEnvSchema, - clientProcessEnv, - type ClientInput, -} from './env-schema' +import { clientEnvSchema, clientProcessEnv } from './env-schema' -const isTestRuntime = - process.env.NODE_ENV === 'test' || process.env.BUN_ENV === 'test' - -const TEST_ENV_DEFAULTS: ClientInput = { - NEXT_PUBLIC_CB_ENVIRONMENT: 'test', - NEXT_PUBLIC_CODEBUFF_APP_URL: 'http://localhost:3000', - NEXT_PUBLIC_SUPPORT_EMAIL: 'support@codebuff.com', - NEXT_PUBLIC_POSTHOG_API_KEY: 'test-posthog-key', - NEXT_PUBLIC_POSTHOG_HOST_URL: 'https://us.i.posthog.com', - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: 'pk_test_placeholder', - NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: - 'https://billing.stripe.com/p/login/test_placeholder', - NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: 'test-verification', - NEXT_PUBLIC_WEB_PORT: '3000', -} - -const envInput = isTestRuntime - ? { ...TEST_ENV_DEFAULTS, ...clientProcessEnv } - : clientProcessEnv - -const parsedEnv = clientEnvSchema.safeParse(envInput) +const parsedEnv = clientEnvSchema.safeParse(clientProcessEnv) if (!parsedEnv.success) { throw parsedEnv.error } export const env = parsedEnv.data -// Populate process.env with defaults during tests so direct access works -if (isTestRuntime) { - for (const [key, value] of Object.entries(TEST_ENV_DEFAULTS)) { - if (!process.env[key] && typeof value === 'string') { - process.env[key] = value - } - } -} - // Only log environment in non-production if (env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') { console.log('Using environment:', env.NEXT_PUBLIC_CB_ENVIRONMENT) diff --git a/common/src/testing/env-ci.ts b/common/src/testing/env-ci.ts new file mode 100644 index 000000000..aee1c2e30 --- /dev/null +++ b/common/src/testing/env-ci.ts @@ -0,0 +1,19 @@ +/** + * Test-only CiEnv fixtures. + */ + +import type { CiEnv } from '../types/contracts/env' + +/** + * Create a test CiEnv with optional overrides. + */ +export const createTestCiEnv = (overrides: Partial = {}): CiEnv => ({ + CI: undefined, + GITHUB_ACTIONS: undefined, + RENDER: undefined, + IS_PULL_REQUEST: undefined, + CODEBUFF_GITHUB_TOKEN: undefined, + CODEBUFF_API_KEY: 'test-api-key', + ...overrides, +}) + diff --git a/common/src/testing/env-process.ts b/common/src/testing/env-process.ts new file mode 100644 index 000000000..61dd4323a --- /dev/null +++ b/common/src/testing/env-process.ts @@ -0,0 +1,87 @@ +/** + * Test-only ProcessEnv fixtures. + */ + +import type { BaseEnv, ProcessEnv } from '../types/contracts/env' + +/** + * Create test defaults for BaseEnv. + * Package-specific test helpers should spread this. + */ +export const createTestBaseEnv = ( + overrides: Partial = {}, +): BaseEnv => ({ + SHELL: undefined, + COMSPEC: undefined, + HOME: '/home/test', + USERPROFILE: undefined, + APPDATA: undefined, + XDG_CONFIG_HOME: undefined, + TERM: 'xterm-256color', + TERM_PROGRAM: undefined, + TERM_BACKGROUND: undefined, + TERMINAL_EMULATOR: undefined, + COLORFGBG: undefined, + NODE_ENV: 'test', + NODE_PATH: undefined, + PATH: '/usr/bin', + ...overrides, +}) + +/** + * Create a test ProcessEnv with optional overrides. + * Composes from createTestBaseEnv for DRY. + */ +export const createTestProcessEnv = ( + overrides: Partial = {}, +): ProcessEnv => ({ + ...createTestBaseEnv(), + + // Terminal-specific + KITTY_WINDOW_ID: undefined, + SIXEL_SUPPORT: undefined, + ZED_NODE_ENV: undefined, + + // VS Code family detection + VSCODE_THEME_KIND: undefined, + VSCODE_COLOR_THEME_KIND: undefined, + VSCODE_GIT_IPC_HANDLE: undefined, + VSCODE_PID: undefined, + VSCODE_CWD: undefined, + VSCODE_NLS_CONFIG: undefined, + + // Cursor editor detection + CURSOR_PORT: undefined, + CURSOR: undefined, + + // JetBrains IDE detection + JETBRAINS_REMOTE_RUN: undefined, + IDEA_INITIAL_DIRECTORY: undefined, + IDE_CONFIG_DIR: undefined, + JB_IDE_CONFIG_DIR: undefined, + + // Editor preferences + VISUAL: undefined, + EDITOR: undefined, + CODEBUFF_CLI_EDITOR: undefined, + CODEBUFF_EDITOR: undefined, + + // Theme preferences + OPEN_TUI_THEME: undefined, + OPENTUI_THEME: undefined, + + // Codebuff CLI-specific + CODEBUFF_IS_BINARY: undefined, + CODEBUFF_CLI_VERSION: undefined, + CODEBUFF_CLI_TARGET: undefined, + CODEBUFF_RG_PATH: undefined, + CODEBUFF_WASM_DIR: undefined, + + // Build/CI flags + VERBOSE: undefined, + OVERRIDE_TARGET: undefined, + OVERRIDE_PLATFORM: undefined, + OVERRIDE_ARCH: undefined, + ...overrides, +}) + diff --git a/eslint.config.js b/eslint.config.js index 0aaa64cdd..7fc067055 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,6 +4,14 @@ import unusedImports from 'eslint-plugin-unused-imports' import globals from 'globals' import tseslint from 'typescript-eslint' +const restrictedTestingImportPatterns = [ + { + group: ['@codebuff/*/testing/**', '**/testing/**'], + message: + 'Do not import test-only modules from production code. Keep these imports in tests (or move shared code into non-testing modules).', + }, +] + export default tseslint.config( // Global ignores { @@ -15,6 +23,26 @@ export default tseslint.config( ], }, + // Prevent production code from importing test-only modules + { + files: ['**/*.{js,mjs,cjs,ts,tsx}'], + ignores: [ + '**/__tests__/**', + '**/*.test.*', + '**/*.spec.*', + '**/testing/**', + '**/e2e/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: restrictedTestingImportPatterns, + }, + ], + }, + }, + // CLI package: enforce using CliProcessEnv instead of ProcessEnv { files: ['cli/src/**/*.{ts,tsx}'], @@ -43,6 +71,41 @@ export default tseslint.config( }, }, + // CLI package (non-test): prevent importing test-only modules + { + files: ['cli/src/**/*.{ts,tsx}'], + ignores: [ + 'cli/src/**/__tests__/**', + 'cli/src/**/*.test.*', + 'cli/src/**/*.spec.*', + 'cli/src/testing/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@codebuff/common/env-process', + importNames: ['getProcessEnv', 'processEnv'], + message: + 'CLI should use getCliEnv() from "../utils/env" or "./env" instead of getProcessEnv() from common. This ensures CLI uses CliEnv type.', + }, + ], + patterns: [ + { + group: ['@codebuff/common/types/contracts/env'], + importNames: ['ProcessEnv'], + message: + 'CLI should use CliEnv from "../types/env" instead of ProcessEnv from common.', + }, + ...restrictedTestingImportPatterns, + ], + }, + ], + }, + }, + // SDK package: enforce using SdkProcessEnv instead of ProcessEnv { files: ['sdk/src/**/*.{ts,tsx}'], @@ -71,6 +134,41 @@ export default tseslint.config( }, }, + // SDK package (non-test): prevent importing test-only modules + { + files: ['sdk/src/**/*.{ts,tsx}'], + ignores: [ + 'sdk/src/**/__tests__/**', + 'sdk/src/**/*.test.*', + 'sdk/src/**/*.spec.*', + 'sdk/src/testing/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@codebuff/common/env-process', + importNames: ['getProcessEnv', 'processEnv'], + message: + 'SDK should use getSdkEnv() from "./env" instead of getProcessEnv() from common. This ensures SDK uses SdkEnv type.', + }, + ], + patterns: [ + { + group: ['@codebuff/common/types/contracts/env'], + importNames: ['ProcessEnv'], + message: + 'SDK should use SdkEnv from "./types/env" instead of ProcessEnv from common.', + }, + ...restrictedTestingImportPatterns, + ], + }, + ], + }, + }, + // Base config for JS/TS files { files: ['**/*.{js,mjs,cjs,ts,tsx}'], diff --git a/knowledge.md b/knowledge.md index 6200abf3e..60d2fd333 100644 --- a/knowledge.md +++ b/knowledge.md @@ -110,8 +110,11 @@ Env DI helpers: - Base contracts: `common/src/types/contracts/env.ts` (`BaseEnv`, `BaseCiEnv`, `ClientEnv`, `CiEnv`) - Helpers: `common/src/env-process.ts`, `common/src/env-ci.ts` -- CLI: `cli/src/utils/env.ts` (`getCliEnv`, `createTestCliEnv`) -- SDK: `sdk/src/env.ts` (`getSdkEnv`, `createTestSdkEnv`) +- Test fixtures: `common/src/testing/env-process.ts`, `common/src/testing/env-ci.ts` +- CLI: `cli/src/utils/env.ts` (`getCliEnv`) +- CLI test fixtures: `cli/src/testing/env.ts` (`createTestCliEnv`) +- SDK: `sdk/src/env.ts` (`getSdkEnv`) +- SDK test fixtures: `sdk/src/testing/env.ts` (`createTestSdkEnv`) Bun loads (highest precedence last): diff --git a/scripts/check-env-architecture.ts b/scripts/check-env-architecture.ts index 2cf70abe7..a54bea970 100644 --- a/scripts/check-env-architecture.ts +++ b/scripts/check-env-architecture.ts @@ -187,9 +187,7 @@ const getModuleSpecifierText = ( moduleSpecifier: ts.Expression | ts.ModuleReference | undefined, ): string | null => { if (!moduleSpecifier) return null - if (ts.isStringLiteral(moduleSpecifier as any)) { - return (moduleSpecifier as ts.StringLiteral).text - } + if (ts.isStringLiteral(moduleSpecifier)) return moduleSpecifier.text return null } @@ -204,10 +202,7 @@ const isDynamicImportCall = ( return true } -/** - * Detect if a file is an "env helper" by checking if it imports getBaseEnv or createTestBaseEnv - * from @codebuff/common/env-process. These files are the designated entry points for env access. - */ +/** Detect if a file is an "env helper" by checking if it imports getBaseEnv. */ const isEnvHelperFile = (sourceFile: ts.SourceFile): boolean => { for (const stmt of sourceFile.statements) { if (!ts.isImportDeclaration(stmt)) continue @@ -216,12 +211,7 @@ const isEnvHelperFile = (sourceFile: ts.SourceFile): boolean => { if (info.kind === 'named') { for (const spec of info.named) { - if ( - spec.imported === 'getBaseEnv' || - spec.imported === 'createTestBaseEnv' - ) { - return true - } + if (spec.imported === 'getBaseEnv') return true } } } @@ -471,7 +461,7 @@ for (const config of packageConfigs) { } if (ts.isExportDeclaration(node)) { - const spec = getModuleSpecifierText(node.moduleSpecifier as any) + const spec = getModuleSpecifierText(node.moduleSpecifier) if (spec && isInternalImport(spec)) { internalImportLines.push( getLine(sourceFile, node.getStart(sourceFile)), @@ -542,7 +532,7 @@ for (const config of packageConfigs) { } if (ts.isExportDeclaration(node)) { - const spec = getModuleSpecifierText(node.moduleSpecifier as any) + const spec = getModuleSpecifierText(node.moduleSpecifier) if (spec && spec === INTERNAL_ENV_MODULE) { internalEnvImportLines.push( getLine(sourceFile, node.getStart(sourceFile)), diff --git a/sdk/src/__tests__/env.test.ts b/sdk/src/__tests__/env.test.ts index dd99d6952..de25fed39 100644 --- a/sdk/src/__tests__/env.test.ts +++ b/sdk/src/__tests__/env.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' -import { getSdkEnv, createTestSdkEnv } from '../env' +import { getSdkEnv } from '../env' +import { createTestSdkEnv } from '../testing/env' describe('sdk/env', () => { describe('getSdkEnv', () => { diff --git a/sdk/src/env.ts b/sdk/src/env.ts index 1488889dc..3e6855544 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -5,10 +5,7 @@ * process env with SDK-specific vars for binary paths and WASM. */ -import { - getBaseEnv, - createTestBaseEnv, -} from '@codebuff/common/env-process' +import { getBaseEnv } from '@codebuff/common/env-process' import { BYOK_OPENROUTER_ENV_VAR } from '@codebuff/common/constants/byok' import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' @@ -32,25 +29,6 @@ export const getSdkEnv = (): SdkEnv => ({ OVERRIDE_ARCH: process.env.OVERRIDE_ARCH, }) -/** - * Create a test SdkEnv with optional overrides. - * Composes from createTestBaseEnv() for DRY. - */ -export const createTestSdkEnv = ( - overrides: Partial = {}, -): SdkEnv => ({ - ...createTestBaseEnv(), - - // SDK-specific defaults - CODEBUFF_RG_PATH: undefined, - CODEBUFF_WASM_DIR: undefined, - VERBOSE: undefined, - OVERRIDE_TARGET: undefined, - OVERRIDE_PLATFORM: undefined, - OVERRIDE_ARCH: undefined, - ...overrides, -}) - export const getCodebuffApiKeyFromEnv = (): string | undefined => { return process.env[API_KEY_ENV_VAR] } diff --git a/sdk/src/testing/env.ts b/sdk/src/testing/env.ts new file mode 100644 index 000000000..b785c12b6 --- /dev/null +++ b/sdk/src/testing/env.ts @@ -0,0 +1,27 @@ +/** + * Test-only SDK env fixtures. + */ + +import { createTestBaseEnv } from '@codebuff/common/testing/env-process' + +import type { SdkEnv } from '../types/env' + +/** + * Create a test SdkEnv with optional overrides. + * Composes from createTestBaseEnv for DRY. + */ +export const createTestSdkEnv = ( + overrides: Partial = {}, +): SdkEnv => ({ + ...createTestBaseEnv(), + + // SDK-specific defaults + CODEBUFF_RG_PATH: undefined, + CODEBUFF_WASM_DIR: undefined, + VERBOSE: undefined, + OVERRIDE_TARGET: undefined, + OVERRIDE_PLATFORM: undefined, + OVERRIDE_ARCH: undefined, + ...overrides, +}) + From e7ba0c6e447f81b81cf613367bb1aa30f56246f6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 17 Dec 2025 16:59:27 -0800 Subject: [PATCH 11/17] fix(env): avoid crashing when NEXT_PUBLIC vars missing --- common/src/env-schema.ts | 22 ++++++++++++------- .../app/profile/components/usage-section.tsx | 15 ++++++++++++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/common/src/env-schema.ts b/common/src/env-schema.ts index 23eb38f9a..ec017ecac 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -3,15 +3,21 @@ import z from 'zod/v4' export const CLIENT_ENV_PREFIX = 'NEXT_PUBLIC_' export const clientEnvSchema = z.object({ - NEXT_PUBLIC_CB_ENVIRONMENT: z.enum(['dev', 'test', 'prod']), - NEXT_PUBLIC_CODEBUFF_APP_URL: z.url().min(1), - NEXT_PUBLIC_SUPPORT_EMAIL: z.email().min(1), - NEXT_PUBLIC_POSTHOG_API_KEY: z.string().min(1), - NEXT_PUBLIC_POSTHOG_HOST_URL: z.url().min(1), - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), - NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1), + NEXT_PUBLIC_CB_ENVIRONMENT: z.enum(['dev', 'test', 'prod']).default('prod'), + NEXT_PUBLIC_CODEBUFF_APP_URL: z.url().min(1).default('https://codebuff.com'), + NEXT_PUBLIC_SUPPORT_EMAIL: z + .email() + .min(1) + .default('support@codebuff.com'), + NEXT_PUBLIC_POSTHOG_API_KEY: z.string().min(1).optional(), + NEXT_PUBLIC_POSTHOG_HOST_URL: z + .url() + .min(1) + .default('https://us.i.posthog.com'), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1).optional(), + NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1).optional(), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), - NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000), + NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000).default(3000), } satisfies Record<`${typeof CLIENT_ENV_PREFIX}${string}`, any>) export const clientEnvVars = clientEnvSchema.keyof().options export type ClientEnvVar = (typeof clientEnvVars)[number] diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index eaa8beab8..5f10ab7ad 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -38,6 +38,15 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { }, onSuccess: async (data) => { if (data.sessionId) { + if (!env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { + toast({ + title: 'Error', + description: 'Stripe publishable key is not configured.', + variant: 'destructive', + }) + return + } + const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) const stripe = await stripePromise if (!stripe) { @@ -83,7 +92,11 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { isPurchasePending={buyCreditsMutation.isPending} showAutoTopup={true} isLoading={isLoading} - billingPortalUrl={`${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}`} + billingPortalUrl={ + env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL + ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}` + : undefined + } /> From 62f04fcb13c9fc78d3ed82b44591457be7042af9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 18 Dec 2025 13:38:16 -0800 Subject: [PATCH 12/17] Decouple test helpers from production src --- cli/src/__tests__/utils/env.test.ts | 2 +- cli/{src => }/testing/env.ts | 3 +- cli/tsconfig.json | 11 +++++- common/package.json | 38 +++++++++++++++++-- common/src/__tests__/env-ci.test.ts | 2 +- common/src/__tests__/env-process.test.ts | 2 +- common/{src => }/testing/env-ci.ts | 3 +- common/{src => }/testing/env-process.ts | 3 +- .../testing/fixtures/agent-runtime.ts | 13 ++++--- common/{src => }/testing/fixtures/billing.ts | 2 +- common/{src => }/testing/fixtures/database.ts | 0 common/{src => }/testing/fixtures/fetch.ts | 0 common/{src => }/testing/fixtures/index.ts | 0 .../{src => }/testing/impl/agent-runtime.ts | 0 common/{src => }/testing/mock-modules.ts | 0 common/tsconfig.json | 10 ++++- packages/agent-runtime/tsconfig.json | 10 ++++- packages/billing/tsconfig.json | 10 ++++- packages/internal/tsconfig.json | 10 ++++- sdk/package.json | 6 +-- sdk/src/__tests__/env.test.ts | 2 +- sdk/{src => }/testing/env.ts | 3 +- sdk/tsconfig.json | 12 +++++- tsconfig.json | 1 + web/tsconfig.json | 11 +++++- 25 files changed, 122 insertions(+), 32 deletions(-) rename cli/{src => }/testing/env.ts (96%) rename common/{src => }/testing/env-ci.ts (86%) rename common/{src => }/testing/env-process.ts (96%) rename common/{src => }/testing/fixtures/agent-runtime.ts (94%) rename common/{src => }/testing/fixtures/billing.ts (99%) rename common/{src => }/testing/fixtures/database.ts (100%) rename common/{src => }/testing/fixtures/fetch.ts (100%) rename common/{src => }/testing/fixtures/index.ts (100%) rename common/{src => }/testing/impl/agent-runtime.ts (100%) rename common/{src => }/testing/mock-modules.ts (100%) rename sdk/{src => }/testing/env.ts (92%) diff --git a/cli/src/__tests__/utils/env.test.ts b/cli/src/__tests__/utils/env.test.ts index 7c6ad1896..101224d4f 100644 --- a/cli/src/__tests__/utils/env.test.ts +++ b/cli/src/__tests__/utils/env.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, afterEach } from 'bun:test' -import { createTestCliEnv } from '../../testing/env' +import { createTestCliEnv } from '../../../testing/env' import { getCliEnv } from '../../utils/env' describe('cli/utils/env', () => { diff --git a/cli/src/testing/env.ts b/cli/testing/env.ts similarity index 96% rename from cli/src/testing/env.ts rename to cli/testing/env.ts index f79cd45ac..01a315633 100644 --- a/cli/src/testing/env.ts +++ b/cli/testing/env.ts @@ -4,7 +4,7 @@ import { createTestBaseEnv } from '@codebuff/common/testing/env-process' -import type { CliEnv } from '../types/env' +import type { CliEnv } from '../src/types/env' /** * Create a test CliEnv with optional overrides. @@ -45,4 +45,3 @@ export const createTestCliEnv = (overrides: Partial = {}): CliEnv => ({ CODEBUFF_SCROLL_MULTIPLIER: undefined, ...overrides, }) - diff --git a/cli/tsconfig.json b/cli/tsconfig.json index d4b7a9283..3483739d5 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -17,5 +17,14 @@ } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/common/package.json b/common/package.json index c5cb7f6ce..e449d942e 100644 --- a/common/package.json +++ b/common/package.json @@ -12,10 +12,40 @@ "default": "./src/*.ts" }, "./testing/fixtures": { - "bun": "./src/testing/fixtures/index.ts", - "import": "./src/testing/fixtures/index.ts", - "types": "./src/testing/fixtures/index.ts", - "default": "./src/testing/fixtures/index.ts" + "bun": "./testing/fixtures/index.ts", + "import": "./testing/fixtures/index.ts", + "types": "./testing/fixtures/index.ts", + "default": "./testing/fixtures/index.ts" + }, + "./testing/fixtures/*": { + "bun": "./testing/fixtures/*.ts", + "import": "./testing/fixtures/*.ts", + "types": "./testing/fixtures/*.ts", + "default": "./testing/fixtures/*.ts" + }, + "./testing/env-process": { + "bun": "./testing/env-process.ts", + "import": "./testing/env-process.ts", + "types": "./testing/env-process.ts", + "default": "./testing/env-process.ts" + }, + "./testing/env-ci": { + "bun": "./testing/env-ci.ts", + "import": "./testing/env-ci.ts", + "types": "./testing/env-ci.ts", + "default": "./testing/env-ci.ts" + }, + "./testing/mock-modules": { + "bun": "./testing/mock-modules.ts", + "import": "./testing/mock-modules.ts", + "types": "./testing/mock-modules.ts", + "default": "./testing/mock-modules.ts" + }, + "./testing/impl/*": { + "bun": "./testing/impl/*.ts", + "import": "./testing/impl/*.ts", + "types": "./testing/impl/*.ts", + "default": "./testing/impl/*.ts" } }, "scripts": { diff --git a/common/src/__tests__/env-ci.test.ts b/common/src/__tests__/env-ci.test.ts index 41ef2c6ac..ef24f4331 100644 --- a/common/src/__tests__/env-ci.test.ts +++ b/common/src/__tests__/env-ci.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { getCiEnv, ciEnv, isCI } from '../env-ci' -import { createTestCiEnv } from '../testing/env-ci' +import { createTestCiEnv } from '../../testing/env-ci' describe('env-ci', () => { describe('getCiEnv', () => { diff --git a/common/src/__tests__/env-process.test.ts b/common/src/__tests__/env-process.test.ts index 448d2cbd8..532a8f804 100644 --- a/common/src/__tests__/env-process.test.ts +++ b/common/src/__tests__/env-process.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { getProcessEnv, processEnv } from '../env-process' -import { createTestProcessEnv } from '../testing/env-process' +import { createTestProcessEnv } from '../../testing/env-process' describe('env-process', () => { describe('getProcessEnv', () => { diff --git a/common/src/testing/env-ci.ts b/common/testing/env-ci.ts similarity index 86% rename from common/src/testing/env-ci.ts rename to common/testing/env-ci.ts index aee1c2e30..6822183f5 100644 --- a/common/src/testing/env-ci.ts +++ b/common/testing/env-ci.ts @@ -2,7 +2,7 @@ * Test-only CiEnv fixtures. */ -import type { CiEnv } from '../types/contracts/env' +import type { CiEnv } from '../src/types/contracts/env' /** * Create a test CiEnv with optional overrides. @@ -16,4 +16,3 @@ export const createTestCiEnv = (overrides: Partial = {}): CiEnv => ({ CODEBUFF_API_KEY: 'test-api-key', ...overrides, }) - diff --git a/common/src/testing/env-process.ts b/common/testing/env-process.ts similarity index 96% rename from common/src/testing/env-process.ts rename to common/testing/env-process.ts index 61dd4323a..88deebae5 100644 --- a/common/src/testing/env-process.ts +++ b/common/testing/env-process.ts @@ -2,7 +2,7 @@ * Test-only ProcessEnv fixtures. */ -import type { BaseEnv, ProcessEnv } from '../types/contracts/env' +import type { BaseEnv, ProcessEnv } from '../src/types/contracts/env' /** * Create test defaults for BaseEnv. @@ -84,4 +84,3 @@ export const createTestProcessEnv = ( OVERRIDE_ARCH: undefined, ...overrides, }) - diff --git a/common/src/testing/fixtures/agent-runtime.ts b/common/testing/fixtures/agent-runtime.ts similarity index 94% rename from common/src/testing/fixtures/agent-runtime.ts rename to common/testing/fixtures/agent-runtime.ts index bf897725d..e3801a53a 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/testing/fixtures/agent-runtime.ts @@ -7,14 +7,17 @@ import { spyOn } from 'bun:test' -import type { AgentTemplate } from '../../types/agent-template' +import type { AgentTemplate } from '../../src/types/agent-template' import type { AgentRuntimeDeps, AgentRuntimeScopedDeps, -} from '../../types/contracts/agent-runtime' -import type { GetUserInfoFromApiKeyInput, UserColumn } from '../../types/contracts/database' -import type { ClientEnv, CiEnv } from '../../types/contracts/env' -import type { Logger } from '../../types/contracts/logger' +} from '../../src/types/contracts/agent-runtime' +import type { + GetUserInfoFromApiKeyInput, + UserColumn, +} from '../../src/types/contracts/database' +import type { ClientEnv, CiEnv } from '../../src/types/contracts/env' +import type { Logger } from '../../src/types/contracts/logger' export const testLogger: Logger = { debug: () => {}, diff --git a/common/src/testing/fixtures/billing.ts b/common/testing/fixtures/billing.ts similarity index 99% rename from common/src/testing/fixtures/billing.ts rename to common/testing/fixtures/billing.ts index 9f90ac23d..1e8e205be 100644 --- a/common/src/testing/fixtures/billing.ts +++ b/common/testing/fixtures/billing.ts @@ -5,7 +5,7 @@ * These helpers create properly-typed mocks without requiring ugly `as unknown as` casts. */ -import type { Logger } from '../../types/contracts/logger' +import type { Logger } from '../../src/types/contracts/logger' // Re-export the test logger for convenience export { testLogger } from './agent-runtime' diff --git a/common/src/testing/fixtures/database.ts b/common/testing/fixtures/database.ts similarity index 100% rename from common/src/testing/fixtures/database.ts rename to common/testing/fixtures/database.ts diff --git a/common/src/testing/fixtures/fetch.ts b/common/testing/fixtures/fetch.ts similarity index 100% rename from common/src/testing/fixtures/fetch.ts rename to common/testing/fixtures/fetch.ts diff --git a/common/src/testing/fixtures/index.ts b/common/testing/fixtures/index.ts similarity index 100% rename from common/src/testing/fixtures/index.ts rename to common/testing/fixtures/index.ts diff --git a/common/src/testing/impl/agent-runtime.ts b/common/testing/impl/agent-runtime.ts similarity index 100% rename from common/src/testing/impl/agent-runtime.ts rename to common/testing/impl/agent-runtime.ts diff --git a/common/src/testing/mock-modules.ts b/common/testing/mock-modules.ts similarity index 100% rename from common/src/testing/mock-modules.ts rename to common/testing/mock-modules.ts diff --git a/common/tsconfig.json b/common/tsconfig.json index e6f4cc498..8c0d72a3f 100644 --- a/common/tsconfig.json +++ b/common/tsconfig.json @@ -5,5 +5,13 @@ "baseUrl": "." }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/packages/agent-runtime/tsconfig.json b/packages/agent-runtime/tsconfig.json index ad5d6e855..cfe4db6e6 100644 --- a/packages/agent-runtime/tsconfig.json +++ b/packages/agent-runtime/tsconfig.json @@ -4,5 +4,13 @@ "types": ["bun", "node"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/packages/billing/tsconfig.json b/packages/billing/tsconfig.json index 51864d1a5..975427d4e 100644 --- a/packages/billing/tsconfig.json +++ b/packages/billing/tsconfig.json @@ -5,5 +5,13 @@ "types": ["bun", "node"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/packages/internal/tsconfig.json b/packages/internal/tsconfig.json index 51864d1a5..975427d4e 100644 --- a/packages/internal/tsconfig.json +++ b/packages/internal/tsconfig.json @@ -5,5 +5,13 @@ "types": ["bun", "node"] }, "include": ["src/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/sdk/package.json b/sdk/package.json index c653b5255..77c1ea3d2 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -25,9 +25,9 @@ "build": "bun run scripts/build.ts", "clean": "rm -rf dist", "typecheck": "tsc --noEmit -p .", - "test": "bun test", - "test:e2e": "bun test e2e/streaming/ e2e/workflows/ e2e/custom-agents/ e2e/features/", - "test:integration": "bun test e2e/integration/", + "test": "bun test --timeout 30000", + "test:e2e": "bun test --timeout 30000 e2e/streaming/ e2e/workflows/ e2e/custom-agents/ e2e/features/", + "test:integration": "bun test --timeout 30000 e2e/integration/", "verify": "bun run scripts/verify.ts", "verify:skip-build": "bun run scripts/verify.ts --skip-build", "smoke-test:dist": "bun run smoke-test-dist.ts", diff --git a/sdk/src/__tests__/env.test.ts b/sdk/src/__tests__/env.test.ts index de25fed39..f8b51a2c2 100644 --- a/sdk/src/__tests__/env.test.ts +++ b/sdk/src/__tests__/env.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { getSdkEnv } from '../env' -import { createTestSdkEnv } from '../testing/env' +import { createTestSdkEnv } from '../../testing/env' describe('sdk/env', () => { describe('getSdkEnv', () => { diff --git a/sdk/src/testing/env.ts b/sdk/testing/env.ts similarity index 92% rename from sdk/src/testing/env.ts rename to sdk/testing/env.ts index b785c12b6..d687d7870 100644 --- a/sdk/src/testing/env.ts +++ b/sdk/testing/env.ts @@ -4,7 +4,7 @@ import { createTestBaseEnv } from '@codebuff/common/testing/env-process' -import type { SdkEnv } from '../types/env' +import type { SdkEnv } from '../src/types/env' /** * Create a test SdkEnv with optional overrides. @@ -24,4 +24,3 @@ export const createTestSdkEnv = ( OVERRIDE_ARCH: undefined, ...overrides, }) - diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json index bf0a2e400..c8d2e2ae2 100644 --- a/sdk/tsconfig.json +++ b/sdk/tsconfig.json @@ -15,6 +15,7 @@ "types": ["node", "bun-types"], "baseUrl": ".", "paths": { + "@codebuff/common/testing/*": ["../common/testing/*"], "@codebuff/common/*": ["../common/src/*"], "@codebuff/agent-runtime/*": ["../packages/agent-runtime/src/*"], "@codebuff/code-map": ["../packages/code-map/src/index.ts"], @@ -22,5 +23,14 @@ } }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] + "exclude": [ + "node_modules", + "dist", + "src/**/__tests__/**", + "src/**/__mocks__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] } diff --git a/tsconfig.json b/tsconfig.json index d87be59cd..24d861d63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "noEmit": true, // don't place JS everywhere while hacking "baseUrl": ".", // Bun & editors see the aliases "paths": { + "@codebuff/common/testing/*": ["./common/testing/*"], "@codebuff/common/*": ["./common/src/*"], "@codebuff/web/*": ["./web/src/*"], "@codebuff/evals/*": ["./evals/*"], diff --git a/web/tsconfig.json b/web/tsconfig.json index 9819b2142..e84e16e97 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -34,7 +34,16 @@ "**/*.mjs", ".next/types/**/*.ts" ], - "exclude": ["node_modules", ".contentlayer"], + "exclude": [ + "node_modules", + ".contentlayer", + "**/__tests__/**", + "**/__mocks__/**", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.spec.ts", + "**/*.spec.tsx" + ], "ts-node": { "require": ["tsconfig-paths/register"] } From 929e3ea17a91207f94bc56439d8653efc4f9dfe1 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 18 Dec 2025 14:33:48 -0800 Subject: [PATCH 13/17] refactor(common): consolidate testing exports to 2 entries --- .../integration/credentials-storage.test.ts | 2 +- cli/testing/env.ts | 2 +- common/package.json | 24 ------------------- common/src/__tests__/env-ci.test.ts | 2 +- common/src/__tests__/env-process.test.ts | 2 +- common/src/env-schema.ts | 2 +- common/testing/{ => fixtures}/env-ci.ts | 0 common/testing/{ => fixtures}/env-process.ts | 0 common/testing/fixtures/index.ts | 7 ++++++ common/testing/{ => fixtures}/mock-modules.ts | 0 common/testing/impl/agent-runtime.ts | 5 ---- .../src/__tests__/cost-aggregation.test.ts | 2 +- .../src/__tests__/fast-rewrite.test.ts | 2 +- .../__tests__/generate-diffs-prompt.test.ts | 2 +- .../src/__tests__/live-user-inputs.test.ts | 2 +- .../src/__tests__/loop-agent-steps.test.ts | 2 +- .../src/__tests__/main-prompt.test.ts | 2 +- .../src/__tests__/n-parameter.test.ts | 2 +- .../src/__tests__/process-file-block.test.ts | 2 +- .../prompt-caching-subagents.test.ts | 2 +- .../src/__tests__/propose-tools.test.ts | 2 +- .../src/__tests__/read-docs-tool.test.ts | 2 +- .../__tests__/run-agent-step-tools.test.ts | 2 +- .../__tests__/run-programmatic-step.test.ts | 2 +- .../src/__tests__/sandbox-generator.test.ts | 2 +- .../spawn-agents-image-content.test.ts | 2 +- .../spawn-agents-message-history.test.ts | 2 +- .../spawn-agents-permissions.test.ts | 2 +- .../src/__tests__/subagent-streaming.test.ts | 2 +- .../src/__tests__/tool-stream-parser.test.ts | 2 +- .../src/__tests__/web-search-tool.test.ts | 2 +- .../xml-tool-result-ordering.test.ts | 2 +- .../__tests__/request-files-prompt.test.ts | 2 +- .../src/llm-api/__tests__/linkup-api.test.ts | 2 +- .../__tests__/agent-registry.test.ts | 2 +- sdk/testing/env.ts | 2 +- web/src/app/api/v1/me/__tests__/me.test.ts | 2 +- .../app/orgs/[slug]/billing/purchase/page.tsx | 2 +- .../app/orgs/[slug]/billing/setup/page.tsx | 9 +++---- web/src/app/orgs/[slug]/page.tsx | 2 +- .../app/profile/components/usage-section.tsx | 9 ------- 41 files changed, 45 insertions(+), 75 deletions(-) rename common/testing/{ => fixtures}/env-ci.ts (100%) rename common/testing/{ => fixtures}/env-process.ts (100%) rename common/testing/{ => fixtures}/mock-modules.ts (100%) delete mode 100644 common/testing/impl/agent-runtime.ts diff --git a/cli/src/__tests__/integration/credentials-storage.test.ts b/cli/src/__tests__/integration/credentials-storage.test.ts index fba687cc4..7b5b73906 100644 --- a/cli/src/__tests__/integration/credentials-storage.test.ts +++ b/cli/src/__tests__/integration/credentials-storage.test.ts @@ -5,7 +5,7 @@ import path from 'path' import { clearMockedModules, mockModule, -} from '@codebuff/common/testing/mock-modules' +} from '@codebuff/common/testing/fixtures' import { describe, test, diff --git a/cli/testing/env.ts b/cli/testing/env.ts index 01a315633..b7a148ae5 100644 --- a/cli/testing/env.ts +++ b/cli/testing/env.ts @@ -2,7 +2,7 @@ * Test-only CLI env fixtures. */ -import { createTestBaseEnv } from '@codebuff/common/testing/env-process' +import { createTestBaseEnv } from '@codebuff/common/testing/fixtures' import type { CliEnv } from '../src/types/env' diff --git a/common/package.json b/common/package.json index e449d942e..e3266c746 100644 --- a/common/package.json +++ b/common/package.json @@ -22,30 +22,6 @@ "import": "./testing/fixtures/*.ts", "types": "./testing/fixtures/*.ts", "default": "./testing/fixtures/*.ts" - }, - "./testing/env-process": { - "bun": "./testing/env-process.ts", - "import": "./testing/env-process.ts", - "types": "./testing/env-process.ts", - "default": "./testing/env-process.ts" - }, - "./testing/env-ci": { - "bun": "./testing/env-ci.ts", - "import": "./testing/env-ci.ts", - "types": "./testing/env-ci.ts", - "default": "./testing/env-ci.ts" - }, - "./testing/mock-modules": { - "bun": "./testing/mock-modules.ts", - "import": "./testing/mock-modules.ts", - "types": "./testing/mock-modules.ts", - "default": "./testing/mock-modules.ts" - }, - "./testing/impl/*": { - "bun": "./testing/impl/*.ts", - "import": "./testing/impl/*.ts", - "types": "./testing/impl/*.ts", - "default": "./testing/impl/*.ts" } }, "scripts": { diff --git a/common/src/__tests__/env-ci.test.ts b/common/src/__tests__/env-ci.test.ts index ef24f4331..db5463f1c 100644 --- a/common/src/__tests__/env-ci.test.ts +++ b/common/src/__tests__/env-ci.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { getCiEnv, ciEnv, isCI } from '../env-ci' -import { createTestCiEnv } from '../../testing/env-ci' +import { createTestCiEnv } from '../../testing/fixtures' describe('env-ci', () => { describe('getCiEnv', () => { diff --git a/common/src/__tests__/env-process.test.ts b/common/src/__tests__/env-process.test.ts index 532a8f804..434f61c92 100644 --- a/common/src/__tests__/env-process.test.ts +++ b/common/src/__tests__/env-process.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, afterEach } from 'bun:test' import { getProcessEnv, processEnv } from '../env-process' -import { createTestProcessEnv } from '../../testing/env-process' +import { createTestProcessEnv } from '../../testing/fixtures' describe('env-process', () => { describe('getProcessEnv', () => { diff --git a/common/src/env-schema.ts b/common/src/env-schema.ts index ec017ecac..4a36f17f6 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -14,7 +14,7 @@ export const clientEnvSchema = z.object({ .url() .min(1) .default('https://us.i.posthog.com'), - NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1).optional(), + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1).optional(), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000).default(3000), diff --git a/common/testing/env-ci.ts b/common/testing/fixtures/env-ci.ts similarity index 100% rename from common/testing/env-ci.ts rename to common/testing/fixtures/env-ci.ts diff --git a/common/testing/env-process.ts b/common/testing/fixtures/env-process.ts similarity index 100% rename from common/testing/env-process.ts rename to common/testing/fixtures/env-process.ts diff --git a/common/testing/fixtures/index.ts b/common/testing/fixtures/index.ts index fcf67a1f8..d2a35f517 100644 --- a/common/testing/fixtures/index.ts +++ b/common/testing/fixtures/index.ts @@ -57,3 +57,10 @@ export { type MockFetchFn, type MockFetchResponseConfig, } from './fetch' + +// Environment fixtures +export { createTestBaseEnv, createTestProcessEnv } from './env-process' +export { createTestCiEnv } from './env-ci' + +// Module mocking utilities +export { mockModule, clearMockedModules, type MockResult } from './mock-modules' diff --git a/common/testing/mock-modules.ts b/common/testing/fixtures/mock-modules.ts similarity index 100% rename from common/testing/mock-modules.ts rename to common/testing/fixtures/mock-modules.ts diff --git a/common/testing/impl/agent-runtime.ts b/common/testing/impl/agent-runtime.ts deleted file mode 100644 index 8f8179fcb..000000000 --- a/common/testing/impl/agent-runtime.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @deprecated Use `@codebuff/common/testing/fixtures/agent-runtime` instead. - */ - -export * from '../fixtures/agent-runtime' diff --git a/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts b/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts index 3ea6392af..e6caab751 100644 --- a/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts +++ b/packages/agent-runtime/src/__tests__/cost-aggregation.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialAgentState, getInitialSessionState, diff --git a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts index 1800251dd..37c90632a 100644 --- a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts +++ b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts @@ -1,6 +1,6 @@ import path from 'path' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { afterAll, beforeEach, describe, expect, it } from 'bun:test' import { createPatch } from 'diff' diff --git a/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts b/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts index e61ca1329..817200275 100644 --- a/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/generate-diffs-prompt.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { expect, describe, it } from 'bun:test' import { parseAndGetDiffBlocksSingleFile } from '../generate-diffs-prompt' diff --git a/packages/agent-runtime/src/__tests__/live-user-inputs.test.ts b/packages/agent-runtime/src/__tests__/live-user-inputs.test.ts index 39bc51352..a436f539d 100644 --- a/packages/agent-runtime/src/__tests__/live-user-inputs.test.ts +++ b/packages/agent-runtime/src/__tests__/live-user-inputs.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { describe, it, expect, beforeEach } from 'bun:test' import { diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 464c92f86..e266dcbdb 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -4,7 +4,7 @@ import { mockBigQuery, mockRandomUUID, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import * as bigquery from '@codebuff/bigquery' diff --git a/packages/agent-runtime/src/__tests__/main-prompt.test.ts b/packages/agent-runtime/src/__tests__/main-prompt.test.ts index bccb016cb..3a25d05b2 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -4,7 +4,7 @@ import { mockAnalytics, mockBigQuery, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { AgentTemplateTypes, getInitialSessionState, diff --git a/packages/agent-runtime/src/__tests__/n-parameter.test.ts b/packages/agent-runtime/src/__tests__/n-parameter.test.ts index c0b293043..20d12478f 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -3,7 +3,7 @@ import { mockAnalytics, mockRandomUUID, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import { diff --git a/packages/agent-runtime/src/__tests__/process-file-block.test.ts b/packages/agent-runtime/src/__tests__/process-file-block.test.ts index 4a9f52298..9484b54b6 100644 --- a/packages/agent-runtime/src/__tests__/process-file-block.test.ts +++ b/packages/agent-runtime/src/__tests__/process-file-block.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { cleanMarkdownCodeBlock } from '@codebuff/common/util/file' import { beforeEach, describe, expect, it } from 'bun:test' import { applyPatch } from 'diff' diff --git a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts index 0991970fe..655c9af0f 100644 --- a/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts +++ b/packages/agent-runtime/src/__tests__/prompt-caching-subagents.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import { beforeEach, describe, expect, it, beforeAll } from 'bun:test' diff --git a/packages/agent-runtime/src/__tests__/propose-tools.test.ts b/packages/agent-runtime/src/__tests__/propose-tools.test.ts index 3fac2be8f..84244b490 100644 --- a/packages/agent-runtime/src/__tests__/propose-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/propose-tools.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, diff --git a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts index 0d7db379e..ba9943ba0 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -4,7 +4,7 @@ import { mockAnalytics, mockBigQuery, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { afterEach, diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index 131df4251..a99ac8abd 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -4,7 +4,7 @@ import { mockAnalytics, mockBigQuery, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import { diff --git a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts index b856eee22..e3c7af80b 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -3,7 +3,7 @@ import { mockAnalytics, mockRandomUUID, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, diff --git a/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts b/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts index 56a0c58c9..9ad38dec9 100644 --- a/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts +++ b/packages/agent-runtime/src/__tests__/sandbox-generator.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialAgentState, type AgentState, diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts index 952df3000..95a9a75b1 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-image-content.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts index e0273a183..ed1cc05c5 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-message-history.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, diff --git a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts index 7bc60b19b..cc556e8dc 100644 --- a/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts +++ b/packages/agent-runtime/src/__tests__/spawn-agents-permissions.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' import { diff --git a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts index 1d5dd1011..77f6128de 100644 --- a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +++ b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' import { diff --git a/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts b/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts index 6f0f480ef..ad8e2b59e 100644 --- a/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts +++ b/packages/agent-runtime/src/__tests__/tool-stream-parser.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { beforeEach, describe, expect, it } from 'bun:test' import { processStreamWithTools } from '../tool-stream-parser' diff --git a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts index 39608b9bf..d7a2b1a9d 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -4,7 +4,7 @@ import { mockAnalytics, mockBigQuery, } from '@codebuff/common/testing/fixtures' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { success } from '@codebuff/common/util/error' import { diff --git a/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts b/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts index 978e8b900..d82390e35 100644 --- a/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts +++ b/packages/agent-runtime/src/__tests__/xml-tool-result-ordering.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { beforeEach, describe, expect, it } from 'bun:test' import { processStreamWithTools } from '../tool-stream-parser' diff --git a/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts b/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts index bc6a8af95..0268637fe 100644 --- a/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts +++ b/packages/agent-runtime/src/find-files/__tests__/request-files-prompt.test.ts @@ -1,5 +1,5 @@ import { finetunedVertexModels } from '@codebuff/common/old-constants' -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { userMessage } from '@codebuff/common/util/messages' import { beforeEach, describe, expect, it, mock } from 'bun:test' diff --git a/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts b/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts index 64eae2add..f4024e3c6 100644 --- a/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts +++ b/packages/agent-runtime/src/llm-api/__tests__/linkup-api.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { createMockFetch, createMockFetchError, diff --git a/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts b/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts index 583264ccf..1ff53cbe0 100644 --- a/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts +++ b/packages/agent-runtime/src/templates/__tests__/agent-registry.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import * as validationModule from '@codebuff/common/templates/agent-validation' import { getStubProjectFileContext } from '@codebuff/common/util/file' import { diff --git a/sdk/testing/env.ts b/sdk/testing/env.ts index d687d7870..d4a5c6c44 100644 --- a/sdk/testing/env.ts +++ b/sdk/testing/env.ts @@ -2,7 +2,7 @@ * Test-only SDK env fixtures. */ -import { createTestBaseEnv } from '@codebuff/common/testing/env-process' +import { createTestBaseEnv } from '@codebuff/common/testing/fixtures' import type { SdkEnv } from '../src/types/env' diff --git a/web/src/app/api/v1/me/__tests__/me.test.ts b/web/src/app/api/v1/me/__tests__/me.test.ts index 3e32f5fc9..56758869b 100644 --- a/web/src/app/api/v1/me/__tests__/me.test.ts +++ b/web/src/app/api/v1/me/__tests__/me.test.ts @@ -1,4 +1,4 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { describe, test, expect, beforeEach } from 'bun:test' import { NextRequest } from 'next/server' diff --git a/web/src/app/orgs/[slug]/billing/purchase/page.tsx b/web/src/app/orgs/[slug]/billing/purchase/page.tsx index 61f169eb6..7edbd757b 100644 --- a/web/src/app/orgs/[slug]/billing/purchase/page.tsx +++ b/web/src/app/orgs/[slug]/billing/purchase/page.tsx @@ -78,7 +78,7 @@ export default function OrganizationBillingPurchasePage() { localStorage.setItem('pendingCreditPurchase', credits.toString()) // Redirect to Stripe Checkout - const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) + const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) if (stripe) { const { error } = await stripe.redirectToCheckout({ diff --git a/web/src/app/orgs/[slug]/billing/setup/page.tsx b/web/src/app/orgs/[slug]/billing/setup/page.tsx index 04fff2127..f41a2d81d 100644 --- a/web/src/app/orgs/[slug]/billing/setup/page.tsx +++ b/web/src/app/orgs/[slug]/billing/setup/page.tsx @@ -1,7 +1,6 @@ 'use client' import { env } from '@codebuff/common/env' -import { loadStripe } from '@stripe/stripe-js' import { ArrowLeft, CreditCard, Loader2 } from 'lucide-react' import Link from 'next/link' import { useParams, useRouter } from 'next/navigation' @@ -26,8 +25,6 @@ interface OrganizationDetails { userRole: 'owner' | 'admin' | 'member' } -const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) - export default function BillingSetupPage() { const { data: session, status } = useSession() const params = useParams() ?? {} @@ -62,7 +59,11 @@ export default function BillingSetupPage() { const { sessionId } = await response.json() // Redirect to Stripe Checkout - const stripeInstance = await stripePromise + const stripe = (await import('@stripe/stripe-js')).loadStripe( + env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, + ) + + const stripeInstance = await stripe if (stripeInstance) { const { error } = await stripeInstance.redirectToCheckout({ sessionId, diff --git a/web/src/app/orgs/[slug]/page.tsx b/web/src/app/orgs/[slug]/page.tsx index 882e94820..620ad8dcf 100644 --- a/web/src/app/orgs/[slug]/page.tsx +++ b/web/src/app/orgs/[slug]/page.tsx @@ -93,7 +93,7 @@ export default function OrganizationPage() { const { sessionId } = await response.json() // Redirect to Stripe Checkout - const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) + const stripe = await loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) if (stripe) { const { error } = await stripe.redirectToCheckout({ diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index 5f10ab7ad..b72558dbd 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -38,15 +38,6 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { }, onSuccess: async (data) => { if (data.sessionId) { - if (!env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) { - toast({ - title: 'Error', - description: 'Stripe publishable key is not configured.', - variant: 'destructive', - }) - return - } - const stripePromise = loadStripe(env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) const stripe = await stripePromise if (!stripe) { From 4e4030d3a1f0e5ac5ecc63e90d81f746cec73257 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 18 Dec 2025 14:44:48 -0800 Subject: [PATCH 14/17] refactor(env): make POSTHOG_API_KEY and STRIPE_CUSTOMER_PORTAL required - Remove .optional() from NEXT_PUBLIC_POSTHOG_API_KEY - Remove .optional() from NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL - Remove conditional check in usage-section.tsx since portal URL is now required - All required vars have dummy values in .env.example --- common/src/env-schema.ts | 4 ++-- web/src/app/profile/components/usage-section.tsx | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/common/src/env-schema.ts b/common/src/env-schema.ts index 4a36f17f6..a31f05630 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -9,13 +9,13 @@ export const clientEnvSchema = z.object({ .email() .min(1) .default('support@codebuff.com'), - NEXT_PUBLIC_POSTHOG_API_KEY: z.string().min(1).optional(), + NEXT_PUBLIC_POSTHOG_API_KEY: z.string().min(1), NEXT_PUBLIC_POSTHOG_HOST_URL: z .url() .min(1) .default('https://us.i.posthog.com'), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), - NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1).optional(), + NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000).default(3000), } satisfies Record<`${typeof CLIENT_ENV_PREFIX}${string}`, any>) diff --git a/web/src/app/profile/components/usage-section.tsx b/web/src/app/profile/components/usage-section.tsx index b72558dbd..eaa8beab8 100644 --- a/web/src/app/profile/components/usage-section.tsx +++ b/web/src/app/profile/components/usage-section.tsx @@ -83,11 +83,7 @@ const ManageCreditsCard = ({ isLoading = false }: { isLoading?: boolean }) => { isPurchasePending={buyCreditsMutation.isPending} showAutoTopup={true} isLoading={isLoading} - billingPortalUrl={ - env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL - ? `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}` - : undefined - } + billingPortalUrl={`${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${email}`} /> From 3144056d02f301bbadb8865523af72f5b8a2870d Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 18 Dec 2025 14:49:20 -0800 Subject: [PATCH 15/17] refactor(env): remove defaults from most client env vars - Remove .default() from NEXT_PUBLIC_CB_ENVIRONMENT - Remove .default() from NEXT_PUBLIC_CODEBUFF_APP_URL - Remove .default() from NEXT_PUBLIC_WEB_PORT - Keep defaults only for SUPPORT_EMAIL and POSTHOG_HOST_URL - Add NEXT_PUBLIC_WEB_PORT to .env.example --- .env.example | 1 + common/src/env-schema.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index f7ab29e62..dae131f50 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,4 @@ NEXT_PUBLIC_POSTHOG_API_KEY=phc_dummy_posthog_key NEXT_PUBLIC_POSTHOG_HOST_URL=https://us.i.posthog.com NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_dummy_publishable NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL=https://billing.stripe.com/p/login/test_dummy +NEXT_PUBLIC_WEB_PORT=3000 diff --git a/common/src/env-schema.ts b/common/src/env-schema.ts index a31f05630..eabe9c90b 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -3,8 +3,8 @@ import z from 'zod/v4' export const CLIENT_ENV_PREFIX = 'NEXT_PUBLIC_' export const clientEnvSchema = z.object({ - NEXT_PUBLIC_CB_ENVIRONMENT: z.enum(['dev', 'test', 'prod']).default('prod'), - NEXT_PUBLIC_CODEBUFF_APP_URL: z.url().min(1).default('https://codebuff.com'), + NEXT_PUBLIC_CB_ENVIRONMENT: z.enum(['dev', 'test', 'prod']), + NEXT_PUBLIC_CODEBUFF_APP_URL: z.url().min(1), NEXT_PUBLIC_SUPPORT_EMAIL: z .email() .min(1) @@ -17,7 +17,7 @@ export const clientEnvSchema = z.object({ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1), NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL: z.url().min(1), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), - NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000).default(3000), + NEXT_PUBLIC_WEB_PORT: z.coerce.number().min(1000), } satisfies Record<`${typeof CLIENT_ENV_PREFIX}${string}`, any>) export const clientEnvVars = clientEnvSchema.keyof().options export type ClientEnvVar = (typeof clientEnvVars)[number] From 6bb14729f4a31891e2e10b4cdde5c22475287501 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 20 Dec 2025 11:47:56 -0800 Subject: [PATCH 16/17] fix(billing): make stripe metering skip list env-based --- .env.example | 2 ++ packages/billing/src/stripe-metering.ts | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index dae131f50..8949ac10f 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret STRIPE_WEBHOOK_SECRET_KEY=whsec_dummy_webhook_secret STRIPE_USAGE_PRICE_ID=price_dummy_usage_id STRIPE_TEAM_FEE_PRICE_ID=price_dummy_team_fee_id +# Optional: comma-separated user IDs to skip Stripe metering (dev/staging) +CODEBUFF_STRIPE_METERING_SKIP_USER_IDS= # External Services LINKUP_API_KEY=dummy_linkup_key diff --git a/packages/billing/src/stripe-metering.ts b/packages/billing/src/stripe-metering.ts index efe6e4155..b717e6a62 100644 --- a/packages/billing/src/stripe-metering.ts +++ b/packages/billing/src/stripe-metering.ts @@ -1,4 +1,3 @@ -import { TEST_USER_ID } from '@codebuff/common/old-constants' import { withRetry, withTimeout } from '@codebuff/common/util/promise' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' @@ -9,6 +8,8 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' const STRIPE_METER_EVENT_NAME = 'credits' const STRIPE_METER_REQUEST_TIMEOUT_MS = 10_000 +const STRIPE_METERING_SKIP_USER_IDS_ENV_VAR = + 'CODEBUFF_STRIPE_METERING_SKIP_USER_IDS' function shouldAttemptStripeMetering(): boolean { // Avoid sending Stripe metering events in CI/tests, and when Stripe isn't configured. @@ -17,6 +18,16 @@ function shouldAttemptStripeMetering(): boolean { return Boolean(process.env.STRIPE_SECRET_KEY) } +function shouldSkipStripeMeteringForUser(userId: string): boolean { + const rawIds = process.env[STRIPE_METERING_SKIP_USER_IDS_ENV_VAR] + if (!rawIds) return false + return rawIds + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .includes(userId) +} + export async function reportPurchasedCreditsToStripe(params: { userId: string stripeCustomerId?: string | null @@ -48,7 +59,7 @@ export async function reportPurchasedCreditsToStripe(params: { } = params if (purchasedCredits <= 0) return - if (userId === TEST_USER_ID) return + if (shouldSkipStripeMeteringForUser(userId)) return if (!shouldAttemptStripeMetering()) return const logContext = { userId, purchasedCredits, eventId } From 05a665b9383f61f2ede629170231b6f72152b641 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 20 Dec 2025 13:53:17 -0800 Subject: [PATCH 17/17] fix(billing): remove stripe metering user skip list --- .env.example | 2 -- packages/billing/src/stripe-metering.ts | 13 ------------- 2 files changed, 15 deletions(-) diff --git a/.env.example b/.env.example index 8949ac10f..dae131f50 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,6 @@ STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret STRIPE_WEBHOOK_SECRET_KEY=whsec_dummy_webhook_secret STRIPE_USAGE_PRICE_ID=price_dummy_usage_id STRIPE_TEAM_FEE_PRICE_ID=price_dummy_team_fee_id -# Optional: comma-separated user IDs to skip Stripe metering (dev/staging) -CODEBUFF_STRIPE_METERING_SKIP_USER_IDS= # External Services LINKUP_API_KEY=dummy_linkup_key diff --git a/packages/billing/src/stripe-metering.ts b/packages/billing/src/stripe-metering.ts index b717e6a62..6f45450d0 100644 --- a/packages/billing/src/stripe-metering.ts +++ b/packages/billing/src/stripe-metering.ts @@ -8,8 +8,6 @@ import type { Logger } from '@codebuff/common/types/contracts/logger' const STRIPE_METER_EVENT_NAME = 'credits' const STRIPE_METER_REQUEST_TIMEOUT_MS = 10_000 -const STRIPE_METERING_SKIP_USER_IDS_ENV_VAR = - 'CODEBUFF_STRIPE_METERING_SKIP_USER_IDS' function shouldAttemptStripeMetering(): boolean { // Avoid sending Stripe metering events in CI/tests, and when Stripe isn't configured. @@ -18,16 +16,6 @@ function shouldAttemptStripeMetering(): boolean { return Boolean(process.env.STRIPE_SECRET_KEY) } -function shouldSkipStripeMeteringForUser(userId: string): boolean { - const rawIds = process.env[STRIPE_METERING_SKIP_USER_IDS_ENV_VAR] - if (!rawIds) return false - return rawIds - .split(',') - .map((value) => value.trim()) - .filter(Boolean) - .includes(userId) -} - export async function reportPurchasedCreditsToStripe(params: { userId: string stripeCustomerId?: string | null @@ -59,7 +47,6 @@ export async function reportPurchasedCreditsToStripe(params: { } = params if (purchasedCredits <= 0) return - if (shouldSkipStripeMeteringForUser(userId)) return if (!shouldAttemptStripeMetering()) return const logContext = { userId, purchasedCredits, eventId }