From 75a1c884a8003f49041c5c87a2275a7d27acb90e Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 16 Feb 2026 20:45:38 +0000 Subject: [PATCH] test(pm): add comprehensive tests for PM lifecycle, factory, and context modules --- tests/unit/pm/context.test.ts | 183 ++++++++++++++++ tests/unit/pm/factory.test.ts | 128 ++++++++++++ tests/unit/pm/lifecycle.test.ts | 355 ++++++++++++++++++++++++++++++++ 3 files changed, 666 insertions(+) create mode 100644 tests/unit/pm/context.test.ts create mode 100644 tests/unit/pm/factory.test.ts create mode 100644 tests/unit/pm/lifecycle.test.ts diff --git a/tests/unit/pm/context.test.ts b/tests/unit/pm/context.test.ts new file mode 100644 index 00000000..fd9b95d5 --- /dev/null +++ b/tests/unit/pm/context.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getPMProvider, getPMProviderOrNull, withPMProvider } from '../../../src/pm/context.js'; +import type { PMProvider } from '../../../src/pm/types.js'; + +describe('pm/context', () => { + // Create a minimal mock provider for testing + const createMockProvider = (): PMProvider => ({ + type: 'trello', + addLabel: vi.fn(), + removeLabel: vi.fn(), + moveWorkItem: vi.fn(), + addComment: vi.fn(), + getWorkItem: vi.fn(), + getWorkItemComments: vi.fn(), + updateWorkItem: vi.fn(), + createWorkItem: vi.fn(), + listWorkItems: vi.fn(), + getChecklists: vi.fn(), + createChecklist: vi.fn(), + addChecklistItem: vi.fn(), + updateChecklistItem: vi.fn(), + getAttachments: vi.fn(), + addAttachment: vi.fn(), + addAttachmentFile: vi.fn(), + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), + getWorkItemUrl: vi.fn(), + getAuthenticatedUser: vi.fn(), + }); + + describe('withPMProvider', () => { + it('makes provider available within the async context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProvider(); + expect(retrieved).toBe(provider); + }); + }); + + it('isolates provider scope between concurrent calls', async () => { + const provider1 = createMockProvider(); + const provider2 = createMockProvider(); + + // Run two contexts concurrently + const [result1, result2] = await Promise.all([ + withPMProvider(provider1, async () => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 10)); + return getPMProvider(); + }), + withPMProvider(provider2, async () => { + // Simulate async work + await new Promise((resolve) => setTimeout(resolve, 5)); + return getPMProvider(); + }), + ]); + + // Each context should see its own provider + expect(result1).toBe(provider1); + expect(result2).toBe(provider2); + }); + + it('removes provider from context after callback completes', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + expect(getPMProvider()).toBe(provider); + }); + + // Provider should not be available outside the context + expect(() => getPMProvider()).toThrow(); + }); + + it('propagates errors from callback', async () => { + const provider = createMockProvider(); + const error = new Error('Callback failed'); + + await expect( + withPMProvider(provider, async () => { + throw error; + }), + ).rejects.toThrow('Callback failed'); + }); + + it('returns the callback result', async () => { + const provider = createMockProvider(); + + const result = await withPMProvider(provider, async () => { + return { success: true, data: 'test' }; + }); + + expect(result).toEqual({ success: true, data: 'test' }); + }); + }); + + describe('getPMProvider', () => { + it('returns provider when in context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProvider(); + expect(retrieved).toBe(provider); + }); + }); + + it('throws error when not in context', () => { + expect(() => getPMProvider()).toThrow( + 'No PMProvider in scope. Wrap the call with withPMProvider() or ensure the webhook handler has established a PM context.', + ); + }); + + it('throws error with helpful message', () => { + try { + getPMProvider(); + // Should not reach here + expect(true).toBe(false); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('withPMProvider()'); + expect((error as Error).message).toContain('webhook handler'); + } + }); + }); + + describe('getPMProviderOrNull', () => { + it('returns provider when in context', async () => { + const provider = createMockProvider(); + + await withPMProvider(provider, async () => { + const retrieved = getPMProviderOrNull(); + expect(retrieved).toBe(provider); + }); + }); + + it('returns null when not in context', () => { + const result = getPMProviderOrNull(); + expect(result).toBeNull(); + }); + + it('does not throw error when not in context', () => { + expect(() => getPMProviderOrNull()).not.toThrow(); + }); + }); + + describe('nested contexts', () => { + it('inner context overrides outer context', async () => { + const outerProvider = createMockProvider(); + const innerProvider = createMockProvider(); + + await withPMProvider(outerProvider, async () => { + expect(getPMProvider()).toBe(outerProvider); + + await withPMProvider(innerProvider, async () => { + expect(getPMProvider()).toBe(innerProvider); + }); + + // After inner context, outer provider is restored + expect(getPMProvider()).toBe(outerProvider); + }); + }); + + it('handles errors in nested contexts without affecting outer context', async () => { + const outerProvider = createMockProvider(); + const innerProvider = createMockProvider(); + + await withPMProvider(outerProvider, async () => { + expect(getPMProvider()).toBe(outerProvider); + + try { + await withPMProvider(innerProvider, async () => { + throw new Error('Inner error'); + }); + } catch (error) { + // Expected error + } + + // Outer context should still be valid + expect(getPMProvider()).toBe(outerProvider); + }); + }); + }); +}); diff --git a/tests/unit/pm/factory.test.ts b/tests/unit/pm/factory.test.ts new file mode 100644 index 00000000..09e625d1 --- /dev/null +++ b/tests/unit/pm/factory.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createPMProvider } from '../../../src/pm/factory.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +// Mock the adapters +vi.mock('../../../src/pm/trello/adapter.js', () => ({ + TrelloPMProvider: vi.fn().mockImplementation(() => ({ + type: 'trello', + addLabel: vi.fn(), + removeLabel: vi.fn(), + })), +})); + +vi.mock('../../../src/pm/jira/adapter.js', () => ({ + JiraPMProvider: vi.fn().mockImplementation((config) => ({ + type: 'jira', + config, + addLabel: vi.fn(), + removeLabel: vi.fn(), + })), +})); + +import { JiraPMProvider } from '../../../src/pm/jira/adapter.js'; +import { TrelloPMProvider } from '../../../src/pm/trello/adapter.js'; + +describe('pm/factory', () => { + describe('createPMProvider', () => { + it('returns TrelloPMProvider when pm.type is trello', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const provider = createPMProvider(project); + + expect(TrelloPMProvider).toHaveBeenCalled(); + expect(provider.type).toBe('trello'); + }); + + it('returns TrelloPMProvider when pm.type is undefined (defaults to trello)', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Default Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const provider = createPMProvider(project); + + expect(TrelloPMProvider).toHaveBeenCalled(); + expect(provider.type).toBe('trello'); + }); + + it('returns JiraPMProvider when pm.type is jira', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }, + }; + + const provider = createPMProvider(project); + + expect(JiraPMProvider).toHaveBeenCalledWith(project.jira); + expect(provider.type).toBe('jira'); + }); + + it('throws error when pm.type is jira but jira config is missing', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Invalid JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + // No jira config + }; + + expect(() => createPMProvider(project)).toThrow( + "Project 'proj1' has pm.type=jira but no jira config", + ); + }); + + it('throws error for unknown pm.type', () => { + const project = { + id: 'proj1', + orgId: 'org1', + name: 'Unknown PM Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'unknown' }, + } as ProjectConfig; + + expect(() => createPMProvider(project)).toThrow('Unknown PM type: unknown'); + }); + }); +}); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts new file mode 100644 index 00000000..f9391a9f --- /dev/null +++ b/tests/unit/pm/lifecycle.test.ts @@ -0,0 +1,355 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + PMLifecycleManager, + type ProjectPMConfig, + resolveProjectPMConfig, +} from '../../../src/pm/lifecycle.js'; +import type { PMProvider } from '../../../src/pm/types.js'; +import type { ProjectConfig } from '../../../src/types/index.js'; + +// Mock safeOperation utilities +vi.mock('../../../src/utils/safeOperation.js', () => ({ + safeOperation: vi.fn((fn) => fn()), + silentOperation: vi.fn((fn) => fn()), +})); + +describe('pm/lifecycle', () => { + describe('resolveProjectPMConfig', () => { + it('returns JIRA config when project type is jira', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'JIRA Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'jira' }, + jira: { + projectKey: 'PROJ', + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', + }, + statuses: { + inProgress: 'In Progress', + inReview: 'Code Review', + done: 'Done', + merged: 'Merged', + }, + }); + }); + + it('returns Trello config when project type is trello', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Trello Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + pm: { type: 'trello' }, + trello: { + boardId: 'board123', + labels: { + processing: 'label-proc-id', + processed: 'label-done-id', + error: 'label-err-id', + readyToProcess: 'label-ready-id', + }, + lists: { + todo: 'list-todo-id', + inProgress: 'list-progress-id', + inReview: 'list-review-id', + done: 'list-done-id', + merged: 'list-merged-id', + }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: 'label-proc-id', + processed: 'label-done-id', + error: 'label-err-id', + readyToProcess: 'label-ready-id', + }, + statuses: { + inProgress: 'list-progress-id', + inReview: 'list-review-id', + done: 'list-done-id', + merged: 'list-merged-id', + }, + }); + }); + + it('defaults to Trello config when pm.type is undefined', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Default Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: { processing: 'label-id' }, + lists: { todo: 'list-id' }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config.labels.processing).toBe('label-id'); + }); + + it('handles missing optional Trello labels and lists', () => { + const project: ProjectConfig = { + id: 'proj1', + orgId: 'org1', + name: 'Partial Project', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board123', + labels: {}, + lists: { todo: 'list-id' }, + }, + }; + + const config = resolveProjectPMConfig(project); + + expect(config).toEqual({ + labels: { + processing: undefined, + processed: undefined, + error: undefined, + readyToProcess: undefined, + }, + statuses: { + inProgress: undefined, + inReview: undefined, + done: undefined, + merged: undefined, + }, + }); + }); + }); + + describe('PMLifecycleManager', () => { + let mockProvider: PMProvider; + let pmConfig: ProjectPMConfig; + let manager: PMLifecycleManager; + + beforeEach(() => { + // Create mock provider with all required methods + mockProvider = { + type: 'trello', + addLabel: vi.fn().mockResolvedValue(undefined), + removeLabel: vi.fn().mockResolvedValue(undefined), + moveWorkItem: vi.fn().mockResolvedValue(undefined), + addComment: vi.fn().mockResolvedValue(undefined), + // Other PMProvider methods (not used by lifecycle manager) + getWorkItem: vi.fn(), + getWorkItemComments: vi.fn(), + updateWorkItem: vi.fn(), + createWorkItem: vi.fn(), + listWorkItems: vi.fn(), + getChecklists: vi.fn(), + createChecklist: vi.fn(), + addChecklistItem: vi.fn(), + updateChecklistItem: vi.fn(), + getAttachments: vi.fn(), + addAttachment: vi.fn(), + addAttachmentFile: vi.fn(), + getCustomFieldNumber: vi.fn(), + updateCustomFieldNumber: vi.fn(), + getWorkItemUrl: vi.fn(), + getAuthenticatedUser: vi.fn(), + }; + + pmConfig = { + labels: { + processing: 'label-proc', + processed: 'label-done', + error: 'label-error', + readyToProcess: 'label-ready', + }, + statuses: { + inProgress: 'list-progress', + inReview: 'list-review', + done: 'list-done', + merged: 'list-merged', + }, + }; + + manager = new PMLifecycleManager(mockProvider, pmConfig); + }); + + describe('prepareForAgent', () => { + it('adds processing label and removes ready/processed labels', async () => { + await manager.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-ready'); + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + }); + + it('moves to inProgress status when agentType is implementation', async () => { + await manager.prepareForAgent('work-item-1', 'implementation'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-progress'); + }); + + it('does not move work item for non-implementation agents', async () => { + await manager.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + }); + + it('skips operations when labels are undefined', async () => { + const managerNoLabels = new PMLifecycleManager(mockProvider, { + labels: {}, + statuses: {}, + }); + + await managerNoLabels.prepareForAgent('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).not.toHaveBeenCalled(); + expect(mockProvider.removeLabel).not.toHaveBeenCalled(); + }); + }); + + describe('handleSuccess', () => { + it('adds processed label', async () => { + await manager.handleSuccess('work-item-1', 'briefing'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + }); + + it('moves to inReview status when agentType is implementation', async () => { + await manager.handleSuccess('work-item-1', 'implementation'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-review'); + }); + + it('adds PR comment when prUrl is provided for implementation agent', async () => { + await manager.handleSuccess('work-item-1', 'implementation', 'https://github.com/pr/123'); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + 'PR created: https://github.com/pr/123', + ); + }); + + it('does not add PR comment when prUrl is not provided', async () => { + await manager.handleSuccess('work-item-1', 'implementation'); + + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('does not move work item for non-implementation agents', async () => { + await manager.handleSuccess('work-item-1', 'briefing'); + + expect(mockProvider.moveWorkItem).not.toHaveBeenCalled(); + }); + }); + + describe('handleFailure', () => { + it('adds error label', async () => { + await manager.handleFailure('work-item-1'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds error comment when error message is provided', async () => { + await manager.handleFailure('work-item-1', 'Something went wrong'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + '❌ Agent failed: Something went wrong', + ); + }); + + it('does not add comment when error message is not provided', async () => { + await manager.handleFailure('work-item-1'); + + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + }); + + describe('handleBudgetExceeded', () => { + it('removes processing label and adds error label', async () => { + await manager.handleBudgetExceeded('work-item-1', 5.5, 5.0); + + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds budget exceeded comment with formatted amounts', async () => { + await manager.handleBudgetExceeded('work-item-1', 5.678, 5.0); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + '⛔ Budget exceeded: cost $5.68 >= limit $5.00. Agent not started.', + ); + }); + }); + + describe('handleBudgetWarning', () => { + it('adds error label', async () => { + await manager.handleBudgetWarning('work-item-1', 4.95, 5.0); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + }); + + it('adds budget warning comment with formatted amounts', async () => { + await manager.handleBudgetWarning('work-item-1', 5.123, 5.0); + + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + '⚠️ Budget limit reached: cost $5.12 >= limit $5.00. Further agent runs will be blocked.', + ); + }); + }); + + describe('cleanupProcessing', () => { + it('removes processing label', async () => { + await manager.cleanupProcessing('work-item-1'); + + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + }); + }); + + describe('handleError', () => { + it('adds error label and error comment', async () => { + await manager.handleError('work-item-1', 'Database connection failed'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).toHaveBeenCalledWith( + 'work-item-1', + '❌ Error: Database connection failed', + ); + }); + }); + }); +});