From 71a3a4bb17df3692cee49547248e832dfbda244b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Feb 2026 21:11:58 +0000 Subject: [PATCH] test(triggers): add unit tests for github trigger utilities and agent result handler --- .../triggers/agent-result-handler.test.ts | 238 ++++++++++++++++++ tests/unit/triggers/github-utils.test.ts | 173 +++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 tests/unit/triggers/agent-result-handler.test.ts create mode 100644 tests/unit/triggers/github-utils.test.ts diff --git a/tests/unit/triggers/agent-result-handler.test.ts b/tests/unit/triggers/agent-result-handler.test.ts new file mode 100644 index 00000000..9abda72e --- /dev/null +++ b/tests/unit/triggers/agent-result-handler.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), +})); + +vi.mock('../../../src/utils/safeOperation.js', () => ({ + safeOperation: vi.fn((fn) => fn()), +})); + +import type { PMProvider } from '../../../src/pm/index.js'; +import { getPMProvider } from '../../../src/pm/index.js'; +import { handleAgentResultArtifacts } from '../../../src/triggers/shared/agent-result-handler.js'; +import type { AgentResult, ProjectConfig } from '../../../src/types/index.js'; + +const mockPMProvider = { + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), +}; + +vi.mocked(getPMProvider).mockReturnValue(mockPMProvider as unknown as PMProvider); + +const mockTrelloProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + customFields: { cost: 'cf-cost-123' }, + }, +}; + +const mockJiraProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + host: 'example.atlassian.net', + projectKey: 'TEST', + customFields: { cost: 'cf-jira-cost-456' }, + }, +}; + +describe('handleAgentResultArtifacts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('updates cost custom field with accumulation', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(2.5); + + const agentResult: AgentResult = { + success: true, + cost: 1.75, + sessionId: 'session-123', + }; + + await handleAgentResultArtifacts('card123', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.getCustomFieldNumber).toHaveBeenCalledWith('card123', 'cf-cost-123'); + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card123', + 'cf-cost-123', + 4.25, + ); + }); + + it('handles zero current cost', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(0); + + const agentResult: AgentResult = { + success: true, + cost: 3.5, + sessionId: 'session-456', + }; + + await handleAgentResultArtifacts('card456', 'review', agentResult, mockTrelloProject); + + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card456', + 'cf-cost-123', + 3.5, + ); + }); + + it('rounds accumulated cost to 4 decimal places', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(1.123456); + + const agentResult: AgentResult = { + success: true, + cost: 0.987654, + sessionId: 'session-789', + }; + + await handleAgentResultArtifacts('card789', 'implementation', agentResult, mockTrelloProject); + + // 1.123456 + 0.987654 = 2.11111, rounded to 4 decimals = 2.1111 + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card789', + 'cf-cost-123', + 2.1111, + ); + }); + + it('skips when no cost field configured (Trello)', async () => { + const projectNoCostField: ProjectConfig = { + ...mockTrelloProject, + trello: mockTrelloProject.trello + ? { + ...mockTrelloProject.trello, + customFields: {}, + } + : undefined, + }; + + const agentResult: AgentResult = { + success: true, + cost: 1.5, + sessionId: 'session-abc', + }; + + await handleAgentResultArtifacts( + 'card-no-field', + 'implementation', + agentResult, + projectNoCostField, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when no cost field configured (JIRA)', async () => { + const projectNoCostField: ProjectConfig = { + ...mockJiraProject, + jira: mockJiraProject.jira + ? { + ...mockJiraProject.jira, + customFields: {}, + } + : undefined, + }; + + const agentResult: AgentResult = { + success: true, + cost: 2.0, + sessionId: 'session-def', + }; + + await handleAgentResultArtifacts( + 'issue-no-field', + 'implementation', + agentResult, + projectNoCostField, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when cost is zero', async () => { + const agentResult: AgentResult = { + success: true, + cost: 0, + sessionId: 'session-zero', + }; + + await handleAgentResultArtifacts('card-zero', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('skips when cost is undefined', async () => { + const agentResult: AgentResult = { + success: true, + sessionId: 'session-undef', + }; + + await handleAgentResultArtifacts( + 'card-undef', + 'implementation', + agentResult, + mockTrelloProject, + ); + + expect(mockPMProvider.getCustomFieldNumber).not.toHaveBeenCalled(); + expect(mockPMProvider.updateCustomFieldNumber).not.toHaveBeenCalled(); + }); + + it('uses JIRA cost field for JIRA projects', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(1.0); + + const agentResult: AgentResult = { + success: true, + cost: 0.5, + sessionId: 'session-jira', + }; + + await handleAgentResultArtifacts('PROJ-123', 'implementation', agentResult, mockJiraProject); + + expect(mockPMProvider.getCustomFieldNumber).toHaveBeenCalledWith( + 'PROJ-123', + 'cf-jira-cost-456', + ); + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'PROJ-123', + 'cf-jira-cost-456', + 1.5, + ); + }); + + it('handles failed agent results with cost', async () => { + mockPMProvider.getCustomFieldNumber.mockResolvedValue(0.5); + + const agentResult: AgentResult = { + success: false, + error: 'Something went wrong', + cost: 0.25, + sessionId: 'session-failed', + }; + + await handleAgentResultArtifacts('card-fail', 'implementation', agentResult, mockTrelloProject); + + expect(mockPMProvider.updateCustomFieldNumber).toHaveBeenCalledWith( + 'card-fail', + 'cf-cost-123', + 0.75, + ); + }); +}); diff --git a/tests/unit/triggers/github-utils.test.ts b/tests/unit/triggers/github-utils.test.ts new file mode 100644 index 00000000..5d46b4b3 --- /dev/null +++ b/tests/unit/triggers/github-utils.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { + extractJiraIssueKey, + extractTrelloCardId, + extractWorkItemId, + hasTrelloCardUrl, + requireWorkItemId, +} from '../../../src/triggers/github/utils.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +const mockTrelloProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + lists: {}, + labels: {}, + }, +}; + +const mockJiraProject: ProjectConfig = { + id: 'test', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + host: 'example.atlassian.net', + projectKey: 'TEST', + }, +}; + +describe('extractTrelloCardId', () => { + it('returns null for null input', () => { + expect(extractTrelloCardId(null)).toBeNull(); + }); + + it('returns null for text with no URL', () => { + expect(extractTrelloCardId('Just some regular text')).toBeNull(); + }); + + it('extracts card ID from valid Trello URL', () => { + const text = 'Implements https://trello.com/c/abc123/card-name'; + expect(extractTrelloCardId(text)).toBe('abc123'); + }); + + it('extracts card ID from URL without slug', () => { + const text = 'See https://trello.com/c/xyz789'; + expect(extractTrelloCardId(text)).toBe('xyz789'); + }); + + it('returns first card ID when multiple URLs present', () => { + const text = + 'https://trello.com/c/first123/card-one and https://trello.com/c/second456/card-two'; + expect(extractTrelloCardId(text)).toBe('first123'); + }); + + it('handles URLs with alphanumeric IDs', () => { + const text = 'https://trello.com/c/AbC123DeF/my-card'; + expect(extractTrelloCardId(text)).toBe('AbC123DeF'); + }); +}); + +describe('hasTrelloCardUrl', () => { + it('returns false for null input', () => { + expect(hasTrelloCardUrl(null)).toBe(false); + }); + + it('returns false for text without URL', () => { + expect(hasTrelloCardUrl('No URL here')).toBe(false); + }); + + it('returns true for text with Trello URL', () => { + expect(hasTrelloCardUrl('https://trello.com/c/abc123/card')).toBe(true); + }); + + it('returns true for partial match in longer text', () => { + expect(hasTrelloCardUrl('Check out this card: https://trello.com/c/xyz789')).toBe(true); + }); +}); + +describe('extractJiraIssueKey', () => { + it('returns null for null input', () => { + expect(extractJiraIssueKey(null)).toBeNull(); + }); + + it('returns null when no key found', () => { + expect(extractJiraIssueKey('Just some text without a key')).toBeNull(); + }); + + it('extracts valid JIRA key', () => { + expect(extractJiraIssueKey('PROJ-123')).toBe('PROJ-123'); + }); + + it('extracts key embedded in longer text', () => { + const text = 'This fixes PROJ-456 by updating the logic'; + expect(extractJiraIssueKey(text)).toBe('PROJ-456'); + }); + + it('extracts key with multiple characters in project code', () => { + expect(extractJiraIssueKey('TEST-999')).toBe('TEST-999'); + }); + + it('extracts key with alphanumeric project code', () => { + expect(extractJiraIssueKey('AB12-345')).toBe('AB12-345'); + }); + + it('requires word boundaries around key', () => { + // Should not match partial strings + expect(extractJiraIssueKey('NOTAKEY-123-MORE')).toBe('NOTAKEY-123'); + }); + + it('returns first key when multiple present', () => { + const text = 'Relates to PROJ-111 and PROJ-222'; + expect(extractJiraIssueKey(text)).toBe('PROJ-111'); + }); +}); + +describe('extractWorkItemId', () => { + it('returns null for null input', () => { + expect(extractWorkItemId(null, mockTrelloProject)).toBeNull(); + }); + + it('delegates to Trello extraction for Trello projects', () => { + const text = 'https://trello.com/c/abc123/card'; + expect(extractWorkItemId(text, mockTrelloProject)).toBe('abc123'); + }); + + it('delegates to JIRA extraction for JIRA projects', () => { + const text = 'Fixes PROJ-456'; + expect(extractWorkItemId(text, mockJiraProject)).toBe('PROJ-456'); + }); + + it('returns null for Trello project without Trello URL', () => { + const text = 'Just regular text'; + expect(extractWorkItemId(text, mockTrelloProject)).toBeNull(); + }); + + it('returns null for JIRA project without JIRA key', () => { + const text = 'Just regular text'; + expect(extractWorkItemId(text, mockJiraProject)).toBeNull(); + }); +}); + +describe('requireWorkItemId', () => { + const context = { prNumber: 42, triggerName: 'test-trigger' }; + + it('returns null when no ID found', () => { + const result = requireWorkItemId('No work item reference', mockTrelloProject, context); + expect(result).toBeNull(); + }); + + it('returns ID when present in Trello project', () => { + const text = 'Implements https://trello.com/c/abc123/card'; + const result = requireWorkItemId(text, mockTrelloProject, context); + expect(result).toBe('abc123'); + }); + + it('returns ID when present in JIRA project', () => { + const text = 'Fixes PROJ-789'; + const result = requireWorkItemId(text, mockJiraProject, context); + expect(result).toBe('PROJ-789'); + }); + + it('returns null for null input', () => { + const result = requireWorkItemId(null, mockTrelloProject, context); + expect(result).toBeNull(); + }); +});