From 032fdcc1f342958fbc4e000909cf0d082032c3c7 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 19:11:00 +0000 Subject: [PATCH] test: add unit tests for DB repositories, Sentry module, and Sentry router adapter --- .../debugAnalysisRepository.test.ts | 172 ++++++ .../unit/db/repositories/joinHelpers.test.ts | 65 +++ .../repositories/llmCallsRepository.test.ts | 252 +++++++++ .../db/repositories/runLogsRepository.test.ts | 116 +++++ .../repositories/runStatsRepository.test.ts | 488 ++++++++++++++++++ .../runsRepository-concurrency.test.ts | 451 ++++++++++++++++ .../repositories/runsRepository-core.test.ts | 360 +++++++++++++ .../webhookLogsRepository.test.ts | 330 ++++++++++++ tests/unit/router/adapters/sentry.test.ts | 390 ++++++++++++++ tests/unit/sentry/client.test.ts | 265 ++++++++++ tests/unit/sentry/integration.test.ts | 171 ++++++ vitest.config.ts | 1 + 12 files changed, 3061 insertions(+) create mode 100644 tests/unit/db/repositories/debugAnalysisRepository.test.ts create mode 100644 tests/unit/db/repositories/joinHelpers.test.ts create mode 100644 tests/unit/db/repositories/llmCallsRepository.test.ts create mode 100644 tests/unit/db/repositories/runLogsRepository.test.ts create mode 100644 tests/unit/db/repositories/runStatsRepository.test.ts create mode 100644 tests/unit/db/repositories/runsRepository-concurrency.test.ts create mode 100644 tests/unit/db/repositories/runsRepository-core.test.ts create mode 100644 tests/unit/db/repositories/webhookLogsRepository.test.ts create mode 100644 tests/unit/router/adapters/sentry.test.ts create mode 100644 tests/unit/sentry/client.test.ts create mode 100644 tests/unit/sentry/integration.test.ts diff --git a/tests/unit/db/repositories/debugAnalysisRepository.test.ts b/tests/unit/db/repositories/debugAnalysisRepository.test.ts new file mode 100644 index 00000000..8425759d --- /dev/null +++ b/tests/unit/db/repositories/debugAnalysisRepository.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + debugAnalyses: { + id: 'id', + analyzedRunId: 'analyzed_run_id', + debugRunId: 'debug_run_id', + summary: 'summary', + issues: 'issues', + timeline: 'timeline', + recommendations: 'recommendations', + rootCause: 'root_cause', + severity: 'severity', + }, +})); + +import { + deleteDebugAnalysisByRunId, + getDebugAnalysisByDebugRunId, + getDebugAnalysisByRunId, + storeDebugAnalysis, +} from '../../../../src/db/repositories/debugAnalysisRepository.js'; + +describe('debugAnalysisRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb(); + }); + + describe('storeDebugAnalysis', () => { + it('inserts analysis and returns the new id', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'debug-uuid-1' }]); + + const result = await storeDebugAnalysis({ + analyzedRunId: 'run-1', + debugRunId: 'debug-run-1', + summary: 'The agent failed due to missing config', + issues: 'Issue 1, Issue 2', + timeline: 'Step 1, Step 2', + rootCause: 'Missing config', + recommendations: 'Add config', + severity: 'failure', + }); + + expect(result).toBe('debug-uuid-1'); + expect(mockDb.db.insert).toHaveBeenCalled(); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + analyzedRunId: 'run-1', + debugRunId: 'debug-run-1', + summary: 'The agent failed due to missing config', + }), + ); + }); + + it('stores optional fields when provided', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'debug-uuid-2' }]); + + await storeDebugAnalysis({ + analyzedRunId: 'run-2', + summary: 'Minimal analysis', + issues: 'One issue', + timeline: 'Timeline text', + recommendations: 'Fix it', + severity: 'warning', + }); + + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + timeline: 'Timeline text', + recommendations: 'Fix it', + severity: 'warning', + }), + ); + }); + + it('stores with only required fields (optional fields undefined)', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'debug-uuid-3' }]); + + await storeDebugAnalysis({ + analyzedRunId: 'run-3', + summary: 'Summary only', + issues: 'Issues only', + }); + + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + analyzedRunId: 'run-3', + summary: 'Summary only', + issues: 'Issues only', + debugRunId: undefined, + timeline: undefined, + recommendations: undefined, + rootCause: undefined, + severity: undefined, + }), + ); + }); + }); + + describe('getDebugAnalysisByRunId', () => { + it('returns analysis when found', async () => { + const mockAnalysis = { + id: 'da-1', + analyzedRunId: 'run-1', + summary: 'Analysis result', + issues: 'Found 3 issues', + }; + mockDb.chain.where.mockResolvedValueOnce([mockAnalysis]); + + const result = await getDebugAnalysisByRunId('run-1'); + + expect(result).toEqual(mockAnalysis); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getDebugAnalysisByRunId('nonexistent-run'); + + expect(result).toBeNull(); + }); + }); + + describe('deleteDebugAnalysisByRunId', () => { + it('deletes analysis by analyzedRunId', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await deleteDebugAnalysisByRunId('run-1'); + + expect(mockDb.db.delete).toHaveBeenCalled(); + expect(mockDb.chain.where).toHaveBeenCalled(); + }); + + it('does not throw when no analysis exists', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await expect(deleteDebugAnalysisByRunId('nonexistent')).resolves.toBeUndefined(); + }); + }); + + describe('getDebugAnalysisByDebugRunId', () => { + it('returns analysis by debug run id', async () => { + const mockAnalysis = { + id: 'da-2', + analyzedRunId: 'run-1', + debugRunId: 'debug-run-1', + summary: 'Debug analysis', + issues: 'Various issues', + }; + mockDb.chain.where.mockResolvedValueOnce([mockAnalysis]); + + const result = await getDebugAnalysisByDebugRunId('debug-run-1'); + + expect(result).toEqual(mockAnalysis); + }); + + it('returns null when debug run id not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getDebugAnalysisByDebugRunId('nonexistent-debug'); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/db/repositories/joinHelpers.test.ts b/tests/unit/db/repositories/joinHelpers.test.ts new file mode 100644 index 00000000..83da79bb --- /dev/null +++ b/tests/unit/db/repositories/joinHelpers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRuns: { + id: 'id', + projectId: 'project_id', + workItemId: 'work_item_id', + prNumber: 'pr_number', + }, + prWorkItems: { + id: 'id', + projectId: 'project_id', + workItemId: 'work_item_id', + prNumber: 'pr_number', + }, +})); + +// Mock drizzle-orm operators to return testable values +vi.mock('drizzle-orm', () => ({ + and: (...args: unknown[]) => ({ type: 'and', conditions: args }), + eq: (a: unknown, b: unknown) => ({ type: 'eq', left: a, right: b }), + or: (...args: unknown[]) => ({ type: 'or', conditions: args }), + sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({ + type: 'sql', + strings, + values, + }), +})); + +import { buildAgentRunWorkItemJoin } from '../../../../src/db/repositories/joinHelpers.js'; + +describe('joinHelpers', () => { + describe('buildAgentRunWorkItemJoin', () => { + it('returns a defined value (not undefined/null)', () => { + const result = buildAgentRunWorkItemJoin(); + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + }); + + it('returns an OR condition', () => { + const result = buildAgentRunWorkItemJoin() as { type: string; conditions: unknown[] }; + expect(result.type).toBe('or'); + }); + + it('returns two branches in the OR condition', () => { + const result = buildAgentRunWorkItemJoin() as { type: string; conditions: unknown[] }; + expect(result.conditions).toHaveLength(2); + }); + + it('first branch is an AND condition (projectId + prNumber match)', () => { + const result = buildAgentRunWorkItemJoin() as { type: string; conditions: unknown[] }; + const branch1 = result.conditions[0] as { type: string; conditions: unknown[] }; + expect(branch1.type).toBe('and'); + expect(branch1.conditions).toHaveLength(2); + }); + + it('second branch is an AND condition (projectId + workItemId match with isNull guard)', () => { + const result = buildAgentRunWorkItemJoin() as { type: string; conditions: unknown[] }; + const branch2 = result.conditions[1] as { type: string; conditions: unknown[] }; + expect(branch2.type).toBe('and'); + // 3 conditions: projectId match, workItemId = workItemId, prNumber IS NULL + expect(branch2.conditions).toHaveLength(3); + }); + }); +}); diff --git a/tests/unit/db/repositories/llmCallsRepository.test.ts b/tests/unit/db/repositories/llmCallsRepository.test.ts new file mode 100644 index 00000000..d2dca3b9 --- /dev/null +++ b/tests/unit/db/repositories/llmCallsRepository.test.ts @@ -0,0 +1,252 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRunLlmCalls: { + id: 'id', + runId: 'run_id', + callNumber: 'call_number', + request: 'request', + response: 'response', + inputTokens: 'input_tokens', + outputTokens: 'output_tokens', + cachedTokens: 'cached_tokens', + costUsd: 'cost_usd', + durationMs: 'duration_ms', + model: 'model', + createdAt: 'created_at', + }, +})); + +import { + getLlmCallByNumber, + getLlmCallsByRunId, + listLlmCallsMeta, + storeLlmCall, + storeLlmCallsBulk, +} from '../../../../src/db/repositories/llmCallsRepository.js'; + +describe('llmCallsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb({ withThenable: true }); + }); + + describe('storeLlmCall', () => { + it('inserts a single call with all fields', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCall({ + runId: 'run-1', + callNumber: 1, + request: 'What is 2+2?', + response: '4', + inputTokens: 100, + outputTokens: 50, + cachedTokens: 10, + costUsd: 0.001, + durationMs: 500, + model: 'claude-3-5-sonnet', + }); + + expect(mockDb.db.insert).toHaveBeenCalled(); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + callNumber: 1, + request: 'What is 2+2?', + response: '4', + inputTokens: 100, + outputTokens: 50, + cachedTokens: 10, + costUsd: '0.001', + durationMs: 500, + model: 'claude-3-5-sonnet', + }); + }); + + it('converts costUsd number to string', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCall({ + runId: 'run-1', + callNumber: 1, + costUsd: 0.123456, + }); + + const valuesArg = mockDb.chain.values.mock.calls[0][0]; + expect(valuesArg.costUsd).toBe('0.123456'); + }); + + it('passes undefined costUsd when not provided', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCall({ runId: 'run-1', callNumber: 2 }); + + const valuesArg = mockDb.chain.values.mock.calls[0][0]; + expect(valuesArg.costUsd).toBeUndefined(); + }); + + it('inserts with only required fields', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCall({ runId: 'run-1', callNumber: 2 }); + + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + callNumber: 2, + request: undefined, + response: undefined, + inputTokens: undefined, + outputTokens: undefined, + cachedTokens: undefined, + costUsd: undefined, + durationMs: undefined, + model: undefined, + }); + }); + }); + + describe('storeLlmCallsBulk', () => { + it('inserts multiple calls at once', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCallsBulk([ + { + runId: 'run-1', + callNumber: 1, + costUsd: 0.001, + inputTokens: 100, + outputTokens: 50, + }, + { + runId: 'run-1', + callNumber: 2, + costUsd: 0.002, + inputTokens: 200, + outputTokens: 100, + }, + ]); + + expect(mockDb.db.insert).toHaveBeenCalled(); + expect(mockDb.chain.values).toHaveBeenCalledWith([ + expect.objectContaining({ runId: 'run-1', callNumber: 1, costUsd: '0.001' }), + expect.objectContaining({ runId: 'run-1', callNumber: 2, costUsd: '0.002' }), + ]); + }); + + it('skips insert when calls array is empty', async () => { + await storeLlmCallsBulk([]); + + expect(mockDb.db.insert).not.toHaveBeenCalled(); + }); + + it('converts costUsd to string for each call', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeLlmCallsBulk([ + { runId: 'run-1', callNumber: 1, costUsd: 0.5 }, + { runId: 'run-1', callNumber: 2, costUsd: 0.25 }, + ]); + + const valuesArg = mockDb.chain.values.mock.calls[0][0] as Array<{ costUsd: string }>; + expect(valuesArg[0].costUsd).toBe('0.5'); + expect(valuesArg[1].costUsd).toBe('0.25'); + }); + }); + + describe('getLlmCallsByRunId', () => { + it('returns calls ordered by callNumber', async () => { + const mockCalls = [ + { id: '1', runId: 'run-1', callNumber: 1 }, + { id: '2', runId: 'run-1', callNumber: 2 }, + ]; + // getLlmCallsByRunId uses .orderBy() as terminal + const mockOrderBy = vi.fn().mockResolvedValueOnce(mockCalls); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await getLlmCallsByRunId('run-1'); + + expect(result).toEqual(mockCalls); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + + it('returns empty array when no calls exist', async () => { + const mockOrderBy = vi.fn().mockResolvedValueOnce([]); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await getLlmCallsByRunId('run-no-calls'); + + expect(result).toEqual([]); + }); + }); + + describe('getLlmCallByNumber', () => { + it('returns the call when found', async () => { + const mockCall = { id: '1', runId: 'run-1', callNumber: 3, request: 'q', response: 'a' }; + mockDb.chain.where.mockResolvedValueOnce([mockCall]); + + const result = await getLlmCallByNumber('run-1', 3); + + expect(result).toEqual(mockCall); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getLlmCallByNumber('run-1', 999); + + expect(result).toBeNull(); + }); + }); + + describe('listLlmCallsMeta', () => { + it('returns metadata without request/response bodies', async () => { + const mockMeta = [ + { + id: 'call-1', + runId: 'run-1', + callNumber: 1, + inputTokens: 100, + outputTokens: 50, + cachedTokens: 0, + costUsd: '0.001', + durationMs: 300, + model: 'claude-3', + createdAt: new Date(), + }, + { + id: 'call-2', + runId: 'run-1', + callNumber: 2, + inputTokens: 200, + outputTokens: 80, + cachedTokens: 10, + costUsd: '0.002', + durationMs: 500, + model: 'claude-3', + createdAt: new Date(), + }, + ]; + const mockOrderBy = vi.fn().mockResolvedValueOnce(mockMeta); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await listLlmCallsMeta('run-1'); + + expect(result).toEqual(mockMeta); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + + it('returns empty array when no calls exist', async () => { + const mockOrderBy = vi.fn().mockResolvedValueOnce([]); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await listLlmCallsMeta('run-no-calls'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/db/repositories/runLogsRepository.test.ts b/tests/unit/db/repositories/runLogsRepository.test.ts new file mode 100644 index 00000000..a8b4b7f0 --- /dev/null +++ b/tests/unit/db/repositories/runLogsRepository.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRunLogs: { + id: 'id', + runId: 'run_id', + cascadeLog: 'cascade_log', + engineLog: 'engine_log', + createdAt: 'created_at', + }, +})); + +import { getRunLogs, storeRunLogs } from '../../../../src/db/repositories/runLogsRepository.js'; + +describe('runLogsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb(); + }); + + describe('storeRunLogs', () => { + it('inserts run logs with cascade and engine log', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeRunLogs('run-1', 'cascade log text', 'engine log text'); + + expect(mockDb.db.insert).toHaveBeenCalled(); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + cascadeLog: 'cascade log text', + engineLog: 'engine log text', + }); + }); + + it('stores null when cascadeLog is undefined (null coalescing)', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeRunLogs('run-1'); + + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + cascadeLog: null, + engineLog: null, + }); + }); + + it('stores null for undefined engineLog only', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeRunLogs('run-1', 'cascade log'); + + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + cascadeLog: 'cascade log', + engineLog: null, + }); + }); + + it('stores null for undefined cascadeLog only', async () => { + mockDb.chain.values.mockResolvedValueOnce(undefined); + + await storeRunLogs('run-1', undefined, 'engine log'); + + expect(mockDb.chain.values).toHaveBeenCalledWith({ + runId: 'run-1', + cascadeLog: null, + engineLog: 'engine log', + }); + }); + }); + + describe('getRunLogs', () => { + it('returns run logs when found', async () => { + const mockLogs = { + id: 'log-1', + runId: 'run-1', + cascadeLog: 'log content', + engineLog: 'engine content', + }; + mockDb.chain.where.mockResolvedValueOnce([mockLogs]); + + const result = await getRunLogs('run-1'); + + expect(result).toEqual(mockLogs); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + + it('returns null when no logs found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getRunLogs('nonexistent-run'); + + expect(result).toBeNull(); + }); + + it('returns logs with null cascadeLog (optional field)', async () => { + const mockLogs = { + id: 'log-2', + runId: 'run-2', + cascadeLog: null, + engineLog: 'engine only', + }; + mockDb.chain.where.mockResolvedValueOnce([mockLogs]); + + const result = await getRunLogs('run-2'); + + expect(result).toEqual(mockLogs); + expect(result?.cascadeLog).toBeNull(); + }); + }); +}); diff --git a/tests/unit/db/repositories/runStatsRepository.test.ts b/tests/unit/db/repositories/runStatsRepository.test.ts new file mode 100644 index 00000000..4e11bace --- /dev/null +++ b/tests/unit/db/repositories/runStatsRepository.test.ts @@ -0,0 +1,488 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRuns: { + id: 'id', + projectId: 'project_id', + workItemId: 'work_item_id', + agentType: 'agent_type', + status: 'status', + startedAt: 'started_at', + prNumber: 'pr_number', + durationMs: 'duration_ms', + costUsd: 'cost_usd', + engine: 'engine', + triggerType: 'trigger_type', + model: 'model', + maxIterations: 'max_iterations', + completedAt: 'completed_at', + llmIterations: 'llm_iterations', + gadgetCalls: 'gadget_calls', + success: 'success', + error: 'error', + prUrl: 'pr_url', + outputSummary: 'output_summary', + jobId: 'job_id', + }, + prWorkItems: { + projectId: 'project_id', + prNumber: 'pr_number', + workItemId: 'work_item_id', + workItemUrl: 'work_item_url', + workItemTitle: 'work_item_title', + prTitle: 'pr_title', + }, + projects: { + id: 'id', + orgId: 'org_id', + name: 'name', + }, + organizations: { + id: 'id', + name: 'name', + }, +})); + +vi.mock('../../../../src/db/repositories/joinHelpers.js', () => ({ + buildAgentRunWorkItemJoin: () => 'mock-join-condition', +})); + +import { mockGetDb } from '../../../helpers/sharedMocks.js'; + +import { + type AggregatedProjectStats, + getProjectWorkStats, + getProjectWorkStatsAggregated, + getRunsByWorkItem, + getRunsForPR, + listProjectsForOrg, + listRuns, +} from '../../../../src/db/repositories/runStatsRepository.js'; + +// ============================================================================ +// Test helper +// ============================================================================ + +function buildSelectChain(opts: { withInnerJoin?: boolean; withLeftJoin?: boolean } = {}) { + const chain: Record> = {}; + const methods = ['from', 'where', 'orderBy', 'limit', 'offset', 'groupBy']; + for (const m of methods) { + chain[m] = vi.fn().mockReturnValue(chain); + } + if (opts.withInnerJoin) { + chain.innerJoin = vi.fn().mockReturnValue(chain); + } + if (opts.withLeftJoin) { + chain.leftJoin = vi.fn().mockReturnValue(chain); + } + // Make thenable + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle query chains + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve([]).then(resolve); + return chain; +} + +describe('runStatsRepository', () => { + let mockSelect: ReturnType; + let mockDb: { select: ReturnType }; + + beforeEach(() => { + vi.resetAllMocks(); + mockSelect = vi.fn(); + mockDb = { select: mockSelect }; + mockGetDb.mockReturnValue(mockDb as never); + }); + + describe('listRuns', () => { + it('returns data and total from parallel queries', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + + const mockData = [{ id: 'run-1', projectId: 'proj-1', orgName: 'Org A' }]; + dataChain.offset.mockResolvedValue(mockData); + countChain.where.mockResolvedValue([{ total: 1 }]); + + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + const result = await listRuns({ orgId: 'org-1', limit: 10, offset: 0 }); + + expect(result.data).toEqual(mockData); + expect(result.total).toBe(1); + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + + it('applies projectId filter', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + dataChain.offset.mockResolvedValue([]); + countChain.where.mockResolvedValue([{ total: 0 }]); + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ orgId: 'org-1', projectId: 'proj-1', limit: 10, offset: 0 }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + + it('applies status filter', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + dataChain.offset.mockResolvedValue([]); + countChain.where.mockResolvedValue([{ total: 0 }]); + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ + orgId: 'org-1', + status: ['running', 'failed'], + limit: 10, + offset: 0, + }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + + it('applies date range filters', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + dataChain.offset.mockResolvedValue([]); + countChain.where.mockResolvedValue([{ total: 0 }]); + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ + orgId: 'org-1', + startedAfter: new Date('2024-01-01'), + startedBefore: new Date('2024-12-31'), + limit: 10, + offset: 0, + }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + + it('uses asc ordering when specified', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + dataChain.offset.mockResolvedValue([]); + countChain.where.mockResolvedValue([{ total: 0 }]); + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ limit: 10, offset: 0, sort: 'durationMs', order: 'asc' }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + + it('uses costUsd as sort column when specified', async () => { + const dataChain = buildSelectChain({ withInnerJoin: true, withLeftJoin: true }); + const countChain = buildSelectChain({ withInnerJoin: true }); + dataChain.offset.mockResolvedValue([]); + countChain.where.mockResolvedValue([{ total: 0 }]); + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ limit: 10, offset: 0, sort: 'costUsd', order: 'desc' }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + }); + + describe('listProjectsForOrg', () => { + it('returns projects for org', async () => { + const mockProjects = [ + { id: 'proj-1', name: 'Project Alpha' }, + { id: 'proj-2', name: 'Project Beta' }, + ]; + const chain = buildSelectChain(); + chain.where.mockResolvedValue(mockProjects); + mockSelect.mockReturnValue(chain); + + const result = await listProjectsForOrg('org-1'); + + expect(result).toEqual(mockProjects); + }); + + it('returns empty array when org has no projects', async () => { + const chain = buildSelectChain(); + chain.where.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + const result = await listProjectsForOrg('org-empty'); + + expect(result).toEqual([]); + }); + }); + + describe('getRunsByWorkItem', () => { + it('returns enriched runs for a work item', async () => { + const mockRuns = [ + { + id: 'run-1', + projectId: 'proj-1', + workItemId: 'card-1', + workItemUrl: 'https://trello.com/c/abc', + workItemTitle: 'Test Card', + prTitle: null, + }, + ]; + const chain = buildSelectChain({ withLeftJoin: true }); + chain.orderBy.mockResolvedValue(mockRuns); + mockSelect.mockReturnValue(chain); + + const result = await getRunsByWorkItem('proj-1', 'card-1'); + + expect(result).toEqual(mockRuns); + expect(chain.leftJoin).toHaveBeenCalledWith(expect.anything(), 'mock-join-condition'); + }); + + it('returns empty array when no runs exist', async () => { + const chain = buildSelectChain({ withLeftJoin: true }); + chain.orderBy.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + const result = await getRunsByWorkItem('proj-1', 'nonexistent'); + + expect(result).toEqual([]); + }); + }); + + describe('getRunsForPR', () => { + it('returns enriched runs for a PR number', async () => { + const mockRuns = [ + { + id: 'run-3', + projectId: 'proj-1', + prNumber: 42, + workItemUrl: 'https://trello.com/c/xyz', + workItemTitle: 'Implement feature', + prTitle: 'feat: implement feature', + }, + ]; + const chain = buildSelectChain({ withLeftJoin: true }); + chain.orderBy.mockResolvedValue(mockRuns); + mockSelect.mockReturnValue(chain); + + const result = await getRunsForPR('proj-1', 42); + + expect(result).toEqual(mockRuns); + expect(chain.leftJoin).toHaveBeenCalledWith(expect.anything(), 'mock-join-condition'); + }); + + it('returns empty array when no runs exist for PR', async () => { + const chain = buildSelectChain({ withLeftJoin: true }); + chain.orderBy.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + const result = await getRunsForPR('proj-1', 9999); + + expect(result).toEqual([]); + }); + }); + + describe('getProjectWorkStats', () => { + it('returns stats with required fields', async () => { + const mockStats = [ + { + agentType: 'implementation', + status: 'completed', + durationMs: 5000, + costUsd: '0.1000', + model: 'claude-3', + startedAt: new Date('2024-01-01'), + }, + ]; + const chain = buildSelectChain(); + chain.limit.mockResolvedValue(mockStats); + mockSelect.mockReturnValue(chain); + + const result = await getProjectWorkStats('proj-1'); + + expect(result).toEqual(mockStats); + }); + + it('applies dateFrom filter when provided', async () => { + const chain = buildSelectChain(); + chain.limit.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + await getProjectWorkStats('proj-1', { dateFrom: new Date('2024-01-01') }); + + expect(mockSelect).toHaveBeenCalled(); + }); + + it('applies agentType filter when provided', async () => { + const chain = buildSelectChain(); + chain.limit.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + await getProjectWorkStats('proj-1', { agentType: 'review' }); + + expect(mockSelect).toHaveBeenCalled(); + }); + + it('applies status filter when provided', async () => { + const chain = buildSelectChain(); + chain.limit.mockResolvedValue([]); + mockSelect.mockReturnValue(chain); + + await getProjectWorkStats('proj-1', { status: 'failed' }); + + expect(mockSelect).toHaveBeenCalled(); + }); + }); + + describe('getProjectWorkStatsAggregated', () => { + function setupAggregatedChains(agentRows: unknown[]) { + // Subquery chain + const subChain = buildSelectChain(); + const subqueryRef = { + agentType: 'agent_type', + status: 'status', + durationMs: 'duration_ms', + costUsd: 'cost_usd', + }; + const mockAs = vi.fn().mockReturnValue(subqueryRef); + subChain.limit.mockReturnValue({ as: mockAs }); + + // Aggregate chain + const aggChain = buildSelectChain(); + aggChain.groupBy.mockResolvedValue(agentRows); + + mockSelect.mockReturnValueOnce(subChain).mockReturnValueOnce(aggChain); + + return { subChain, aggChain }; + } + + it('returns empty summary when no rows', async () => { + setupAggregatedChains([]); + + const result = await getProjectWorkStatsAggregated('proj-1'); + + expect(result.summary.totalRuns).toBe(0); + expect(result.summary.completedRuns).toBe(0); + expect(result.summary.failedRuns).toBe(0); + expect(result.summary.timedOutRuns).toBe(0); + expect(result.summary.successRate).toBe(0); + expect(result.summary.avgDurationMs).toBeNull(); + expect(result.byAgentType).toEqual([]); + }); + + it('computes correct totals across agent types', async () => { + const agentRows = [ + { + agentType: 'implementation', + runCount: 10, + completedCount: 8, + failedCount: 2, + timedOutCount: 0, + totalCostUsd: '1.2000', + totalDurationMs: 600000, + durationRunCount: 8, + avgDurationMs: 75000, + }, + { + agentType: 'review', + runCount: 5, + completedCount: 5, + failedCount: 0, + timedOutCount: 0, + totalCostUsd: '0.5000', + totalDurationMs: 150000, + durationRunCount: 5, + avgDurationMs: 30000, + }, + ]; + setupAggregatedChains(agentRows); + + const result = await getProjectWorkStatsAggregated('proj-1'); + + expect(result.summary.totalRuns).toBe(15); + expect(result.summary.completedRuns).toBe(13); + expect(result.summary.failedRuns).toBe(2); + expect(result.summary.timedOutRuns).toBe(0); + expect(result.byAgentType).toHaveLength(2); + }); + + it('computes correct totalCostUsd in summary', async () => { + setupAggregatedChains([ + { + agentType: 'implementation', + runCount: 2, + completedCount: 2, + failedCount: 0, + timedOutCount: 0, + totalCostUsd: '0.5000', + totalDurationMs: 60000, + durationRunCount: 2, + avgDurationMs: 30000, + }, + { + agentType: 'review', + runCount: 1, + completedCount: 1, + failedCount: 0, + timedOutCount: 0, + totalCostUsd: '0.2500', + totalDurationMs: 30000, + durationRunCount: 1, + avgDurationMs: 30000, + }, + ]); + + const result = await getProjectWorkStatsAggregated('proj-1'); + + expect(result.summary.totalCostUsd).toBe('0.7500'); + }); + + it('handles null avgDurationMs gracefully', async () => { + setupAggregatedChains([ + { + agentType: 'implementation', + runCount: 2, + completedCount: 1, + failedCount: 1, + timedOutCount: 0, + totalCostUsd: '0.0000', + totalDurationMs: 0, + durationRunCount: 0, + avgDurationMs: null, + }, + ]); + + const result: AggregatedProjectStats = await getProjectWorkStatsAggregated('proj-1'); + + expect(result.summary.avgDurationMs).toBeNull(); + expect(result.byAgentType[0].avgDurationMs).toBeNull(); + }); + + it('returns 100% success rate when all runs completed', async () => { + setupAggregatedChains([ + { + agentType: 'implementation', + runCount: 5, + completedCount: 5, + failedCount: 0, + timedOutCount: 0, + totalCostUsd: '1.0000', + totalDurationMs: 300000, + durationRunCount: 5, + avgDurationMs: 60000, + }, + ]); + + const result = await getProjectWorkStatsAggregated('proj-1'); + + expect(result.summary.successRate).toBe(100); + }); + + it('applies filters when provided', async () => { + setupAggregatedChains([]); + + await getProjectWorkStatsAggregated('proj-1', { + dateFrom: new Date('2024-01-01'), + agentType: 'review', + status: 'completed', + }); + + expect(mockSelect).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/unit/db/repositories/runsRepository-concurrency.test.ts b/tests/unit/db/repositories/runsRepository-concurrency.test.ts new file mode 100644 index 00000000..744b631b --- /dev/null +++ b/tests/unit/db/repositories/runsRepository-concurrency.test.ts @@ -0,0 +1,451 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRuns: { + id: 'id', + projectId: 'project_id', + workItemId: 'work_item_id', + agentType: 'agent_type', + status: 'status', + startedAt: 'started_at', + prNumber: 'pr_number', + durationMs: 'duration_ms', + costUsd: 'cost_usd', + engine: 'engine', + triggerType: 'trigger_type', + model: 'model', + maxIterations: 'max_iterations', + completedAt: 'completed_at', + llmIterations: 'llm_iterations', + gadgetCalls: 'gadget_calls', + success: 'success', + error: 'error', + prUrl: 'pr_url', + outputSummary: 'output_summary', + jobId: 'job_id', + }, + prWorkItems: { + projectId: 'project_id', + prNumber: 'pr_number', + workItemId: 'work_item_id', + workItemUrl: 'work_item_url', + workItemTitle: 'work_item_title', + prTitle: 'pr_title', + }, + agentRunLogs: { runId: 'run_id' }, + agentRunLlmCalls: { + id: 'id', + runId: 'run_id', + callNumber: 'call_number', + }, + debugAnalyses: { id: 'id', analyzedRunId: 'analyzed_run_id', debugRunId: 'debug_run_id' }, + projects: { id: 'id', orgId: 'org_id', name: 'name' }, + organizations: { id: 'id', name: 'name' }, +})); + +vi.mock('../../../../src/db/repositories/joinHelpers.js', () => ({ + buildAgentRunWorkItemJoin: () => 'mock-join-condition', +})); + +vi.mock('../../../../src/db/repositories/llmCallsRepository.js', () => ({ + storeLlmCall: vi.fn(), + storeLlmCallsBulk: vi.fn(), + getLlmCallsByRunId: vi.fn(), + getLlmCallByNumber: vi.fn(), + listLlmCallsMeta: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/debugAnalysisRepository.js', () => ({ + storeDebugAnalysis: vi.fn(), + getDebugAnalysisByRunId: vi.fn(), + deleteDebugAnalysisByRunId: vi.fn(), + getDebugAnalysisByDebugRunId: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/runLogsRepository.js', () => ({ + storeRunLogs: vi.fn(), + getRunLogs: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/runStatsRepository.js', () => ({ + listRuns: vi.fn(), + listProjectsForOrg: vi.fn(), + getRunsByWorkItem: vi.fn(), + getRunsForPR: vi.fn(), + getProjectWorkStats: vi.fn(), + getProjectWorkStatsAggregated: vi.fn(), +})); + +import { mockGetDb } from '../../../helpers/sharedMocks.js'; + +import { + cancelRunById, + countActiveRuns, + failOrphanedRun, + failOrphanedRunFallback, + hasActiveRunForWorkItem, +} from '../../../../src/db/repositories/runsRepository.js'; + +// ============================================================================ +// Test helpers +// ============================================================================ + +function buildMockDb() { + const mockInsert = vi.fn(); + const mockUpdate = vi.fn(); + const mockSelect = vi.fn(); + const mockDelete = vi.fn(); + const mockValues = vi.fn(); + const mockReturning = vi.fn(); + const mockSet = vi.fn(); + const mockWhere = vi.fn(); + const mockFrom = vi.fn(); + const mockOrderBy = vi.fn(); + const mockLimit = vi.fn(); + + mockInsert.mockReturnValue({ values: mockValues }); + mockValues.mockReturnValue({ returning: mockReturning }); + mockUpdate.mockReturnValue({ set: mockSet }); + mockSet.mockReturnValue({ where: mockWhere }); + mockWhere.mockResolvedValue([]); + mockSelect.mockReturnValue({ from: mockFrom }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockOrderBy.mockReturnValue({ limit: mockLimit }); + + const db = { + insert: mockInsert, + update: mockUpdate, + select: mockSelect, + delete: mockDelete, + }; + + mockGetDb.mockReturnValue(db as never); + + return { + db, + chain: { + insert: mockInsert, + update: mockUpdate, + select: mockSelect, + delete: mockDelete, + values: mockValues, + returning: mockReturning, + set: mockSet, + where: mockWhere, + from: mockFrom, + orderBy: mockOrderBy, + limit: mockLimit, + }, + }; +} + +describe('runsRepository - concurrency functions', () => { + describe('countActiveRuns', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = buildMockDb(); + mocks.chain.where.mockResolvedValue([{ count: 0 }]); + }); + + it('returns count for projectId only (base condition)', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 3 }]); + + const result = await countActiveRuns({ projectId: 'proj-1' }); + + expect(result).toBe(3); + expect(mocks.db.select).toHaveBeenCalled(); + }); + + it('returns count for projectId + workItemId', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 1 }]); + + const result = await countActiveRuns({ projectId: 'proj-1', workItemId: 'card-1' }); + + expect(result).toBe(1); + }); + + it('returns count for projectId + agentType', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 2 }]); + + const result = await countActiveRuns({ + projectId: 'proj-1', + agentType: 'implementation', + }); + + expect(result).toBe(2); + }); + + it('returns count for projectId + workItemId + agentType', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 1 }]); + + const result = await countActiveRuns({ + projectId: 'proj-1', + workItemId: 'card-1', + agentType: 'implementation', + }); + + expect(result).toBe(1); + }); + + it('accepts maxAgeMs and applies date cutoff condition', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 0 }]); + + const result = await countActiveRuns({ projectId: 'proj-1', maxAgeMs: 3600000 }); + + expect(result).toBe(0); + }); + + it('returns 0 when count row is missing', async () => { + mocks.chain.where.mockResolvedValueOnce([]); + + const result = await countActiveRuns({ projectId: 'proj-1' }); + + expect(result).toBe(0); + }); + + it('returns 0 when row has undefined count', async () => { + mocks.chain.where.mockResolvedValueOnce([undefined]); + + const result = await countActiveRuns({ projectId: 'proj-1' }); + + expect(result).toBe(0); + }); + }); + + describe('hasActiveRunForWorkItem', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = buildMockDb(); + }); + + it('returns true when count > 0', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 1 }]); + + const result = await hasActiveRunForWorkItem('proj-1', 'card-1'); + + expect(result).toBe(true); + }); + + it('returns false when count is 0', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 0 }]); + + const result = await hasActiveRunForWorkItem('proj-1', 'card-1'); + + expect(result).toBe(false); + }); + + it('returns false when count row is missing', async () => { + mocks.chain.where.mockResolvedValueOnce([]); + + const result = await hasActiveRunForWorkItem('proj-1', 'card-1'); + + expect(result).toBe(false); + }); + + it('accepts optional maxAgeMs parameter', async () => { + mocks.chain.where.mockResolvedValueOnce([{ count: 2 }]); + + const result = await hasActiveRunForWorkItem('proj-1', 'card-1', 3600000); + + expect(result).toBe(true); + }); + }); + + describe('failOrphanedRun', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = buildMockDb(); + }); + + it('returns null when no running run found', async () => { + const mockLimitNoResult = vi.fn().mockResolvedValue([]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitNoResult }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const result = await failOrphanedRun('proj-1', 'card-1', 'Container died'); + + expect(result).toBeNull(); + expect(mocks.db.update).not.toHaveBeenCalled(); + }); + + it('updates status to failed and returns the run id', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([{ id: 'run-orphan-1' }]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + // UPDATE returning + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-orphan-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await failOrphanedRun('proj-1', 'card-1', 'Container died'); + + expect(result).toBe('run-orphan-1'); + expect(mocks.db.update).toHaveBeenCalled(); + expect(mocks.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + error: 'Container died', + }), + ); + }); + + it('accepts timed_out status', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await failOrphanedRun('proj-1', 'card-1', 'Timeout', 'timed_out'); + + expect(result).toBe('run-1'); + expect(mocks.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ status: 'timed_out' }), + ); + }); + + it('returns null when concurrent update wins (update matches nothing)', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([{ id: 'run-orphan-2' }]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const mockReturningForUpdate = vi.fn().mockResolvedValue([]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await failOrphanedRun('proj-1', 'card-1', 'Died'); + + expect(result).toBeNull(); + }); + + it('passes durationMs to the update', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + await failOrphanedRun('proj-1', 'card-1', 'Died', 'failed', 5000); + + expect(mocks.chain.set).toHaveBeenCalledWith(expect.objectContaining({ durationMs: 5000 })); + }); + }); + + describe('failOrphanedRunFallback', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = buildMockDb(); + }); + + it('returns null when no matching run found', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const result = await failOrphanedRunFallback( + 'proj-1', + 'implementation', + new Date('2024-01-01'), + 'failed', + 'Worker died', + ); + + expect(result).toBeNull(); + }); + + it('updates the run and returns its id', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([{ id: 'run-fb-1' }]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-fb-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await failOrphanedRunFallback( + 'proj-1', + undefined, + new Date('2024-01-01'), + 'timed_out', + 'Container died', + 3000, + ); + + expect(result).toBe('run-fb-1'); + expect(mocks.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'timed_out', + error: 'Container died', + durationMs: 3000, + }), + ); + }); + + it('includes agentType condition when provided', async () => { + const mockLimitForSelect = vi.fn().mockResolvedValue([]); + mocks.chain.orderBy.mockReturnValue({ limit: mockLimitForSelect }); + mocks.chain.where.mockReturnValue({ orderBy: mocks.chain.orderBy }); + + await failOrphanedRunFallback('proj-1', 'implementation', new Date(), 'failed', 'Reason'); + + expect(mocks.db.select).toHaveBeenCalled(); + }); + }); + + describe('cancelRunById', () => { + let mocks: ReturnType; + + beforeEach(() => { + mocks = buildMockDb(); + }); + + it('returns true when run is successfully cancelled', async () => { + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await cancelRunById('run-1', 'Cancelled by user'); + + expect(result).toBe(true); + expect(mocks.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + error: 'Cancelled by user', + }), + ); + }); + + it('sets completedAt when cancelling', async () => { + const mockReturningForUpdate = vi.fn().mockResolvedValue([{ id: 'run-1' }]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + await cancelRunById('run-1', 'User cancelled'); + + const setArg = mocks.chain.set.mock.calls[0][0]; + expect(setArg.completedAt).toBeInstanceOf(Date); + }); + + it('returns false when run is not in running state', async () => { + const mockReturningForUpdate = vi.fn().mockResolvedValue([]); + const mockWhereForUpdate = vi.fn().mockReturnValue({ returning: mockReturningForUpdate }); + mocks.chain.set.mockReturnValue({ where: mockWhereForUpdate }); + + const result = await cancelRunById('run-completed', 'Trying to cancel'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/tests/unit/db/repositories/runsRepository-core.test.ts b/tests/unit/db/repositories/runsRepository-core.test.ts new file mode 100644 index 00000000..3a61d481 --- /dev/null +++ b/tests/unit/db/repositories/runsRepository-core.test.ts @@ -0,0 +1,360 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRuns: { + id: 'id', + projectId: 'project_id', + workItemId: 'work_item_id', + agentType: 'agent_type', + status: 'status', + startedAt: 'started_at', + prNumber: 'pr_number', + durationMs: 'duration_ms', + costUsd: 'cost_usd', + engine: 'engine', + triggerType: 'trigger_type', + model: 'model', + maxIterations: 'max_iterations', + completedAt: 'completed_at', + llmIterations: 'llm_iterations', + gadgetCalls: 'gadget_calls', + success: 'success', + error: 'error', + prUrl: 'pr_url', + outputSummary: 'output_summary', + jobId: 'job_id', + }, + prWorkItems: { + projectId: 'project_id', + prNumber: 'pr_number', + workItemId: 'work_item_id', + workItemUrl: 'work_item_url', + workItemTitle: 'work_item_title', + prTitle: 'pr_title', + }, + agentRunLogs: { runId: 'run_id' }, + agentRunLlmCalls: { + id: 'id', + runId: 'run_id', + callNumber: 'call_number', + inputTokens: 'input_tokens', + outputTokens: 'output_tokens', + cachedTokens: 'cached_tokens', + costUsd: 'cost_usd', + durationMs: 'duration_ms', + model: 'model', + createdAt: 'created_at', + }, + debugAnalyses: { id: 'id', analyzedRunId: 'analyzed_run_id', debugRunId: 'debug_run_id' }, + projects: { id: 'id', orgId: 'org_id', name: 'name' }, + organizations: { id: 'id', name: 'name' }, +})); + +vi.mock('../../../../src/db/repositories/joinHelpers.js', () => ({ + buildAgentRunWorkItemJoin: () => 'mock-join-condition', +})); + +vi.mock('../../../../src/db/repositories/llmCallsRepository.js', () => ({ + storeLlmCall: vi.fn(), + storeLlmCallsBulk: vi.fn(), + getLlmCallsByRunId: vi.fn(), + getLlmCallByNumber: vi.fn(), + listLlmCallsMeta: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/debugAnalysisRepository.js', () => ({ + storeDebugAnalysis: vi.fn(), + getDebugAnalysisByRunId: vi.fn(), + deleteDebugAnalysisByRunId: vi.fn(), + getDebugAnalysisByDebugRunId: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/runLogsRepository.js', () => ({ + storeRunLogs: vi.fn(), + getRunLogs: vi.fn(), +})); + +vi.mock('../../../../src/db/repositories/runStatsRepository.js', () => ({ + listRuns: vi.fn(), + listProjectsForOrg: vi.fn(), + getRunsByWorkItem: vi.fn(), + getRunsForPR: vi.fn(), + getProjectWorkStats: vi.fn(), + getProjectWorkStatsAggregated: vi.fn(), +})); + +import { + completeRun, + createRun, + getRunById, + getRunJobId, + getRunsByProjectId, + getRunsByWorkItemId, + updateRunJobId, + updateRunPRNumber, +} from '../../../../src/db/repositories/runsRepository.js'; + +describe('runsRepository - core CRUD', () => { + let mockDb: ReturnType; + + // leftJoin support needed for getRunById + const mockLeftJoin = vi.fn(); + + beforeEach(() => { + mockDb = createMockDbWithGetDb({ withThenable: true }); + + // Wire leftJoin into the from chain + const originalFrom = mockDb.chain.from; + originalFrom.mockReturnValue({ + where: mockDb.chain.where, + innerJoin: mockDb.chain.innerJoin, + leftJoin: mockLeftJoin, + }); + mockLeftJoin.mockReturnValue({ where: mockDb.chain.where }); + }); + + describe('createRun', () => { + it('inserts a run and returns the id', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'run-uuid-1' }]); + + const result = await createRun({ + projectId: 'proj-1', + workItemId: 'card-1', + agentType: 'implementation', + engine: 'llmist', + triggerType: 'card-moved-to-todo', + model: 'claude-3', + maxIterations: 20, + }); + + expect(result).toBe('run-uuid-1'); + expect(mockDb.db.insert).toHaveBeenCalled(); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'proj-1', + workItemId: 'card-1', + agentType: 'implementation', + engine: 'llmist', + status: 'running', + }), + ); + }); + + it('inserts with optional fields undefined', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'run-uuid-2' }]); + + const result = await createRun({ + projectId: 'proj-2', + agentType: 'splitting', + engine: 'claude-code', + }); + + expect(result).toBe('run-uuid-2'); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'proj-2', + agentType: 'splitting', + engine: 'claude-code', + status: 'running', + workItemId: undefined, + prNumber: undefined, + }), + ); + }); + + it('sets prNumber when provided', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 'run-uuid-3' }]); + + await createRun({ + projectId: 'proj-1', + prNumber: 42, + agentType: 'review', + engine: 'claude-code', + }); + + expect(mockDb.chain.values).toHaveBeenCalledWith(expect.objectContaining({ prNumber: 42 })); + }); + }); + + describe('completeRun', () => { + it('updates run with completed status and all fields', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await completeRun('run-1', { + status: 'completed', + durationMs: 5000, + llmIterations: 10, + gadgetCalls: 5, + costUsd: 0.123456, + success: true, + prUrl: 'https://github.com/owner/repo/pull/42', + outputSummary: 'Summary text', + }); + + expect(mockDb.db.update).toHaveBeenCalled(); + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + durationMs: 5000, + llmIterations: 10, + success: true, + costUsd: '0.123456', + prUrl: 'https://github.com/owner/repo/pull/42', + }), + ); + }); + + it('converts costUsd to string representation', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await completeRun('run-1', { + status: 'completed', + costUsd: 0.001, + }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.costUsd).toBe('0.001'); + }); + + it('sets completedAt to a Date', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await completeRun('run-1', { status: 'failed', success: false }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.completedAt).toBeInstanceOf(Date); + }); + + it('handles timed_out status', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await completeRun('run-1', { + status: 'timed_out', + success: false, + error: 'Watchdog timeout', + }); + + expect(mockDb.chain.set).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'timed_out', + error: 'Watchdog timeout', + }), + ); + }); + }); + + describe('updateRunPRNumber', () => { + it('calls update with isNull guard on prNumber', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateRunPRNumber('run-1', 42); + + expect(mockDb.db.update).toHaveBeenCalled(); + expect(mockDb.chain.set).toHaveBeenCalledWith({ prNumber: 42 }); + // where() is called to apply the isNull guard + expect(mockDb.chain.where).toHaveBeenCalled(); + }); + }); + + describe('updateRunJobId', () => { + it('updates the job_id for a run', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateRunJobId('run-1', 'job-456'); + + expect(mockDb.db.update).toHaveBeenCalled(); + expect(mockDb.chain.set).toHaveBeenCalledWith({ jobId: 'job-456' }); + }); + }); + + describe('getRunJobId', () => { + it('returns jobId when found', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ jobId: 'job-789' }]); + + const result = await getRunJobId('run-1'); + + expect(result).toBe('job-789'); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + + it('returns null when run not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getRunJobId('nonexistent'); + + expect(result).toBeNull(); + }); + + it('returns null when jobId is null', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ jobId: null }]); + + const result = await getRunJobId('run-1'); + + expect(result).toBeNull(); + }); + }); + + describe('getRunById', () => { + it('returns run when found', async () => { + const mockRun = { id: 'run-1', agentType: 'implementation', status: 'completed' }; + mockDb.chain.where.mockResolvedValueOnce([mockRun]); + + const result = await getRunById('run-1'); + + expect(result).toEqual(mockRun); + expect(mockLeftJoin).toHaveBeenCalledWith(expect.anything(), 'mock-join-condition'); + }); + + it('returns null when not found', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getRunById('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('getRunsByWorkItemId', () => { + it('returns runs ordered by startedAt desc', async () => { + const mockRuns = [ + { id: 'run-2', workItemId: 'card-1' }, + { id: 'run-1', workItemId: 'card-1' }, + ]; + + // getRunsByWorkItemId uses .orderBy() as terminal + const mockOrderBy = vi.fn().mockResolvedValueOnce(mockRuns); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await getRunsByWorkItemId('card-1'); + + expect(result).toEqual(mockRuns); + expect(mockDb.db.select).toHaveBeenCalled(); + }); + }); + + describe('getRunsByProjectId', () => { + it('returns runs for project ordered by startedAt desc', async () => { + const mockRuns = [{ id: 'run-1', projectId: 'proj-1' }]; + + const mockOrderBy = vi.fn().mockResolvedValueOnce(mockRuns); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await getRunsByProjectId('proj-1'); + + expect(result).toEqual(mockRuns); + }); + + it('returns empty array when no runs exist', async () => { + const mockOrderBy = vi.fn().mockResolvedValueOnce([]); + mockDb.chain.where.mockReturnValueOnce({ orderBy: mockOrderBy }); + + const result = await getRunsByProjectId('empty-proj'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/db/repositories/webhookLogsRepository.test.ts b/tests/unit/db/repositories/webhookLogsRepository.test.ts new file mode 100644 index 00000000..4fc55c43 --- /dev/null +++ b/tests/unit/db/repositories/webhookLogsRepository.test.ts @@ -0,0 +1,330 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + webhookLogs: { + id: 'id', + source: 'source', + method: 'method', + path: 'path', + headers: 'headers', + body: 'body', + bodyRaw: 'body_raw', + statusCode: 'status_code', + receivedAt: 'received_at', + projectId: 'project_id', + eventType: 'event_type', + processed: 'processed', + decisionReason: 'decision_reason', + }, +})); + +import { mockGetDb } from '../../../helpers/sharedMocks.js'; + +import { + getWebhookLogById, + getWebhookLogStats, + insertWebhookLog, + listWebhookLogs, + pruneWebhookLogs, +} from '../../../../src/db/repositories/webhookLogsRepository.js'; + +// Helper to build a chainable mock db +function buildMockDb() { + const mockInsert = vi.fn(); + const mockSelect = vi.fn(); + const mockDelete = vi.fn(); + const mockValues = vi.fn(); + const mockReturning = vi.fn(); + const mockWhere = vi.fn(); + const mockFrom = vi.fn(); + const mockOrderBy = vi.fn(); + const mockLimit = vi.fn(); + const mockOffset = vi.fn(); + const mockGroupBy = vi.fn(); + + mockInsert.mockReturnValue({ values: mockValues }); + mockValues.mockReturnValue({ returning: mockReturning }); + mockReturning.mockResolvedValue([]); + mockSelect.mockReturnValue({ from: mockFrom }); + mockFrom.mockReturnValue({ + where: mockWhere, + orderBy: mockOrderBy, + groupBy: mockGroupBy, + }); + mockWhere.mockReturnValue({ orderBy: mockOrderBy, limit: mockLimit }); + mockOrderBy.mockReturnValue({ limit: mockLimit }); + mockLimit.mockReturnValue({ offset: mockOffset }); + mockOffset.mockResolvedValue([]); + mockGroupBy.mockResolvedValue([]); + mockDelete.mockReturnValue({ where: mockWhere }); + + const db = { insert: mockInsert, select: mockSelect, delete: mockDelete }; + mockGetDb.mockReturnValue(db as never); + + return { + db, + chain: { + insert: mockInsert, + select: mockSelect, + delete: mockDelete, + values: mockValues, + returning: mockReturning, + where: mockWhere, + from: mockFrom, + orderBy: mockOrderBy, + limit: mockLimit, + offset: mockOffset, + groupBy: mockGroupBy, + }, + }; +} + +describe('webhookLogsRepository', () => { + let mocks: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + mocks = buildMockDb(); + }); + + describe('insertWebhookLog', () => { + it('inserts a webhook log and returns the id', async () => { + mocks.chain.returning.mockResolvedValueOnce([{ id: 'log-uuid-1' }]); + + const result = await insertWebhookLog({ + source: 'trello', + method: 'POST', + path: '/trello/webhook', + headers: { 'content-type': 'application/json' }, + body: { action: { type: 'createCard' } }, + statusCode: 200, + eventType: 'createCard', + processed: true, + }); + + expect(result).toBe('log-uuid-1'); + expect(mocks.db.insert).toHaveBeenCalled(); + expect(mocks.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'trello', + method: 'POST', + path: '/trello/webhook', + statusCode: 200, + eventType: 'createCard', + processed: true, + }), + ); + }); + + it('defaults processed to false when not provided', async () => { + mocks.chain.returning.mockResolvedValueOnce([{ id: 'log-uuid-2' }]); + + await insertWebhookLog({ + source: 'github', + method: 'POST', + path: '/github/webhook', + statusCode: 200, + }); + + expect(mocks.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ processed: false }), + ); + }); + + it('stores optional projectId and decisionReason', async () => { + mocks.chain.returning.mockResolvedValueOnce([{ id: 'log-uuid-3' }]); + + await insertWebhookLog({ + source: 'github', + method: 'POST', + path: '/webhook', + statusCode: 200, + projectId: 'proj-1', + decisionReason: 'duplicate event', + }); + + expect(mocks.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'proj-1', + decisionReason: 'duplicate event', + }), + ); + }); + }); + + describe('listWebhookLogs', () => { + it('returns paginated data and total without filters', async () => { + const mockData = [{ id: 'log-1', source: 'trello' }]; + const mockTotal = [{ total: 1 }]; + + // Two parallel queries: data + count + mocks.chain.offset.mockResolvedValueOnce(mockData); + // Second select returns for count query + const countWhere = vi.fn().mockResolvedValue(mockTotal); + mocks.chain.from + .mockReturnValueOnce({ + where: mocks.chain.where, + orderBy: mocks.chain.orderBy, + groupBy: mocks.chain.groupBy, + }) + .mockReturnValueOnce({ where: countWhere }); + + const result = await listWebhookLogs({ limit: 50, offset: 0 }); + + expect(mocks.db.select).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + it('applies source filter', async () => { + mocks.chain.offset.mockResolvedValueOnce([]); + const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]); + mocks.chain.from + .mockReturnValueOnce({ + where: mocks.chain.where, + orderBy: mocks.chain.orderBy, + }) + .mockReturnValueOnce({ where: countWhere }); + + await listWebhookLogs({ source: 'trello', limit: 50, offset: 0 }); + + expect(mocks.db.select).toHaveBeenCalled(); + }); + + it('applies eventType filter', async () => { + mocks.chain.offset.mockResolvedValueOnce([]); + const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]); + mocks.chain.from + .mockReturnValueOnce({ + where: mocks.chain.where, + orderBy: mocks.chain.orderBy, + }) + .mockReturnValueOnce({ where: countWhere }); + + await listWebhookLogs({ eventType: 'push', limit: 50, offset: 0 }); + + expect(mocks.db.select).toHaveBeenCalled(); + }); + + it('applies date range filters', async () => { + mocks.chain.offset.mockResolvedValueOnce([]); + const countWhere = vi.fn().mockResolvedValue([{ total: 0 }]); + mocks.chain.from + .mockReturnValueOnce({ + where: mocks.chain.where, + orderBy: mocks.chain.orderBy, + }) + .mockReturnValueOnce({ where: countWhere }); + + await listWebhookLogs({ + receivedAfter: new Date('2024-01-01'), + receivedBefore: new Date('2024-12-31'), + limit: 10, + offset: 0, + }); + + expect(mocks.db.select).toHaveBeenCalled(); + }); + }); + + describe('getWebhookLogById', () => { + it('returns log when found by full UUID', async () => { + const mockLog = { id: '11111111-1111-1111-1111-111111111111', source: 'trello' }; + mocks.chain.where.mockResolvedValueOnce([mockLog]); + + const result = await getWebhookLogById('11111111-1111-1111-1111-111111111111'); + + expect(result).toEqual(mockLog); + }); + + it('returns null when not found by full UUID', async () => { + mocks.chain.where.mockResolvedValueOnce([]); + + const result = await getWebhookLogById('00000000-0000-0000-0000-000000000000'); + + expect(result).toBeNull(); + }); + + it('resolves short ID prefix returning single match', async () => { + const mockLog = { id: '11111111-1111-1111-1111-111111111111', source: 'trello' }; + mocks.chain.limit.mockResolvedValueOnce([mockLog]); + + const result = await getWebhookLogById('11111111'); + + expect(result).toEqual(mockLog); + }); + + it('returns null for ambiguous short ID prefix (multiple matches)', async () => { + mocks.chain.limit.mockResolvedValueOnce([ + { id: '11111111-aaaa-0000-0000-000000000000' }, + { id: '11111111-bbbb-0000-0000-000000000000' }, + ]); + + const result = await getWebhookLogById('11111111'); + + expect(result).toBeNull(); + }); + + it('returns null for short prefix with no matches', async () => { + mocks.chain.limit.mockResolvedValueOnce([]); + + const result = await getWebhookLogById('aaaabbbb'); + + expect(result).toBeNull(); + }); + + it('uses short prefix path when id length < 36', async () => { + mocks.chain.limit.mockResolvedValueOnce([{ id: 'abc-123' }]); + + // Short IDs (length < 36) use SQL LIKE query + limit + await getWebhookLogById('abc12345'); + + // limit should have been called (short prefix path uses limit) + expect(mocks.chain.limit).toHaveBeenCalled(); + }); + }); + + describe('pruneWebhookLogs', () => { + it('calls delete to prune logs beyond retention count', async () => { + mocks.chain.where.mockResolvedValueOnce(undefined); + + await pruneWebhookLogs(1000); + + expect(mocks.db.delete).toHaveBeenCalled(); + }); + + it('can prune to different retention counts', async () => { + mocks.chain.where.mockResolvedValueOnce(undefined); + + await pruneWebhookLogs(500); + + expect(mocks.db.delete).toHaveBeenCalled(); + }); + }); + + describe('getWebhookLogStats', () => { + it('returns stats grouped by source', async () => { + const statsData = [ + { source: 'trello', count: 5 }, + { source: 'github', count: 10 }, + { source: 'jira', count: 3 }, + ]; + mocks.chain.groupBy.mockResolvedValueOnce(statsData); + + const result = await getWebhookLogStats(); + + expect(result).toEqual(statsData); + }); + + it('returns empty array when no logs exist', async () => { + mocks.chain.groupBy.mockResolvedValueOnce([]); + + const result = await getWebhookLogStats(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/router/adapters/sentry.test.ts b/tests/unit/router/adapters/sentry.test.ts new file mode 100644 index 00000000..a45054c5 --- /dev/null +++ b/tests/unit/router/adapters/sentry.test.ts @@ -0,0 +1,390 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockLogger } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/utils/logging.js', () => ({ logger: mockLogger })); + +vi.mock('../../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn(), +})); + +import { SentryRouterAdapter } from '../../../../src/router/adapters/sentry.js'; +import { loadProjectConfig } from '../../../../src/router/config.js'; +import type { RouterProjectConfig } from '../../../../src/router/config.js'; +import type { SentryJob } from '../../../../src/router/queue.js'; +import type { TriggerRegistry } from '../../../../src/triggers/registry.js'; + +// ============================================================================ +// Test fixtures +// ============================================================================ + +const mockProject: RouterProjectConfig = { + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', +}; + +const mockFullProject = { id: 'p1', repo: 'owner/repo' }; + +const mockTriggerRegistry = { + dispatch: vi.fn().mockResolvedValue({ + agentType: 'implementation', + workItemId: undefined, + prNumber: undefined, + }), +} as unknown as TriggerRegistry; + +const validEventAlertPayload = { + resource: 'event_alert', + payload: { action: 'triggered', data: { event: {} } }, + cascadeProjectId: 'p1', +}; + +const validMetricAlertPayload = { + resource: 'metric_alert', + payload: { action: 'critical', data: {} }, + cascadeProjectId: 'p1', +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [mockFullProject as never], + }); +}); + +describe('SentryRouterAdapter', () => { + let adapter: SentryRouterAdapter; + + beforeEach(() => { + adapter = new SentryRouterAdapter(); + }); + + describe('type', () => { + it('has type "sentry"', () => { + expect(adapter.type).toBe('sentry'); + }); + }); + + describe('parseWebhook', () => { + it('returns parsed event for event_alert resource', async () => { + const result = await adapter.parseWebhook(validEventAlertPayload); + + expect(result).not.toBeNull(); + expect(result?.eventType).toBe('event_alert'); + expect(result?.projectIdentifier).toBe('p1'); + expect(result?.isCommentEvent).toBe(false); + expect(result?.workItemId).toBeUndefined(); + }); + + it('returns parsed event for metric_alert resource', async () => { + const result = await adapter.parseWebhook(validMetricAlertPayload); + + expect(result).not.toBeNull(); + expect(result?.eventType).toBe('metric_alert'); + expect(result?.projectIdentifier).toBe('p1'); + }); + + it('returns null when cascadeProjectId is missing', async () => { + const payload = { + resource: 'event_alert', + payload: {}, + // cascadeProjectId missing + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('returns null when resource is missing', async () => { + const payload = { + cascadeProjectId: 'p1', + payload: {}, + // resource missing + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('returns null when payload is missing', async () => { + const payload = { + resource: 'event_alert', + cascadeProjectId: 'p1', + // payload missing + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalled(); + }); + + it('returns null for non-processable resource type "issue"', async () => { + const payload = { + resource: 'issue', + payload: { action: 'created', data: {} }, + cascadeProjectId: 'p1', + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).toBeNull(); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + + it('returns null for non-processable resource type "error"', async () => { + const payload = { + resource: 'error', + payload: { data: {} }, + cascadeProjectId: 'p1', + }; + + const result = await adapter.parseWebhook(payload); + + expect(result).toBeNull(); + }); + }); + + describe('isProcessableEvent', () => { + it('returns true for event_alert', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + expect(adapter.isProcessableEvent(event)).toBe(true); + }); + + it('returns true for metric_alert', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'metric_alert', + isCommentEvent: false, + }; + + expect(adapter.isProcessableEvent(event)).toBe(true); + }); + + it('returns false for "issue" resource', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'issue', + isCommentEvent: false, + }; + + expect(adapter.isProcessableEvent(event)).toBe(false); + }); + + it('returns false for unknown event type', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'unknown_type', + isCommentEvent: false, + }; + + expect(adapter.isProcessableEvent(event)).toBe(false); + }); + }); + + describe('isSelfAuthored', () => { + it('always returns false (Sentry has no CASCADE bot)', async () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + const result = await adapter.isSelfAuthored(event, {}); + + expect(result).toBe(false); + }); + }); + + describe('sendReaction', () => { + it('is a no-op (does not throw)', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + expect(() => adapter.sendReaction(event, {})).not.toThrow(); + }); + }); + + describe('resolveProject', () => { + it('returns project config when cascadeProjectId matches', async () => { + vi.mocked(loadProjectConfig).mockResolvedValueOnce({ + projects: [mockProject, { id: 'p2', repo: 'other/repo', pmType: 'trello' }], + fullProjects: [], + }); + + const result = await adapter.resolveProject({ + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + cascadeProjectId: 'p1', + } as never); + + expect(result).toEqual(mockProject); + }); + + it('returns null when no project matches cascadeProjectId', async () => { + vi.mocked(loadProjectConfig).mockResolvedValueOnce({ + projects: [mockProject], + fullProjects: [], + }); + + const result = await adapter.resolveProject({ + projectIdentifier: 'nonexistent', + eventType: 'event_alert', + isCommentEvent: false, + cascadeProjectId: 'nonexistent', + } as never); + + expect(result).toBeNull(); + }); + }); + + describe('dispatchWithCredentials', () => { + it('dispatches to trigger registry when full project found', async () => { + const mockTriggerResult = { + agentType: 'implementation', + workItemId: undefined, + prNumber: undefined, + }; + vi.mocked(mockTriggerRegistry.dispatch).mockResolvedValueOnce(mockTriggerResult); + + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + const result = await adapter.dispatchWithCredentials( + event, + validEventAlertPayload, + mockProject, + mockTriggerRegistry, + ); + + expect(mockTriggerRegistry.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'sentry', + project: mockFullProject, + payload: validEventAlertPayload, + }), + ); + expect(result).toEqual(mockTriggerResult); + }); + + it('returns null when full project is not found', async () => { + vi.mocked(loadProjectConfig).mockResolvedValueOnce({ + projects: [mockProject], + fullProjects: [], // no full project + }); + + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + const result = await adapter.dispatchWithCredentials( + event, + validEventAlertPayload, + mockProject, + mockTriggerRegistry, + ); + + expect(result).toBeNull(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'SentryRouterAdapter: no full project config found', + expect.objectContaining({ projectId: 'p1' }), + ); + }); + }); + + describe('postAck', () => { + it('returns undefined (no ack mechanism for Sentry)', async () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + + const result = await adapter.postAck(event, {}, mockProject, 'implementation'); + + expect(result).toBeUndefined(); + }); + }); + + describe('buildJob', () => { + it('builds a SentryJob with all required fields', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'event_alert', + isCommentEvent: false, + }; + const triggerResult = { + agentType: 'implementation', + workItemId: undefined, + prNumber: undefined, + }; + + const job = adapter.buildJob( + event, + validEventAlertPayload, + mockProject, + triggerResult, + ) as SentryJob; + + expect(job.type).toBe('sentry'); + expect(job.source).toBe('sentry'); + expect(job.projectId).toBe('p1'); + expect(job.eventType).toBe('event_alert'); + expect(job.payload).toEqual(validEventAlertPayload); + expect(job.triggerResult).toEqual(triggerResult); + expect(typeof job.receivedAt).toBe('string'); + }); + + it('sets receivedAt to a valid ISO string', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'metric_alert', + isCommentEvent: false, + }; + const triggerResult = { agentType: 'implementation' }; + + const job = adapter.buildJob( + event, + validMetricAlertPayload, + mockProject, + triggerResult as never, + ) as SentryJob; + + expect(() => new Date(job.receivedAt)).not.toThrow(); + expect(new Date(job.receivedAt).getFullYear()).toBeGreaterThan(2020); + }); + + it('uses event.eventType in the job', () => { + const event = { + projectIdentifier: 'p1', + eventType: 'metric_alert', + isCommentEvent: false, + }; + + const job = adapter.buildJob(event, validMetricAlertPayload, mockProject, { + agentType: 'implementation', + } as never) as SentryJob; + + expect(job.eventType).toBe('metric_alert'); + }); + }); +}); diff --git a/tests/unit/sentry/client.test.ts b/tests/unit/sentry/client.test.ts new file mode 100644 index 00000000..afd8cc54 --- /dev/null +++ b/tests/unit/sentry/client.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSentryClient } from '../../../src/sentry/client.js'; + +describe('sentry/client', () => { + describe('getSentryClient factory', () => { + it('throws when SENTRY_API_TOKEN is not set', () => { + vi.stubEnv('SENTRY_API_TOKEN', ''); + + expect(() => getSentryClient()).toThrow('SENTRY_API_TOKEN environment variable is not set'); + }); + + it('throws with empty SENTRY_API_TOKEN', () => { + vi.stubEnv('SENTRY_API_TOKEN', ''); + + expect(() => getSentryClient()).toThrow('SENTRY_API_TOKEN environment variable is not set'); + }); + + it('returns a client when SENTRY_API_TOKEN is set', () => { + vi.stubEnv('SENTRY_API_TOKEN', 'test-token-123'); + + const client = getSentryClient(); + + expect(client).toBeDefined(); + expect(typeof client.getIssue).toBe('function'); + expect(typeof client.getIssueEvent).toBe('function'); + expect(typeof client.listIssueEvents).toBe('function'); + }); + }); + + describe('getIssue', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + vi.stubEnv('SENTRY_API_TOKEN', 'test-token'); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + it('makes GET request to correct URL', async () => { + const mockIssue = { id: 'issue-1', title: 'TypeError', status: 'unresolved' }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockIssue, + }); + + const client = getSentryClient(); + const result = await client.getIssue('my-org', 'issue-1'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/my-org/issues/issue-1/', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'Content-Type': 'application/json', + }), + }), + ); + expect(result).toEqual(mockIssue); + }); + + it('encodes organization slug in URL', async () => { + const mockIssue = { id: 'issue-1', title: 'Error' }; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockIssue, + }); + + const client = getSentryClient(); + await client.getIssue('my org/company', 'issue-1'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('my%20org%2Fcompany'); + }); + + it('encodes issue id in URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'issue with spaces' }), + }); + + const client = getSentryClient(); + await client.getIssue('my-org', 'issue with spaces'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('issue%20with%20spaces'); + }); + + it('throws on non-OK response with status and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => 'Forbidden', + }); + + const client = getSentryClient(); + await expect(client.getIssue('my-org', 'issue-1')).rejects.toThrow( + 'Sentry API error 403: Forbidden', + ); + }); + + it('throws on 404 not found', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: async () => 'Not Found', + }); + + const client = getSentryClient(); + await expect(client.getIssue('my-org', 'nonexistent')).rejects.toThrow( + 'Sentry API error 404', + ); + }); + + it('handles empty error body gracefully', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => { + throw new Error('failed to read body'); + }, + }); + + const client = getSentryClient(); + await expect(client.getIssue('my-org', 'issue-1')).rejects.toThrow('Sentry API error 500: '); + }); + }); + + describe('getIssueEvent', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + vi.stubEnv('SENTRY_API_TOKEN', 'test-token'); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + it('uses "latest" as default eventId', async () => { + const mockEvent = { id: 'evt-1', type: 'error' }; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockEvent }); + + const client = getSentryClient(); + await client.getIssueEvent('my-org', 'issue-1'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/events/latest/'); + }); + + it('uses custom eventId when provided', async () => { + const mockEvent = { id: 'evt-specific', type: 'error' }; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockEvent }); + + const client = getSentryClient(); + await client.getIssueEvent('my-org', 'issue-1', 'oldest'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/events/oldest/'); + }); + + it('uses recommended as eventId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: 'evt-rec' }), + }); + + const client = getSentryClient(); + await client.getIssueEvent('my-org', 'issue-1', 'recommended'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/events/recommended/'); + }); + + it('constructs correct URL structure', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + const client = getSentryClient(); + await client.getIssueEvent('test-org', 'issue-42', 'latest'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://sentry.io/api/0/organizations/test-org/issues/issue-42/events/latest/', + expect.anything(), + ); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + }); + + const client = getSentryClient(); + await expect(client.getIssueEvent('my-org', 'issue-1')).rejects.toThrow( + 'Sentry API error 401', + ); + }); + }); + + describe('listIssueEvents', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + vi.stubEnv('SENTRY_API_TOKEN', 'test-token'); + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + it('calls correct base URL without options', async () => { + const mockEvents = [{ id: 'evt-1' }, { id: 'evt-2' }]; + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockEvents }); + + const client = getSentryClient(); + const result = await client.listIssueEvents('my-org', 'issue-1'); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('/organizations/my-org/issues/issue-1/events/'); + expect(calledUrl).not.toContain('?'); + expect(result).toEqual(mockEvents); + }); + + it('appends limit query param when provided', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }); + + const client = getSentryClient(); + await client.listIssueEvents('my-org', 'issue-1', { limit: 5 }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('limit=5'); + }); + + it('appends full=true when provided', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }); + + const client = getSentryClient(); + await client.listIssueEvents('my-org', 'issue-1', { full: true }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('full=true'); + }); + + it('appends both limit and full when provided', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }); + + const client = getSentryClient(); + await client.listIssueEvents('my-org', 'issue-1', { limit: 10, full: true }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('limit=10'); + expect(calledUrl).toContain('full=true'); + }); + + it('throws on error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => 'Too Many Requests', + }); + + const client = getSentryClient(); + await expect(client.listIssueEvents('my-org', 'issue-1')).rejects.toThrow( + 'Sentry API error 429', + ); + }); + }); +}); diff --git a/tests/unit/sentry/integration.test.ts b/tests/unit/sentry/integration.test.ts new file mode 100644 index 00000000..cec38e40 --- /dev/null +++ b/tests/unit/sentry/integration.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/db/repositories/integrationsRepository.js', () => ({ + getIntegrationByProjectAndCategory: vi.fn(), +})); + +import { getIntegrationByProjectAndCategory } from '../../../src/db/repositories/integrationsRepository.js'; +import { + getSentryIntegrationConfig, + hasAlertingIntegration, +} from '../../../src/sentry/integration.js'; + +const mockGetIntegrationByProjectAndCategory = vi.mocked(getIntegrationByProjectAndCategory); + +describe('sentry/integration', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('getSentryIntegrationConfig', () => { + it('returns null when no integration exists for the project', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('proj-1', 'alerting'); + }); + + it('returns null when integration has wrong provider (not sentry)', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'pagerduty', + config: { organizationSlug: 'my-org' }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when config is missing organizationSlug', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { someOtherField: 'value' }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when organizationSlug is not a string', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 12345 }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns null when config is null', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: null, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toBeNull(); + }); + + it('returns SentryIntegrationConfig when valid sentry integration exists', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 'my-company' }, + }); + + const result = await getSentryIntegrationConfig('proj-1'); + + expect(result).toEqual({ organizationSlug: 'my-company' }); + }); + + it('returns correct organizationSlug from config', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-2', + provider: 'sentry', + config: { organizationSlug: 'acme-corp', extraField: 'ignored' }, + }); + + const result = await getSentryIntegrationConfig('proj-2'); + + expect(result).toEqual({ organizationSlug: 'acme-corp' }); + }); + + it('queries using projectId and alerting category', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); + + await getSentryIntegrationConfig('specific-proj-id'); + + expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith( + 'specific-proj-id', + 'alerting', + ); + }); + }); + + describe('hasAlertingIntegration', () => { + it('returns true when sentry integration is configured', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 'my-org' }, + }); + + const result = await hasAlertingIntegration('proj-1'); + + expect(result).toBe(true); + }); + + it('returns false when no integration exists', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce(null); + + const result = await hasAlertingIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns false when integration has wrong provider', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'pagerduty', + config: { organizationSlug: 'my-org' }, + }); + + const result = await hasAlertingIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('returns false when integration config is missing organizationSlug', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: {}, + }); + + const result = await hasAlertingIntegration('proj-1'); + + expect(result).toBe(false); + }); + + it('delegates to getSentryIntegrationConfig (not null => true)', async () => { + mockGetIntegrationByProjectAndCategory.mockResolvedValueOnce({ + id: 'int-1', + provider: 'sentry', + config: { organizationSlug: 'org-slug' }, + }); + + const result = await hasAlertingIntegration('proj-with-sentry'); + + expect(result).toBe(true); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index c10474e1..ac0ad67e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -123,6 +123,7 @@ export default defineConfig({ 'tests/unit/integration-helpers/**/*.test.ts', 'tests/unit/tools/**/*.test.ts', 'tests/unit/openrouter/**/*.test.ts', + 'tests/unit/sentry/**/*.test.ts', 'tests/unit/*.test.ts', ], ...sharedTest,