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/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=="], diff --git a/cli/src/__tests__/integration/api-integration.test.ts b/cli/src/__tests__/integration/api-integration.test.ts index f2af505a0..1ad4ae00a 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, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { AuthenticationError, NetworkError, @@ -41,10 +42,10 @@ describe('API Integration', () => { }) as LoggerMocks const setFetchMock = ( - impl: Parameters[0], - ): ReturnType => { - const fetchMock = mock(impl) - globalThis.fetch = fetchMock as unknown as typeof fetch + impl: FetchCallFn, + ): ReturnType> => { + const fetchMock = mock(impl) + globalThis.fetch = wrapMockAsFetch(fetchMock) return fetchMock } 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/src/__tests__/integration/usage-refresh-on-completion.test.ts b/cli/src/__tests__/integration/usage-refresh-on-completion.test.ts index 86d56c872..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,3 +1,4 @@ +import { wrapMockAsFetch, type FetchCallFn } 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(() => { @@ -80,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' @@ -101,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++) { @@ -123,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' @@ -145,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' @@ -165,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) @@ -180,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/__tests__/utils/env.test.ts b/cli/src/__tests__/utils/env.test.ts index 72490c2fa..101224d4f 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/hooks/__tests__/use-usage-query.test.ts b/cli/src/hooks/__tests__/use-usage-query.test.ts index 567745ebc..14040e654 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, type FetchCallFn } from '@codebuff/common/testing/fixtures' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook, waitFor } from '@testing-library/react' import { @@ -11,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' @@ -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,18 +61,18 @@ describe('fetchUsageData', () => { }) test('should throw error on failed request', async () => { - globalThis.fetch = mock( - async () => new Response('Error', { status: 500 }), - ) as unknown as typeof fetch - const mockLogger = { - error: mock(() => {}), - warn: mock(() => {}), - info: mock(() => {}), - debug: mock(() => {}), + globalThis.fetch = wrapMockAsFetch( + mock(async () => new Response('Error', { status: 500 })), + ) + 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') }) @@ -77,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') }) @@ -127,13 +128,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 +151,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 +168,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(), @@ -199,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 31be2844d..da97961dc 100644 --- a/cli/src/utils/__tests__/codebuff-api.test.ts +++ b/cli/src/utils/__tests__/codebuff-api.test.ts @@ -1,15 +1,16 @@ +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, @@ -39,20 +40,20 @@ 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 }) 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 () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetch), }) await client.get('/api/v1/me', { @@ -60,23 +61,22 @@ 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 () => { 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 }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({ Authorization: 'Bearer my-token', }) @@ -86,15 +86,12 @@ 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 }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.headers).toEqual({}) }) }) @@ -103,15 +100,12 @@ 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 }) - 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', @@ -123,7 +117,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( @@ -132,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;', @@ -147,15 +138,12 @@ 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 }) - 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', @@ -167,15 +155,12 @@ 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 }) - const [, options] = mockFetch.mock.calls[0] as [ - string, - RequestInit | undefined, - ] + const [, options] = mockFetch.mock.calls[0] expect(options?.method).toBe('PATCH') }) }) @@ -184,16 +169,13 @@ 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 }) - 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() }) @@ -202,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, @@ -212,7 +194,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 }) @@ -225,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, @@ -236,7 +218,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,19 +231,21 @@ describe('createCodebuffApiClient', () => { }) test('should handle non-JSON error responses', async () => { - const mockErrorFetch = mock(() => + // 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, status: 500, 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 }) @@ -274,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, @@ -284,7 +268,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 }) @@ -297,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({ @@ -316,7 +300,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 @@ -331,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, @@ -342,7 +326,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 }, }) @@ -354,7 +338,7 @@ describe('createCodebuffApiClient', () => { }) test('should respect retry: false option', async () => { - const mockServerErrorFetch = mock(() => + const mockServerErrorFetch = mock(() => Promise.resolve({ ok: false, status: 500, @@ -365,7 +349,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockServerErrorFetch as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockServerErrorFetch), retry: { maxRetries: 3 }, }) @@ -377,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')) @@ -391,7 +375,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 }, }) @@ -406,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, @@ -419,7 +403,7 @@ describe('createCodebuffApiClient', () => { const client = createCodebuffApiClient({ baseUrl: 'https://test.api', - fetch: mockFetchWithSignal as unknown as typeof fetch, + fetch: wrapMockAsFetch(mockFetchWithSignal), defaultTimeoutMs: 5000, }) @@ -430,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) @@ -438,7 +422,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 +437,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', { @@ -461,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/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/cli/testing/env.ts b/cli/testing/env.ts new file mode 100644 index 000000000..b7a148ae5 --- /dev/null +++ b/cli/testing/env.ts @@ -0,0 +1,47 @@ +/** + * Test-only CLI env fixtures. + */ + +import { createTestBaseEnv } from '@codebuff/common/testing/fixtures' + +import type { CliEnv } from '../src/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/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 485378a84..e3266c746 100644 --- a/common/package.json +++ b/common/package.json @@ -10,6 +10,18 @@ "import": "./src/*.ts", "types": "./src/*.ts", "default": "./src/*.ts" + }, + "./testing/fixtures": { + "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" } }, "scripts": { diff --git a/common/src/__tests__/env-ci.test.ts b/common/src/__tests__/env-ci.test.ts index 63e829e01..db5463f1c 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/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 2522b1df3..434f61c92 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/fixtures' 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-schema.ts b/common/src/env-schema.ts index 23eb38f9a..eabe9c90b 100644 --- a/common/src/env-schema.ts +++ b/common/src/env-schema.ts @@ -5,9 +5,15 @@ 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_SUPPORT_EMAIL: z + .email() + .min(1) + .default('support@codebuff.com'), NEXT_PUBLIC_POSTHOG_API_KEY: z.string().min(1), - NEXT_PUBLIC_POSTHOG_HOST_URL: z.url().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), NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION_ID: z.string().optional(), 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/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/impl/agent-runtime.ts b/common/src/testing/impl/agent-runtime.ts deleted file mode 100644 index 8f8179fcb..000000000 --- a/common/src/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/common/src/testing/fixtures/agent-runtime.ts b/common/testing/fixtures/agent-runtime.ts similarity index 55% rename from common/src/testing/fixtures/agent-runtime.ts rename to common/testing/fixtures/agent-runtime.ts index 3c5531cf5..e3801a53a 100644 --- a/common/src/testing/fixtures/agent-runtime.ts +++ b/common/testing/fixtures/agent-runtime.ts @@ -5,14 +5,19 @@ * Do not import from production code. */ -import type { AgentTemplate } from '../../types/agent-template' +import { spyOn } from 'bun:test' + +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: () => {}, @@ -61,7 +66,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', @@ -135,3 +140,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: any[]) => void + trackEvent: (...args: any[]) => void + flushAnalytics?: (...args: any[]) => Promise +} + +/** + * Type for the bigquery module to be mocked. + * Matches the shape of @codebuff/bigquery. + */ +type BigQueryModule = { + insertTrace: (...args: any[]) => 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/testing/fixtures/billing.ts b/common/testing/fixtures/billing.ts new file mode 100644 index 000000000..1e8e205be --- /dev/null +++ b/common/testing/fixtures/billing.ts @@ -0,0 +1,267 @@ +/** + * 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 '../../src/types/contracts/logger' + +// Re-export the test logger for convenience +export { testLogger } from './agent-runtime' + +// ============================================================================ +// Grant Credits Mock (packages/billing/src/grant-credits.ts) +// ============================================================================ + +export interface GrantCreditsMockOptions { + user: { + 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 +} + +/** + * Minimal data access interface for grant-credits module. + * Structurally matches GrantCreditsStore from packages/billing/src/grant-credits.ts + */ +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 +} + +/** + * Store interface for grant-credits. + */ +export interface GrantCreditsStore extends GrantCreditsTxStore { + withTransaction(callback: (tx: GrantCreditsTxStore) => Promise): Promise +} + +/** + * Creates a typed mock store for grant-credits tests. + * + * @example + * ```ts + * const mockStore = createGrantCreditsStoreMock({ + * user: { next_quota_reset: futureDate, auto_topup_enabled: true }, + * }) + * + * const result = await triggerMonthlyResetAndGrant({ + * userId: 'user-123', + * logger, + * store: mockStore, + * }) + * ``` + */ +export function createGrantCreditsStoreMock( + options: GrantCreditsMockOptions, +): GrantCreditsStore { + const { + user, + previousExpiredFreeGrantPrincipal = null, + totalReferralBonusCredits = 0, + } = options + + const txStore: GrantCreditsTxStore = { + getMonthlyResetUser: async () => user, + updateUserNextQuotaReset: async () => {}, + getMostRecentExpiredFreeGrantPrincipal: async () => + previousExpiredFreeGrantPrincipal, + getTotalReferralBonusCredits: async () => totalReferralBonusCredits, + listActiveCreditGrants: async () => [], + updateCreditLedgerBalance: async () => {}, + insertCreditLedgerEntry: async () => {}, + } + + return { + ...txStore, + withTransaction: async (callback) => callback(txStore), + } +} + +// ============================================================================ +// Org Billing Mock (packages/billing/src/org-billing.ts) +// ============================================================================ + +export interface OrgBillingGrant { + operation_id: string + user_id: string + org_id: string + principal: number + balance: number + type: 'organization' + description: string + priority: number + expires_at: Date + created_at: Date +} + +export interface OrgBillingMockOptions { + grants?: OrgBillingGrant[] + insertCreditLedgerEntry?: (values: Record) => Promise + updateCreditLedgerBalance?: (params: { + operationId: string + balance: number + }) => Promise +} + +/** + * Transaction store interface for org-billing. + * Structurally matches OrgBillingTxStore from packages/billing/src/org-billing.ts + */ +export interface OrgBillingTxStore { + listOrderedActiveOrganizationGrants(params: { + organizationId: string + now: Date + }): Promise + insertCreditLedgerEntry(values: Record): Promise + updateCreditLedgerBalance(params: { + operationId: string + balance: number + }): Promise +} + +/** + * Store interface for org-billing. + * Structurally matches OrgBillingStore from packages/billing/src/org-billing.ts + */ +export interface OrgBillingStore extends OrgBillingTxStore { + withTransaction(params: { + callback: (tx: OrgBillingTxStore) => Promise + context: Record + logger: Logger + }): Promise +} + +/** + * Creates a typed mock store for org-billing tests. + * + * @example + * ```ts + * const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) + * + * const result = await calculateOrganizationUsageAndBalance({ + * organizationId: 'org-123', + * quotaResetDate: new Date(), + * now: new Date(), + * logger, + * store: mockStore, + * }) + * ``` + */ +export function createOrgBillingStoreMock( + options?: OrgBillingMockOptions, +): OrgBillingStore { + const { grants = [], insertCreditLedgerEntry, updateCreditLedgerBalance } = + options ?? {} + + const txStore: OrgBillingTxStore = { + listOrderedActiveOrganizationGrants: async () => grants, + insertCreditLedgerEntry: + insertCreditLedgerEntry ?? + (async () => { + return + }), + updateCreditLedgerBalance: async (params) => { + await updateCreditLedgerBalance?.(params) + }, + } + + return { + ...txStore, + withTransaction: async ({ callback }) => callback(txStore), + } +} + +// ============================================================================ +// 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[] +} + +/** + * Minimal data access interface for credit-delegation module. + * Structurally matches CreditDelegationStore from packages/billing/src/credit-delegation.ts + */ +export interface CreditDelegationStore { + listUserOrganizations(params: { userId: string }): Promise + listActiveOrganizationRepos(params: { + organizationId: string + }): Promise +} + +/** + * Creates a typed mock data store for credit-delegation tests. + * + * @example + * ```ts + * const mockStore = createCreditDelegationStoreMock({ + * 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, + * store: mockStore, + * }) + * ``` + */ +export function createCreditDelegationStoreMock( + options?: CreditDelegationMockOptions, +): CreditDelegationStore { + const { userOrganizations = [], orgRepos = [] } = options ?? {} + + return { + listUserOrganizations: async () => userOrganizations, + listActiveOrganizationRepos: async () => orgRepos.filter((repo) => repo.isActive), + } +} diff --git a/common/testing/fixtures/database.ts b/common/testing/fixtures/database.ts new file mode 100644 index 000000000..cd0282785 --- /dev/null +++ b/common/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/testing/fixtures/env-ci.ts b/common/testing/fixtures/env-ci.ts new file mode 100644 index 000000000..6822183f5 --- /dev/null +++ b/common/testing/fixtures/env-ci.ts @@ -0,0 +1,18 @@ +/** + * Test-only CiEnv fixtures. + */ + +import type { CiEnv } from '../src/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/testing/fixtures/env-process.ts b/common/testing/fixtures/env-process.ts new file mode 100644 index 000000000..88deebae5 --- /dev/null +++ b/common/testing/fixtures/env-process.ts @@ -0,0 +1,86 @@ +/** + * Test-only ProcessEnv fixtures. + */ + +import type { BaseEnv, ProcessEnv } from '../src/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/common/testing/fixtures/fetch.ts b/common/testing/fixtures/fetch.ts new file mode 100644 index 000000000..c4b9bdb80 --- /dev/null +++ b/common/testing/fixtures/fetch.ts @@ -0,0 +1,223 @@ +/** + * 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 + +/** + * 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> + } +} + +/** + * 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', +} + +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. + * + * @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 = {}, +): MockFetchFn { + 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' + } + + return withPreconnect( + mock(async () => { + return new Response(responseBody, { + status, + statusText, + headers: responseHeaders, + }) + }), + ) +} + +/** + * 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): MockFetchFn { + return withPreconnect( + mock(async () => { + throw error + }), + ) +} + +/** + * 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, +): MockFetchFn { + return withPreconnect(mock(implementation)) +} + +/** + * 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({ + * status: 200, + * json: () => Promise.resolve({ id: 'test' }), + * }) + * ``` + */ +export function createMockFetchPartial( + 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 + }), + ) +} + +/** + * 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>, +): MockFetchFn { + return withPreconnect(mockFn) +} diff --git a/common/testing/fixtures/index.ts b/common/testing/fixtures/index.ts new file mode 100644 index 000000000..d2a35f517 --- /dev/null +++ b/common/testing/fixtures/index.ts @@ -0,0 +1,66 @@ +/** + * Test fixtures barrel file. + * + * Re-exports all test fixtures for cleaner imports: + * @example + * ```ts + * import { testLogger, createMockFetch, createGrantCreditsStoreMock } 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 { + createGrantCreditsStoreMock, + createOrgBillingStoreMock, + createCreditDelegationStoreMock, + type GrantCreditsMockOptions, + type GrantCreditsStore, + type GrantCreditsTxStore, + type OrgBillingGrant, + type OrgBillingMockOptions, + type OrgBillingStore, + type OrgBillingTxStore, + type UserOrganization, + type OrgRepo, + type CreditDelegationMockOptions, + type CreditDelegationStore, +} from './billing' + +// Database mock fixtures +export { + createVersionQueryDbMock, + createExistsQueryDbMock, + type VersionRow, +} from './database' + +// Fetch mock fixtures +export { + createMockFetch, + createMockFetchError, + createMockFetchCustom, + createMockFetchPartial, + wrapMockAsFetch, + type FetchFn, + type FetchCallFn, + 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/src/testing/mock-modules.ts b/common/testing/fixtures/mock-modules.ts similarity index 100% rename from common/src/testing/mock-modules.ts rename to common/testing/fixtures/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/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/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 199485686..5e2a31d80 100644 --- a/evals/scaffolding.ts +++ b/evals/scaffolding.ts @@ -5,8 +5,8 @@ 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 { generateCompactId } from '@codebuff/common/util/string' import { getSystemInfo } from '@codebuff/common/util/system-info' import { ToolHelpers } from '@codebuff/sdk' @@ -256,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/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/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 9dfd6ff70..37c90632a 100644 --- a/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts +++ b/packages/agent-runtime/src/__tests__/fast-rewrite.test.ts @@ -1,12 +1,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 { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' +import { afterAll, beforeEach, describe, expect, it } from 'bun:test' import { createPatch } from 'diff' import { rewriteWithOpenAI } from '../fast-rewrite' @@ -19,30 +14,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') @@ -57,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__/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 2015f8f06..e266dcbdb 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -1,13 +1,13 @@ 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' +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 db from '@codebuff/internal/db' +import * as bigquery from '@codebuff/bigquery' import { afterAll, afterEach, @@ -45,16 +45,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: () => {}, @@ -63,21 +63,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' } @@ -85,15 +70,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', @@ -136,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: () => {}, @@ -150,9 +126,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..3a25d05b2 100644 --- a/packages/agent-runtime/src/__tests__/main-prompt.test.ts +++ b/packages/agent-runtime/src/__tests__/main-prompt.test.ts @@ -1,7 +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 { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { AgentTemplateTypes, getInitialSessionState, @@ -93,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, @@ -101,12 +104,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( @@ -413,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 48239f728..20d12478f 100644 --- a/packages/agent-runtime/src/__tests__/n-parameter.test.ts +++ b/packages/agent-runtime/src/__tests__/n-parameter.test.ts @@ -1,6 +1,9 @@ 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 { + mockAnalytics, + mockRandomUUID, +} from '@codebuff/common/testing/fixtures' +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 { @@ -55,16 +58,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 = { @@ -108,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', @@ -246,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', @@ -286,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', @@ -351,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', @@ -434,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', @@ -530,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', @@ -595,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', @@ -634,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', @@ -681,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', @@ -725,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', @@ -788,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', @@ -836,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 9c85878cd..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,11 +1,6 @@ -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 { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' 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 +13,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 } }) @@ -84,7 +58,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -135,7 +109,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -169,7 +143,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -209,7 +183,7 @@ describe('processFileBlockModule', () => { clientSessionId: 'clientSessionId', fingerprintId: 'fingerprintId', userInputId: 'userInputId', - userId: TEST_USER_ID, + userId: 'user-123', }) expect(result).not.toBeNull() @@ -257,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 48e10960f..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,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' import { beforeEach, describe, expect, it, beforeAll } from 'bun:test' @@ -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 d404b3acb..84244b490 100644 --- a/packages/agent-runtime/src/__tests__/propose-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/propose-tools.test.ts @@ -1,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, @@ -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 1ed0d8b28..ba9943ba0 100644 --- a/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/read-docs-tool.test.ts @@ -1,7 +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 { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { afterEach, @@ -54,15 +57,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 @@ -78,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 d7f51fdc3..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 @@ -1,10 +1,12 @@ import * as bigquery from '@codebuff/bigquery' 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 { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures' +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 db from '@codebuff/internal/db' import { afterAll, afterEach, @@ -64,24 +66,10 @@ 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 - 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 = {} @@ -129,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 3d99ccd00..e3c7af80b 100644 --- a/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts +++ b/packages/agent-runtime/src/__tests__/run-programmatic-step.test.ts @@ -1,6 +1,9 @@ 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 { + mockAnalytics, + mockRandomUUID, +} from '@codebuff/common/testing/fixtures' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, @@ -17,6 +20,7 @@ import { spyOn, } from 'bun:test' import { cloneDeep } from 'lodash' +import { z } from 'zod/v4' import { clearAgentGeneratorCache, @@ -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', @@ -124,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', @@ -858,15 +856,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'], } @@ -891,7 +885,7 @@ describe('runProgrammaticStep', () => { ...mockParams, template: schemaTemplate, localAgentTemplates: { 'test-agent': schemaTemplate }, - } as any) + }) expect(result.endTurn).toBe(true) expect(result.agentState.output).toEqual({ @@ -906,14 +900,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'], } @@ -941,7 +931,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) @@ -1415,8 +1405,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: [], } } @@ -1448,9 +1453,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 @@ -1466,9 +1472,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 @@ -1483,10 +1490,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 @@ -1501,10 +1509,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 @@ -1609,9 +1618,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 @@ -1624,9 +1634,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 @@ -1639,9 +1650,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 @@ -1654,9 +1666,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 @@ -1669,9 +1682,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/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 0159390f9..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,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, @@ -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 41c98ea92..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,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage, @@ -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 3fe3107a8..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,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' import { @@ -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 d65c9f10a..77f6128de 100644 --- a/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts +++ b/packages/agent-runtime/src/__tests__/subagent-streaming.test.ts @@ -1,5 +1,4 @@ -import { TEST_USER_ID } 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 { getInitialSessionState } from '@codebuff/common/types/session-state' import { assistantMessage } from '@codebuff/common/util/messages' import { @@ -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__/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 34becae01..d7a2b1a9d 100644 --- a/packages/agent-runtime/src/__tests__/web-search-tool.test.ts +++ b/packages/agent-runtime/src/__tests__/web-search-tool.test.ts @@ -1,7 +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 { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { + mockAnalytics, + mockBigQuery, +} from '@codebuff/common/testing/fixtures' +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 { @@ -73,17 +76,14 @@ 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', } // 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/__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 b5c933d96..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,12 +1,11 @@ -import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' +import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/fixtures' 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/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/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/src/__tests__/credit-delegation.test.ts b/packages/billing/src/__tests__/credit-delegation.test.ts index 7517c0ec6..ec8a43eb2 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' + createCreditDelegationStoreMock, + 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 mockStore = createCreditDelegationStoreMock({ + 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, + store: mockStore, }) expect(result.found).toBe(true) @@ -101,6 +52,23 @@ describe('Credit Delegation', () => { }) it('should return not found for non-matching repository', async () => { + const mockStore = createCreditDelegationStoreMock({ + 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, + store: mockStore, + }) + + expect(result.found).toBe(false) + }) + + it('should return not found when user has no organizations', async () => { + const mockStore = createCreditDelegationStoreMock({ + userOrganizations: [], + orgRepos: [], + }) + + const userId = 'user-123' + const repositoryUrl = 'https://github.com/some/repo' + + const result = await findOrganizationForRepository({ + userId, + repositoryUrl, + logger, + store: mockStore, }) expect(result.found).toBe(false) @@ -116,6 +104,8 @@ describe('Credit Delegation', () => { describe('consumeCreditsWithDelegation', () => { it('should fail when no repository URL provided', async () => { + const mockStore = createCreditDelegationStoreMock() + const userId = 'user-123' const repositoryUrl = null const creditsToConsume = 100 @@ -125,10 +115,33 @@ describe('Credit Delegation', () => { repositoryUrl, creditsToConsume, logger, + store: mockStore, }) expect(result.success).toBe(false) expect(result.error).toBe('No repository URL provided') }) + + it('should fail when no organization found for repository', async () => { + const mockStore = createCreditDelegationStoreMock({ + userOrganizations: [], + orgRepos: [], + }) + + const userId = 'user-123' + const repositoryUrl = 'https://github.com/other/repo' + const creditsToConsume = 100 + + const result = await consumeCreditsWithDelegation({ + userId, + repositoryUrl, + creditsToConsume, + logger, + store: mockStore, + }) + + 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..53c9eca5c 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' + createGrantCreditsStoreMock, + 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 mockStore = createGrantCreditsStoreMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: true, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + store: mockStore, }) 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 mockStore = createGrantCreditsStoreMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: false, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + store: mockStore, }) 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 mockStore = createGrantCreditsStoreMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: null, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + store: mockStore, }) 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 mockStore = createGrantCreditsStoreMock({ + user: null, + }) await expect( - fn({ + triggerMonthlyResetAndGrant({ userId: 'nonexistent-user', logger, + store: mockStore, }), ).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 mockStore = createGrantCreditsStoreMock({ + user: { + next_quota_reset: futureDate, + auto_topup_enabled: false, + }, + }) - const result = await fn({ + const result = await triggerMonthlyResetAndGrant({ userId: 'user-123', logger, + 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 990fd676e..9c2ef6add 100644 --- a/packages/billing/src/__tests__/org-billing.test.ts +++ b/packages/billing/src/__tests__/org-billing.test.ts @@ -1,8 +1,10 @@ +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' + createOrgBillingStoreMock, + testLogger, + type OrgBillingGrant, +} from '@codebuff/common/testing/fixtures' import { calculateOrganizationUsageAndBalance, @@ -12,17 +14,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', + org_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'), @@ -31,10 +31,10 @@ const mockGrants = [ { operation_id: 'org-grant-2', user_id: '', - organization_id: 'org-123', + org_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 +42,26 @@ const mockGrants = [ }, ] -const logger: Logger = { - debug: () => {}, - error: () => {}, - info: () => {}, - warn: () => {}, -} +const logger = testLogger -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(), - }), - })), +class PgUniqueViolationError extends Error { + code = '23505' + constraint: string + + constructor(message: string, constraint: string) { + super(message) + this.constraint = constraint } } 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 mockStore = createOrgBillingStoreMock({ grants: mockGrants }) const organizationId = 'org-123' const quotaResetDate = new Date('2024-01-01') const now = new Date('2024-06-01') @@ -108,6 +71,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, + store: mockStore, }) // Total positive balance: 800 @@ -123,9 +87,7 @@ describe('Organization Billing', () => { it('should handle organization with no grants', async () => { // Mock empty grants - await mockModule('@codebuff/internal/db', () => ({ - default: createDbMock({ grants: [] }), - })) + const mockStore = createOrgBillingStoreMock({ grants: [] }) const organizationId = 'org-empty' const quotaResetDate = new Date('2024-01-01') @@ -136,6 +98,7 @@ describe('Organization Billing', () => { quotaResetDate, now, logger, + store: mockStore, }) expect(result.balance.totalRemaining).toBe(0) @@ -214,6 +177,8 @@ describe('Organization Billing', () => { describe('consumeOrganizationCredits', () => { it('should consume credits from organization grants', async () => { + const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) + const organizationId = 'org-123' const creditsToConsume = 100 @@ -221,6 +186,7 @@ describe('Organization Billing', () => { organizationId, creditsToConsume, logger, + store: mockStore, }) expect(result.consumed).toBe(100) @@ -230,6 +196,8 @@ describe('Organization Billing', () => { describe('grantOrganizationCredits', () => { it('should create organization credit grant', async () => { + const mockStore = createOrgBillingStoreMock({ grants: mockGrants }) + const organizationId = 'org-123' const userId = 'user-123' const amount = 1000 @@ -245,24 +213,19 @@ describe('Organization Billing', () => { operationId, description, logger, + store: mockStore, }), ).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 mockStore = createOrgBillingStoreMock({ + grants: mockGrants, + insertCreditLedgerEntry: async () => { + throw new PgUniqueViolationError('Duplicate key', 'credit_ledger_pkey') + }, + }) const organizationId = 'org-123' const userId = 'user-123' @@ -279,6 +242,7 @@ describe('Organization Billing', () => { operationId, description, logger, + store: mockStore, }), ).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/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index 23007f42f..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' @@ -102,21 +101,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 +135,7 @@ export async function consumeFromOrderedGrants( consumed += repayAmount await updateGrantBalance({ - ...params, + userId, grant, consumed: -repayAmount, newBalance, @@ -161,7 +164,7 @@ export async function consumeFromOrderedGrants( } await updateGrantBalance({ - ...params, + userId, grant, consumed: consumeFromThisGrant, newBalance, @@ -175,7 +178,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 +201,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. @@ -467,9 +495,6 @@ export async function consumeCreditsAndAddAgentStep(params: { tx, }) - if (userId === TEST_USER_ID) { - return { ...result, agentStepId: 'test-step-id' } - } } try { diff --git a/packages/billing/src/credit-delegation.ts b/packages/billing/src/credit-delegation.ts index 5f1fa32b0..ed2646af4 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,60 @@ import { extractOwnerAndRepo, } from './org-billing' +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' @@ -33,12 +88,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 + store: CreditDelegationStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, ...rest } = params + const { userId, repositoryUrl, logger } = rest try { const normalizedUrl = normalizeRepositoryUrl(repositoryUrl) @@ -53,15 +115,7 @@ export async function findOrganizationForRepository(params: { } // First, check if user is a member of any organizations - const userOrganizations = await db - .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( @@ -73,19 +127,9 @@ export async function findOrganizationForRepository(params: { // Check each organization for matching repositories for (const userOrg of userOrganizations) { - const orgRepos = await db - .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) { @@ -142,13 +186,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 + store: CreditDelegationStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, ...rest } = params + const { userId, repositoryUrl, creditsToConsume, logger } = rest // If no repository URL, fall back to personal credits if (!repositoryUrl) { @@ -159,11 +210,14 @@ export async function consumeCreditsWithDelegation(params: { return { success: false, error: 'No repository URL provided' } } - const withRepoUrl = { ...params, repositoryUrl } - 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( @@ -176,7 +230,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..18b58e75e 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' @@ -15,11 +16,123 @@ 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 + +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))), +} /** * Finds the amount of the most recent expired 'free' grant for a user. @@ -30,36 +143,27 @@ 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 + store: GrantCreditsTxStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, userId, logger } = params const now = new Date() - const lastExpiredFreeGrant = await db - .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) { - // TODO: remove this once it's past May 22nd, after all users have been migrated over - const cappedAmount = Math.min(lastExpiredFreeGrant[0].principal, 2000) + if (amount !== null) { 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 }, @@ -75,26 +179,20 @@ 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 + store: GrantCreditsTxStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, userId, logger } = params try { - const result = await db - .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) { @@ -116,7 +214,8 @@ export async function grantCreditOperation(params: { description: string expiresAt: Date | null operationId: string - tx?: DbTransaction + tx?: GrantCreditsTxClient + store?: GrantCreditsTxStore logger: Logger }) { const { @@ -127,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() @@ -144,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( @@ -164,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, @@ -200,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, @@ -349,28 +440,28 @@ 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 + store: GrantCreditsStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, userId, logger } = params - return await db.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`) @@ -389,8 +480,8 @@ export async function triggerMonthlyResetAndGrant(params: { // Calculate grant amounts separately const [freeGrantAmount, referralBonus] = await Promise.all([ - getPreviousFreeGrantAmount(params), - calculateTotalReferralBonus(params), + getPreviousFreeGrantAmount({ userId, logger, store: txStore }), + calculateTotalReferralBonus({ userId, logger, store: txStore }), ]) // Generate a deterministic operation ID based on userId and reset date to minute precision @@ -399,32 +490,34 @@ export async function triggerMonthlyResetAndGrant(params: { 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({ - ...params, 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({ - ...params, 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 15ed98045..703790600 100644 --- a/packages/billing/src/org-billing.ts +++ b/packages/billing/src/org-billing.ts @@ -7,7 +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' +import { consumeFromOrderedGrantsWithUpdater } from './balance-calculator' import type { CreditBalance, @@ -18,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' -// Add a minimal structural type that both `db` and `tx` satisfy -type DbConn = Pick +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. @@ -132,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 }) } /** @@ -168,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) @@ -267,20 +314,26 @@ 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 + store: OrgBillingStore + }, + 'store' + >, +): Promise { + const { store = DEFAULT_STORE, organizationId, creditsToConsume, logger } = + params - return await withSerializableTransaction({ - callback: async (tx) => { + return await store.withTransaction({ + callback: async (txStore) => { const now = new Date() - const activeGrants = await getOrderedActiveOrganizationGrants({ - ...params, + const activeGrants = await txStore.listOrderedActiveOrganizationGrants({ + organizationId, now, - conn: tx, }) if (activeGrants.length === 0) { @@ -291,12 +344,16 @@ export async function consumeOrganizationCredits(params: { 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 @@ -319,13 +376,15 @@ export async function grantOrganizationCredits( description: string expiresAt: Date | null logger: Logger + store: OrgBillingTxStore }, - 'description' | 'expiresAt' + 'description' | 'expiresAt' | 'store' >, ): Promise { const withDefaults = { description: 'Organization credit purchase', expiresAt: null, + store: DEFAULT_STORE, ...params, } const { @@ -336,12 +395,13 @@ export async function grantOrganizationCredits( description, expiresAt, logger, + store, } = withDefaults const now = new Date() try { - await db.insert(schema.creditLedger).values({ + await store.insertCreditLedgerEntry({ operation_id: operationId, user_id: userId, org_id: organizationId, diff --git a/packages/billing/src/stripe-metering.ts b/packages/billing/src/stripe-metering.ts index efe6e4155..6f45450d0 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' @@ -48,7 +47,6 @@ export async function reportPurchasedCreditsToStripe(params: { } = params if (purchasedCredits <= 0) return - if (userId === TEST_USER_ID) return if (!shouldAttemptStripeMetering()) return const logContext = { userId, purchasedCredits, eventId } 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/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/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', }) }) diff --git a/packages/internal/src/utils/__tests__/version-utils.test.ts b/packages/internal/src/utils/__tests__/version-utils.test.ts index 1a654333e..e26230ce1 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' -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,9 @@ 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 +151,9 @@ 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 +166,9 @@ 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 +179,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 +191,9 @@ 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 +208,9 @@ 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 +225,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 +242,9 @@ 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 +256,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/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/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/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/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/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__/client.test.ts b/sdk/src/__tests__/client.test.ts index 333f5c75e..20c68967a 100644 --- a/sdk/src/__tests__/client.test.ts +++ b/sdk/src/__tests__/client.test.ts @@ -1,11 +1,13 @@ +import { wrapMockAsFetch, type FetchCallFn } 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 + const setFetchMock = (mockFetch: ReturnType>) => { + globalThis.fetch = wrapMockAsFetch(mockFetch) } afterEach(() => { @@ -14,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' }), @@ -31,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' }), @@ -48,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' }), @@ -64,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')), @@ -80,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) @@ -91,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'), @@ -107,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), @@ -123,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 b368ae41e..124787dd1 100644 --- a/sdk/src/__tests__/code-search.test.ts +++ b/sdk/src/__tests__/code-search.test.ts @@ -1,24 +1,52 @@ import { EventEmitter } from 'events' +import { PassThrough } from 'stream' -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' +import type { Readable } from 'stream' + +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 + } +} -// Helper to create a mock child process function createMockChildProcess() { - const mockProcess = new EventEmitter() as ChildProcess & { - stdout: EventEmitter - stderr: EventEmitter + 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: Parameters[0] + args: string[] + options: Parameters[2] + }> = [] + + 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 +82,38 @@ 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 + + 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') + } + } - 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,12 +125,13 @@ 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') - 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:') }) @@ -97,7 +139,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,18 +155,19 @@ 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') - 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"') 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 +175,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,11 +191,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should contain match lines expect(value.stdout).toContain('export const main = () => {}') @@ -165,7 +209,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,11 +222,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should contain match line expect(value.stdout).toContain('TODO: implement this') @@ -193,7 +238,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,11 +251,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should contain all matches expect(value.stdout).toContain('import foo from "foo"') @@ -221,7 +267,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,18 +276,19 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should still work with match at file start expect(value.stdout).toContain('import foo from "foo"') }) 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,11 +299,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should not contain '--' separator expect(value.stdout).not.toContain('--') @@ -265,7 +313,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,11 +324,12 @@ 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 + 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:') @@ -290,7 +339,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,11 +351,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should parse correctly despite multiple hyphens in filename expect(value.stdout).toContain('my-complex_file-name.ts:') @@ -314,7 +364,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,11 +376,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Output should be reasonably sized, not including entire file expect(value.stdout.length).toBeLessThan(2000) @@ -343,7 +394,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,11 +412,12 @@ 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 + 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 @@ -383,7 +435,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,11 +453,12 @@ 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 + 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 @@ -418,7 +471,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,11 +486,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should include the match expect(value.stdout).toContain('match line') @@ -452,7 +506,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,11 +517,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should still process valid lines expect(value.stdout).toContain('valid line') @@ -475,16 +530,17 @@ 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 + const value = result[0].value + assertHasStdout(value) // formatCodeSearchOutput returns 'No results' for empty input expect(value.stdout).toBe('No results') @@ -495,18 +551,19 @@ 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 + const value = result[0].value + assertHasStdout(value) expect(value.stdout).toContain('file.ts:') expect(value.stdout).toContain('-foo') @@ -515,7 +572,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,11 +587,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should not have double newlines or blank lines expect(value.stdout).not.toContain('\n\n\n') @@ -544,7 +602,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,11 +615,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // All three matches should be processed expect(value.stdout).toContain('file1.ts:') @@ -572,7 +631,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,16 +640,19 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should have stopped early and included size limit message expect(value.stdout).toContain('Output size limit reached') @@ -600,7 +662,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,11 +677,12 @@ 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 + const value = result[0].value + assertHasStdout(value) // Should handle path.bytes expect(value.stdout).toContain('file-with-bytes.ts:') @@ -629,7 +692,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 +703,24 @@ 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 + const value = result[0].value + assertHasStdout(value) 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 +728,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) 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 - 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 +757,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,10 +789,11 @@ 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 + const value = result[0].value + assertHasErrorMessage(value) expect(value.errorMessage).toContain('timed out') }) @@ -733,7 +801,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,26 +809,24 @@ 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 - - // 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') // 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,30 +834,30 @@ 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 - - expect(value.errorMessage).toBeUndefined() + const value = result[0].value + assertHasStdout(value) 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', }) 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/__tests__/env.test.ts b/sdk/src/__tests__/env.test.ts index dd99d6952..f8b51a2c2 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/tools/code-search.ts b/sdk/src/tools/code-search.ts index e246ab83f..e1a1af767 100644 --- a/sdk/src/tools/code-search.ts +++ b/sdk/src/tools/code-search.ts @@ -1,11 +1,17 @@ -import { spawn } from 'child_process' +import { spawn as nodeSpawn } from 'child_process' import * as fs from 'fs' import * as path from 'path' import { formatCodeSearchOutput } from '../../../common/src/util/format-code-search' import { getBundledRgPath } from '../native/ripgrep' +import type { + 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,25 +24,45 @@ 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 = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithStdioTuple, +) => 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, + { + 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 @@ -104,7 +130,9 @@ export function codeSearch({ 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 @@ -383,3 +411,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) +} diff --git a/sdk/testing/env.ts b/sdk/testing/env.ts new file mode 100644 index 000000000..d4a5c6c44 --- /dev/null +++ b/sdk/testing/env.ts @@ -0,0 +1,26 @@ +/** + * Test-only SDK env fixtures. + */ + +import { createTestBaseEnv } from '@codebuff/common/testing/fixtures' + +import type { SdkEnv } from '../src/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, +}) 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/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) 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/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"] }