diff --git a/tests/unit/agents/shared/promptContext.test.ts b/tests/unit/agents/shared/promptContext.test.ts new file mode 100644 index 00000000..024767a2 --- /dev/null +++ b/tests/unit/agents/shared/promptContext.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock getPMProvider to control the PM type +vi.mock('../../../../src/pm/index.js', () => ({ + getPMProvider: vi.fn(), +})); + +import { buildPromptContext } from '../../../../src/agents/shared/promptContext.js'; +import { getPMProvider } from '../../../../src/pm/index.js'; +import { createMockPMProvider } from '../../../helpers/mockPMProvider.js'; + +const mockGetPMProvider = vi.mocked(getPMProvider); + +function makeProject(overrides: Record = {}) { + return { + id: 'test-project', + name: 'Test Project', + repo: 'owner/repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + trello: { + boardId: 'board1', + lists: { + briefing: 'list1', + planning: 'list2', + todo: 'list3', + stories: 'list-stories', + debug: 'list-debug', + }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + ...overrides, + }; +} + +describe('buildPromptContext', () => { + describe('with Trello provider', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.type = 'trello'; + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('sets workItemNoun to "card" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNoun).toBe('card'); + }); + + it('sets workItemNounPlural to "cards" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounPlural).toBe('cards'); + }); + + it('sets workItemNounCap to "Card" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounCap).toBe('Card'); + }); + + it('sets workItemNounPluralCap to "Cards" for Trello', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.workItemNounPluralCap).toBe('Cards'); + }); + + it('sets pmName to "Trello"', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.pmName).toBe('Trello'); + }); + + it('sets pmType to "trello"', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.pmType).toBe('trello'); + }); + + it('generates cardUrl from provider', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.cardUrl).toBe('https://trello.com/c/card123'); + }); + + it('sets cardId from parameter', () => { + const ctx = buildPromptContext('card-abc', makeProject() as never); + expect(ctx.cardId).toBe('card-abc'); + }); + + it('includes storiesListId from project trello config', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.storiesListId).toBe('list-stories'); + }); + + it('includes processedLabelId from project trello config', () => { + const ctx = buildPromptContext('card123', makeProject() as never); + expect(ctx.processedLabelId).toBe('label2'); + }); + }); + + describe('with JIRA provider', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.type = 'jira' as never; + mockProvider.getWorkItemUrl = vi.fn( + (id: string) => `https://company.atlassian.net/browse/${id}`, + ); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('sets workItemNoun to "issue" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNoun).toBe('issue'); + }); + + it('sets workItemNounPlural to "issues" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounPlural).toBe('issues'); + }); + + it('sets workItemNounCap to "Issue" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounCap).toBe('Issue'); + }); + + it('sets workItemNounPluralCap to "Issues" for JIRA', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.workItemNounPluralCap).toBe('Issues'); + }); + + it('sets pmName to "JIRA"', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.pmName).toBe('JIRA'); + }); + + it('sets pmType to "jira"', () => { + const ctx = buildPromptContext('PROJ-123', makeProject() as never); + expect(ctx.pmType).toBe('jira'); + }); + }); + + describe('with prContext', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + const prContext = { + prNumber: 42, + prBranch: 'feature/my-branch', + repoFullName: 'owner/repo', + headSha: 'abc123def456', + }; + + it('includes prNumber', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.prNumber).toBe(42); + }); + + it('includes prBranch', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.prBranch).toBe('feature/my-branch'); + }); + + it('includes repoFullName', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.repoFullName).toBe('owner/repo'); + }); + + it('includes headSha', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.headSha).toBe('abc123def456'); + }); + + it('includes triggerType', () => { + const ctx = buildPromptContext('card1', makeProject() as never, 'check_suite', prContext); + expect(ctx.triggerType).toBe('check_suite'); + }); + }); + + describe('with debugContext', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn((id: string) => `https://trello.com/c/${id}`); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + const debugContext = { + logDir: '/tmp/logs/debug-session', + originalCardId: 'original-card-id', + originalCardName: 'My Feature Card', + originalCardUrl: 'https://trello.com/c/abc', + detectedAgentType: 'implementation', + }; + + it('includes logDir', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.logDir).toBe('/tmp/logs/debug-session'); + }); + + it('includes originalCardName', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.originalCardName).toBe('My Feature Card'); + }); + + it('includes originalCardUrl', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.originalCardUrl).toBe('https://trello.com/c/abc'); + }); + + it('includes detectedAgentType', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.detectedAgentType).toBe('implementation'); + }); + + it('includes debugListId from project trello config', () => { + const ctx = buildPromptContext( + undefined, + makeProject() as never, + undefined, + undefined, + debugContext, + ); + expect(ctx.debugListId).toBe('list-debug'); + }); + }); + + describe('without optional contexts', () => { + beforeEach(() => { + const mockProvider = createMockPMProvider(); + mockProvider.getWorkItemUrl = vi.fn(() => undefined); + mockGetPMProvider.mockReturnValue(mockProvider); + }); + + it('has undefined prNumber when no prContext', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.prNumber).toBeUndefined(); + }); + + it('has undefined logDir when no debugContext', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.logDir).toBeUndefined(); + }); + + it('handles undefined cardId', () => { + const ctx = buildPromptContext(undefined, makeProject() as never); + expect(ctx.cardId).toBeUndefined(); + expect(ctx.cardUrl).toBeUndefined(); + }); + + it('includes projectId from project', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.projectId).toBe('test-project'); + }); + + it('includes baseBranch from project', () => { + const ctx = buildPromptContext('card1', makeProject() as never); + expect(ctx.baseBranch).toBe('main'); + }); + }); +}); diff --git a/tests/unit/gadgets/fileInsertContent.test.ts b/tests/unit/gadgets/fileInsertContent.test.ts new file mode 100644 index 00000000..01d21bb7 --- /dev/null +++ b/tests/unit/gadgets/fileInsertContent.test.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock readTracking so we don't have to pre-mark files +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + assertFileRead: vi.fn(), // No-op — skip read guard + markFileRead: vi.fn(), + hasReadFile: vi.fn().mockReturnValue(true), + clearReadTracking: vi.fn(), + invalidateFileRead: vi.fn(), + hasListedDirectory: vi.fn().mockReturnValue(false), + markDirectoryListed: vi.fn(), +})); + +// Mock post-edit checks to avoid running tsc/biome +vi.mock('../../../src/gadgets/shared/postEditChecks.js', () => ({ + runPostEditChecks: vi.fn().mockReturnValue(null), +})); + +// Mock diagnosticState to avoid side effects +vi.mock('../../../src/gadgets/shared/diagnosticState.js', () => ({ + updateDiagnosticState: vi.fn(), + formatDiagnosticStatus: vi + .fn() + .mockReturnValue('## Diagnostic Status\n\n✅ All edited files pass type checking'), + runDiagnosticsWithTracking: vi.fn().mockReturnValue(null), + clearDiagnosticState: vi.fn(), + trackModifiedFile: vi.fn(), + getModifiedFiles: vi.fn().mockReturnValue([]), + clearModifiedFiles: vi.fn(), + recordEditFailure: vi.fn().mockReturnValue(1), + clearEditFailure: vi.fn(), + clearEditFailures: vi.fn(), + recordDiagnosticLoop: vi.fn().mockReturnValue(1), + clearDiagnosticLoop: vi.fn(), + getDiagnosticLoopFiles: vi.fn().mockReturnValue(new Map()), + hasAnyDiagnosticErrors: vi.fn().mockReturnValue(false), + getFilesWithErrors: vi.fn().mockReturnValue([]), +})); + +import { FileInsertContent } from '../../../src/gadgets/FileInsertContent.js'; + +let tmpDir: string; +let gadget: FileInsertContent; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cascade-test-insert-')); + gadget = new FileInsertContent(); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function createFile(name: string, content: string): string { + const filePath = join(tmpDir, name); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +describe('FileInsertContent', () => { + describe('insert before line', () => { + it('inserts content before line 1 (prepend)', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'before', + content: 'newline', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('newline\nline1\nline2\nline3\n'); + expect(result).toContain('Inserted 1 line before line 1'); + }); + + it('inserts content before a middle line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'before', + content: 'inserted', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\ninserted\nline2\nline3\n'); + }); + + it('appends at end when line exceeds file length with mode=before', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 999, + mode: 'before', + content: 'appended', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('appended'); + expect(result).toContain('Appended'); + }); + + it('inserts multiline content before a line', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'before', + content: 'newA\nnewB', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nnewA\nnewB\nline2\n'); + }); + }); + + describe('insert after line', () => { + it('inserts content after line 1', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'inserted', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\ninserted\nline2\nline3\n'); + }); + + it('appends at end when line >= line count', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 100, + mode: 'after', + content: 'appended', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('appended'); + expect(result).toContain('Appended'); + }); + + it('inserts multiline content after a line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'newA\nnewB', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nnewA\nnewB\nline2\nline3\n'); + }); + + it('returns output with status=success for non-TS files', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'new content', + }); + + expect(result).toContain('status=success'); + }); + }); + + describe('output format', () => { + it('output includes the file path', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 1, + mode: 'after', + content: 'new', + }); + + expect(result).toContain(`path=${filePath}`); + }); + + it('output contains context lines around the insertion point', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 2, + mode: 'after', + content: 'inserted', + }); + + expect(result).toContain('inserted'); + }); + }); + + describe('new file creation', () => { + it('creates a new file when it does not exist', () => { + const filePath = join(tmpDir, 'newfile.txt'); + + const result = gadget.execute({ + comment: 'test', + filePath, + line: 0, + mode: 'before', + content: 'first line', + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toContain('first line'); + expect(result).toContain('status=success'); + }); + }); +}); diff --git a/tests/unit/gadgets/fileRemoveContent.test.ts b/tests/unit/gadgets/fileRemoveContent.test.ts new file mode 100644 index 00000000..e71c17f8 --- /dev/null +++ b/tests/unit/gadgets/fileRemoveContent.test.ts @@ -0,0 +1,240 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock readTracking so we don't have to pre-mark files +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + assertFileRead: vi.fn(), // No-op — skip read guard + markFileRead: vi.fn(), + hasReadFile: vi.fn().mockReturnValue(true), + clearReadTracking: vi.fn(), + invalidateFileRead: vi.fn(), + hasListedDirectory: vi.fn().mockReturnValue(false), + markDirectoryListed: vi.fn(), +})); + +// Mock post-edit checks to avoid running tsc/biome +vi.mock('../../../src/gadgets/shared/postEditChecks.js', () => ({ + runPostEditChecks: vi.fn().mockReturnValue(null), +})); + +// Mock diagnosticState to avoid side effects +vi.mock('../../../src/gadgets/shared/diagnosticState.js', () => ({ + updateDiagnosticState: vi.fn(), + formatDiagnosticStatus: vi + .fn() + .mockReturnValue('## Diagnostic Status\n\n✅ All edited files pass type checking'), + runDiagnosticsWithTracking: vi.fn().mockReturnValue(null), + clearDiagnosticState: vi.fn(), + trackModifiedFile: vi.fn(), + getModifiedFiles: vi.fn().mockReturnValue([]), + clearModifiedFiles: vi.fn(), + recordEditFailure: vi.fn().mockReturnValue(1), + clearEditFailure: vi.fn(), + clearEditFailures: vi.fn(), + recordDiagnosticLoop: vi.fn().mockReturnValue(1), + clearDiagnosticLoop: vi.fn(), + getDiagnosticLoopFiles: vi.fn().mockReturnValue(new Map()), + hasAnyDiagnosticErrors: vi.fn().mockReturnValue(false), + getFilesWithErrors: vi.fn().mockReturnValue([]), +})); + +import { FileRemoveContent } from '../../../src/gadgets/FileRemoveContent.js'; + +let tmpDir: string; +let gadget: FileRemoveContent; + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cascade-test-remove-')); + gadget = new FileRemoveContent(); +}); + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function createFile(name: string, content: string): string { + const filePath = join(tmpDir, name); + writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +describe('FileRemoveContent', () => { + describe('remove single line', () => { + it('removes a single line from the file', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline3\n'); + expect(result).toContain('Removed 1 line (line 2)'); + }); + + it('removes the first line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line2\nline3\n'); + }); + + it('removes the last line', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 3, + endLine: 3, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline2'); + }); + }); + + describe('remove range of lines', () => { + it('removes a range of lines', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\nline4\nline5\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 4, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe('line1\nline5\n'); + expect(result).toContain('Removed 3 lines (lines 2-4)'); + }); + + it('removes all lines when range covers entire file', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 2, + }); + + const written = readFileSync(filePath, 'utf-8'); + expect(written).toBe(''); + }); + + it('clamps endLine to file length when exceeding', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 100, + }); + + const written = readFileSync(filePath, 'utf-8'); + // After removing lines 2+ from 'line1\nline2\nline3\n', only line1 remains + expect(written).toContain('line1'); + expect(written).not.toContain('line2'); + expect(result).toContain('Removed'); + }); + }); + + describe('output format', () => { + it('output includes BEFORE section', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + expect(result).toContain('--- BEFORE ---'); + }); + + it('output includes AFTER section', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 2, + endLine: 2, + }); + + expect(result).toContain('--- AFTER ---'); + }); + + it('output includes file path and status', () => { + const filePath = createFile('test.txt', 'line1\nline2\nline3\n'); + + const result = gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }); + + expect(result).toContain(`path=${filePath}`); + expect(result).toContain('status=success'); + }); + }); + + describe('error cases', () => { + it('throws when startLine > endLine', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 5, + endLine: 2, + }), + ).toThrow('Invalid line range'); + }); + + it('throws when startLine is beyond end of file', () => { + const filePath = createFile('test.txt', 'line1\nline2\n'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 10, + endLine: 12, + }), + ).toThrow('beyond end of file'); + }); + + it('throws when file does not exist', () => { + const filePath = join(tmpDir, 'nonexistent.txt'); + + expect(() => + gadget.execute({ + comment: 'test', + filePath, + startLine: 1, + endLine: 1, + }), + ).toThrow('File not found'); + }); + }); +}); diff --git a/tests/unit/gadgets/shared/diagnosticState.test.ts b/tests/unit/gadgets/shared/diagnosticState.test.ts new file mode 100644 index 00000000..138f66c3 --- /dev/null +++ b/tests/unit/gadgets/shared/diagnosticState.test.ts @@ -0,0 +1,324 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock the core diagnostics module to avoid running actual tsc/biome +vi.mock('../../../../src/gadgets/shared/diagnostics.js', () => ({ + runDiagnostics: vi.fn(), + shouldRunDiagnostics: vi.fn(), +})); + +import { + clearDiagnosticState, + formatDiagnosticStatus, + runDiagnosticsWithTracking, + updateDiagnosticState, +} from '../../../../src/gadgets/shared/diagnosticState.js'; +import { + runDiagnostics, + shouldRunDiagnostics, +} from '../../../../src/gadgets/shared/diagnostics.js'; + +const mockRunDiagnostics = vi.mocked(runDiagnostics); +const mockShouldRunDiagnostics = vi.mocked(shouldRunDiagnostics); + +afterEach(() => { + clearDiagnosticState(); + vi.clearAllMocks(); +}); + +describe('updateDiagnosticState', () => { + describe('TypeScript error parsing', () => { + it('parses TypeScript errors from raw output', () => { + const tsOutput = '/workspace/src/file.ts(10,5): error TS2345: Argument of type string'; + updateDiagnosticState( + '/workspace/src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('TS2345'); + expect(status).toContain('Argument of type string'); + }); + + it('parses line number from TypeScript output', () => { + const tsOutput = '/workspace/src/file.ts(42,3): error TS1234: Some error'; + updateDiagnosticState( + '/workspace/src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('line 42'); + }); + + it('falls back to generic message when no pattern matches', () => { + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + // No raw output — should use generic fallback + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Type errors detected'); + }); + + it('ignores TS error lines not matching the file path', () => { + const tsOutput = '/workspace/src/other.ts(10,5): error TS2345: Some error'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: true, hasParseErrors: false, hasLintErrors: false }, + { typescript: tsOutput }, + ); + + const status = formatDiagnosticStatus(); + // Falls back to generic since the error is in other.ts + expect(status).toContain('Type errors detected'); + }); + }); + + describe('Biome parse error parsing', () => { + it('parses biome parse errors', () => { + const biomeOutput = 'src/file.ts:5:10 parse error something went wrong'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: true, hasLintErrors: false }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Parse'); + expect(status).toContain('parse error'); + }); + + it('falls back to generic parse error message', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Parse error detected'); + }); + }); + + describe('Biome lint error parsing', () => { + it('parses lint rule names from biome output', () => { + const biomeOutput = ` +src/file.ts:3:5 lint/suspicious/noDoubleEquals + 3 │ if (x == y) {} +`; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('noDoubleEquals'); + }); + + it('deduplicates repeated lint rules to single error entry', () => { + const biomeOutput = ` +src/file.ts:3:5 lint/suspicious/noDoubleEquals +src/file.ts:7:5 lint/suspicious/noDoubleEquals +src/file.ts:9:5 lint/suspicious/noBannedTypes +`; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + // Both distinct rule names should appear + expect(status).toContain('noDoubleEquals'); + expect(status).toContain('noBannedTypes'); + // The file should show 2 errors (one per unique rule) + expect(status).toContain('2 errors'); + }); + + it('falls back to generic lint error message when no rules found', () => { + const biomeOutput = 'src/file.ts:5:10 some non-lint error'; + updateDiagnosticState( + 'src/file.ts', + { hasTypeErrors: false, hasParseErrors: false, hasLintErrors: true }, + { biome: biomeOutput }, + ); + + const status = formatDiagnosticStatus(); + expect(status).toContain('lint error'); + }); + + it('falls back to generic lint error when no raw output', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: true, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('Lint error(s) detected'); + }); + }); + + describe('state management', () => { + it('removes file from tracking when all errors are resolved', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + let status = formatDiagnosticStatus(); + expect(status).toContain('⚠️'); + + // Fix the errors + updateDiagnosticState('src/file.ts', { + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + status = formatDiagnosticStatus(); + expect(status).toContain('✅ All edited files pass type checking'); + }); + + it('tracks multiple files independently', () => { + updateDiagnosticState('src/file1.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + updateDiagnosticState('src/file2.ts', { + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('src/file1.ts'); + expect(status).toContain('src/file2.ts'); + expect(status).toContain('2 file(s)'); + }); + }); +}); + +describe('formatDiagnosticStatus', () => { + it('returns success message when no errors', () => { + const status = formatDiagnosticStatus(); + expect(status).toBe('## Diagnostic Status [v2]\n\n✅ All edited files pass type checking'); + }); + + it('shows error count per file', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: true, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('2 errors'); + }); + + it('shows singular "error" for exactly 1 error', () => { + updateDiagnosticState('src/file.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toMatch(/1 error\b/); + }); + + it('includes file path in output', () => { + updateDiagnosticState('src/myModule.ts', { + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + const status = formatDiagnosticStatus(); + expect(status).toContain('src/myModule.ts'); + }); +}); + +describe('runDiagnosticsWithTracking', () => { + it('returns null when diagnostics should not run for file type', () => { + mockShouldRunDiagnostics.mockReturnValue(false); + + const result = runDiagnosticsWithTracking('config.json', '/abs/config.json'); + expect(result).toBeNull(); + expect(mockRunDiagnostics).not.toHaveBeenCalled(); + }); + + it('returns hasErrors=false when no issues found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result).not.toBeNull(); + expect(result?.hasErrors).toBe(false); + expect(result?.statusMessage).toBe('✓ No issues'); + }); + + it('returns hasErrors=true when TypeScript errors found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + rawTypescript: '/abs/src/file.ts(5,3): error TS2345: error TS2345: Something is wrong', + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result?.hasErrors).toBe(true); + expect(result?.statusMessage).toContain('diagnostic issue'); + }); + + it('returns hasErrors=true when parse errors found', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: true, + hasLintErrors: false, + }); + + const result = runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + expect(result?.hasErrors).toBe(true); + }); + + it('updates diagnostic state after running', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: true, + hasParseErrors: false, + hasLintErrors: false, + }); + + runDiagnosticsWithTracking('src/file.ts', '/abs/src/file.ts'); + + const status = formatDiagnosticStatus(); + expect(status).toContain('⚠️'); + expect(status).toContain('src/file.ts'); + }); + + it('calls runDiagnostics with the validated path', () => { + mockShouldRunDiagnostics.mockReturnValue(true); + mockRunDiagnostics.mockReturnValue({ + hasTypeErrors: false, + hasParseErrors: false, + hasLintErrors: false, + }); + + runDiagnosticsWithTracking('src/file.ts', '/abs/workspace/src/file.ts'); + + expect(mockRunDiagnostics).toHaveBeenCalledWith('/abs/workspace/src/file.ts'); + }); +}); diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index ec572234..d72c5aa8 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -8,12 +8,48 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -// Mock jira.js Version3Client (for other methods, not needed for raw fetch methods) +// Use vi.hoisted to create mock objects before vi.mock factories run +const { mockIssues, mockIssueComments, mockIssueSearch, mockIssueAttachments, mockMyself } = + vi.hoisted(() => ({ + mockIssues: { + getIssue: vi.fn(), + editIssue: vi.fn(), + createIssue: vi.fn(), + doTransition: vi.fn(), + getTransitions: vi.fn(), + }, + mockIssueComments: { + getComments: vi.fn(), + addComment: vi.fn(), + updateComment: vi.fn(), + }, + mockIssueSearch: { + searchForIssuesUsingJql: vi.fn(), + }, + mockIssueAttachments: { + addAttachment: vi.fn(), + }, + mockMyself: { + getCurrentUser: vi.fn(), + }, + })); + vi.mock('jira.js', () => ({ - Version3Client: vi.fn().mockImplementation(() => ({})), + Version3Client: vi.fn().mockImplementation(() => ({ + issues: mockIssues, + issueComments: mockIssueComments, + issueSearch: mockIssueSearch, + issueAttachments: mockIssueAttachments, + myself: mockMyself, + })), })); -import { _resetCloudIdCache, jiraClient, withJiraCredentials } from '../../../src/jira/client.js'; +import { + _resetCloudIdCache, + getJiraCredentials, + jiraClient, + withJiraCredentials, +} from '../../../src/jira/client.js'; describe('jiraClient', () => { const creds = { @@ -24,12 +60,26 @@ describe('jiraClient', () => { const expectedAuth = `Basic ${Buffer.from('bot@example.com:jira-token').toString('base64')}`; beforeEach(() => { - vi.clearAllMocks(); + // Reset only the call history of mock client methods, not their implementations + mockIssues.getIssue.mockReset(); + mockIssues.editIssue.mockReset(); + mockIssues.createIssue.mockReset(); + mockIssues.doTransition.mockReset(); + mockIssues.getTransitions.mockReset(); + mockIssueComments.getComments.mockReset(); + mockIssueComments.addComment.mockReset(); + mockIssueComments.updateComment.mockReset(); + mockIssueSearch.searchForIssuesUsingJql.mockReset(); + mockIssueAttachments.addAttachment.mockReset(); + mockMyself.getCurrentUser.mockReset(); _resetCloudIdCache(); }); afterEach(() => { - vi.restoreAllMocks(); + // Note: We don't call vi.restoreAllMocks() here because it would reset + // the Version3Client mock implementation from vi.mock(), breaking subsequent tests. + // Instead we clear only the fetch spy manually. + vi.clearAllMocks(); }); describe('getCloudId', () => { @@ -165,4 +215,248 @@ describe('jiraClient', () => { ).rejects.toThrow('No JIRA credentials in scope'); }); }); + + describe('getIssue', () => { + it('calls getIssue with the issue key and required fields', async () => { + const issueData = { key: 'TEST-1', fields: { summary: 'Test Issue' } }; + mockIssues.getIssue.mockResolvedValue(issueData); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssue('TEST-1')); + + expect(result).toEqual(issueData); + expect(mockIssues.getIssue).toHaveBeenCalledWith( + expect.objectContaining({ issueIdOrKey: 'TEST-1' }), + ); + }); + + it('throws when called outside scope', async () => { + await expect(jiraClient.getIssue('TEST-1')).rejects.toThrow('No JIRA credentials in scope'); + }); + }); + + describe('updateIssue', () => { + it('calls editIssue with summary', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => + jiraClient.updateIssue('TEST-1', { summary: 'New Title' }), + ); + + expect(mockIssues.editIssue).toHaveBeenCalledWith( + expect.objectContaining({ + issueIdOrKey: 'TEST-1', + fields: expect.objectContaining({ summary: 'New Title' }), + }), + ); + }); + + it('calls editIssue with description', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + const desc = { type: 'doc', version: 1, content: [] }; + + await withJiraCredentials(creds, () => + jiraClient.updateIssue('TEST-1', { description: desc }), + ); + + expect(mockIssues.editIssue).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.objectContaining({ description: desc }), + }), + ); + }); + }); + + describe('addComment', () => { + it('returns comment id', async () => { + mockIssueComments.addComment.mockResolvedValue({ id: 'comment-123' }); + + const id = await withJiraCredentials(creds, () => + jiraClient.addComment('TEST-1', { type: 'doc' }), + ); + + expect(id).toBe('comment-123'); + expect(mockIssueComments.addComment).toHaveBeenCalledWith( + expect.objectContaining({ issueIdOrKey: 'TEST-1' }), + ); + }); + + it('returns empty string when id is missing', async () => { + mockIssueComments.addComment.mockResolvedValue({}); + + const id = await withJiraCredentials(creds, () => + jiraClient.addComment('TEST-1', { type: 'doc' }), + ); + + expect(id).toBe(''); + }); + }); + + describe('createIssue', () => { + it('calls createIssue with the provided fields', async () => { + const newIssue = { id: '10001', key: 'TEST-2' }; + mockIssues.createIssue.mockResolvedValue(newIssue); + + const result = await withJiraCredentials(creds, () => + jiraClient.createIssue({ + project: { key: 'TEST' }, + summary: 'New Issue', + issuetype: { name: 'Task' }, + }), + ); + + expect(result).toEqual(newIssue); + expect(mockIssues.createIssue).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.objectContaining({ project: { key: 'TEST' } }), + }), + ); + }); + }); + + describe('transitionIssue', () => { + it('calls doTransition with issue key and transition id', async () => { + mockIssues.doTransition.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => jiraClient.transitionIssue('TEST-1', 'transition-31')); + + expect(mockIssues.doTransition).toHaveBeenCalledWith({ + issueIdOrKey: 'TEST-1', + transition: { id: 'transition-31' }, + }); + }); + }); + + describe('getTransitions', () => { + it('returns transitions array', async () => { + const transitions = [ + { id: '31', name: 'Done' }, + { id: '11', name: 'In Progress' }, + ]; + mockIssues.getTransitions.mockResolvedValue({ transitions }); + + const result = await withJiraCredentials(creds, () => jiraClient.getTransitions('TEST-1')); + + expect(result).toEqual(transitions); + }); + + it('returns empty array when transitions is missing', async () => { + mockIssues.getTransitions.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.getTransitions('TEST-1')); + + expect(result).toEqual([]); + }); + }); + + describe('updateLabels', () => { + it('calls editIssue with labels array', async () => { + mockIssues.editIssue.mockResolvedValue(undefined); + + await withJiraCredentials(creds, () => jiraClient.updateLabels('TEST-1', ['bug', 'urgent'])); + + expect(mockIssues.editIssue).toHaveBeenCalledWith({ + issueIdOrKey: 'TEST-1', + fields: { labels: ['bug', 'urgent'] }, + }); + }); + }); + + describe('searchIssues', () => { + it('returns issues from JQL search', async () => { + const issues = [ + { id: '1', key: 'TEST-1' }, + { id: '2', key: 'TEST-2' }, + ]; + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({ issues }); + + const result = await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST AND status = "In Progress"'), + ); + + expect(result).toEqual(issues); + expect(mockIssueSearch.searchForIssuesUsingJql).toHaveBeenCalledWith( + expect.objectContaining({ + jql: 'project = TEST AND status = "In Progress"', + }), + ); + }); + + it('returns empty array when issues is missing', async () => { + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST'), + ); + + expect(result).toEqual([]); + }); + + it('uses custom fields when provided', async () => { + mockIssueSearch.searchForIssuesUsingJql.mockResolvedValue({ issues: [] }); + + await withJiraCredentials(creds, () => + jiraClient.searchIssues('project = TEST', ['summary', 'status', 'priority']), + ); + + expect(mockIssueSearch.searchForIssuesUsingJql).toHaveBeenCalledWith( + expect.objectContaining({ + fields: ['summary', 'status', 'priority'], + }), + ); + }); + }); + + describe('addAttachmentFile', () => { + it('calls addAttachment with buffer and filename', async () => { + mockIssueAttachments.addAttachment.mockResolvedValue(undefined); + const buf = Buffer.from('file content'); + + await withJiraCredentials(creds, () => + jiraClient.addAttachmentFile('TEST-1', buf, 'session.zip'), + ); + + expect(mockIssueAttachments.addAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + issueIdOrKey: 'TEST-1', + attachment: expect.objectContaining({ + filename: 'session.zip', + file: buf, + }), + }), + ); + }); + }); + + describe('getIssueComments', () => { + it('returns comments array', async () => { + const comments = [{ id: 'c1', body: 'First comment' }]; + mockIssueComments.getComments.mockResolvedValue({ comments }); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssueComments('TEST-1')); + + expect(result).toEqual(comments); + }); + + it('returns empty array when comments is missing', async () => { + mockIssueComments.getComments.mockResolvedValue({}); + + const result = await withJiraCredentials(creds, () => jiraClient.getIssueComments('TEST-1')); + + expect(result).toEqual([]); + }); + }); + + describe('getJiraCredentials', () => { + it('throws when called outside scope', () => { + expect(() => getJiraCredentials()).toThrow('No JIRA credentials in scope'); + }); + + it('returns credentials when inside withJiraCredentials scope', async () => { + let captured: ReturnType | undefined; + await withJiraCredentials(creds, async () => { + captured = getJiraCredentials(); + }); + expect(captured).toEqual(creds); + }); + }); }); diff --git a/tests/unit/router/config.test.ts b/tests/unit/router/config.test.ts new file mode 100644 index 00000000..2bc1f72e --- /dev/null +++ b/tests/unit/router/config.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock config provider to avoid DB connections +vi.mock('../../../src/config/provider.js', () => ({ + loadConfig: vi.fn(), +})); +vi.mock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + getProjectByBoardId: vi.fn().mockReturnValue(null), + getProjectByRepo: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + setProjectByBoardId: vi.fn(), + setProjectByRepo: vi.fn(), + setSecrets: vi.fn(), + invalidate: vi.fn(), + }, +})); + +import { loadConfig } from '../../../src/config/provider.js'; +import { getProjectConfig, loadProjectConfig, routerConfig } from '../../../src/router/config.js'; + +const mockLoadConfig = vi.mocked(loadConfig); + +// Helper to reset the module-level cache between tests +async function resetProjectConfig(): Promise { + // Re-import the module fresh to reset its state + vi.resetModules(); +} + +describe('routerConfig', () => { + it('has default Redis URL', () => { + expect(routerConfig.redisUrl).toBe('redis://localhost:6379'); + }); + + it('has default maxWorkers', () => { + expect(routerConfig.maxWorkers).toBe(3); + }); + + it('has default workerMemoryMb', () => { + expect(routerConfig.workerMemoryMb).toBe(4096); + }); + + it('has default dockerNetwork', () => { + expect(routerConfig.dockerNetwork).toBe('services_default'); + }); + + it('has default workerTimeoutMs of 30 minutes', () => { + expect(routerConfig.workerTimeoutMs).toBe(30 * 60 * 1000); + }); +}); + +describe('getProjectConfig', () => { + it('throws when config has not been loaded yet', async () => { + // We need a fresh module state without cached config + // Use a dynamic import with a reset module + vi.resetModules(); + + // Re-mock after resetModules + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: vi.fn(), + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + + const { getProjectConfig: freshGetProjectConfig } = await import( + '../../../src/router/config.js' + ); + expect(() => freshGetProjectConfig()).toThrow( + '[Router] Config not loaded yet. Call loadProjectConfig() first.', + ); + + vi.resetModules(); + }); +}); + +describe('loadProjectConfig', () => { + beforeEach(() => { + vi.resetModules(); + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: mockLoadConfig, + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + }); + + it('maps trello project config correctly', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p1', + name: 'Project 1', + repo: 'owner/repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + pm: { type: 'trello' }, + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toMatchObject({ + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3' }, + labels: { readyToProcess: 'label1', processed: 'label2' }, + }, + }); + }); + + it('maps jira project config correctly', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p2', + name: 'JIRA Project', + repo: 'owner/jira-repo', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + pm: { type: 'jira' }, + jira: { + projectKey: 'MYPROJ', + baseUrl: 'https://mycompany.atlassian.net', + }, + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects).toHaveLength(1); + expect(result.projects[0]).toMatchObject({ + id: 'p2', + repo: 'owner/jira-repo', + pmType: 'jira', + jira: { + projectKey: 'MYPROJ', + baseUrl: 'https://mycompany.atlassian.net', + }, + }); + }); + + it('defaults pmType to trello when pm.type is not set', async () => { + mockLoadConfig.mockResolvedValueOnce({ + projects: [ + { + id: 'p3', + name: 'No PM type', + repo: 'owner/repo3', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + // No pm field + }, + ], + } as never); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + const result = await freshLoad(); + + expect(result.projects[0].pmType).toBe('trello'); + }); + + it('returns cached result on subsequent calls', async () => { + const innerMock = vi.fn().mockResolvedValue({ + projects: [ + { + id: 'p4', + name: 'Cached', + repo: 'owner/cached', + orgId: 'org1', + baseBranch: 'main', + branchPrefix: 'cascade/', + }, + ], + }); + + vi.resetModules(); + vi.doMock('../../../src/config/provider.js', () => ({ + loadConfig: innerMock, + })); + vi.doMock('../../../src/config/configCache.js', () => ({ + configCache: { + getSecrets: vi.fn().mockReturnValue(null), + getConfig: vi.fn().mockReturnValue(null), + setConfig: vi.fn(), + }, + })); + + const { loadProjectConfig: freshLoad } = await import('../../../src/router/config.js'); + await freshLoad(); + await freshLoad(); // Second call — should use cache + + expect(innerMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/router/index.test.ts b/tests/unit/router/index.test.ts new file mode 100644 index 00000000..1434072a --- /dev/null +++ b/tests/unit/router/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock heavy imports that cause side effects +vi.mock('../../../src/router/queue.js', () => ({ + addJob: vi.fn(), + getQueueStats: vi.fn(), +})); +vi.mock('../../../src/router/worker-manager.js', () => ({ + getActiveWorkerCount: vi.fn(), + getActiveWorkers: vi.fn(), + startWorkerProcessor: vi.fn(), + stopWorkerProcessor: vi.fn(), +})); +vi.mock('@hono/node-server', () => ({ + serve: vi.fn(), +})); +vi.mock('../../../src/utils/webhookLogger.js', () => ({ + logWebhookCall: vi.fn(), +})); +vi.mock('../../../src/router/reactions.js', () => ({ + sendAcknowledgeReaction: vi.fn(), +})); +vi.mock('../../../src/router/pre-actions.js', () => ({ + addEyesReactionToPR: vi.fn(), +})); +vi.mock('../../../src/router/config.js', () => ({ + loadProjectConfig: vi.fn().mockResolvedValue({ projects: [] }), + getProjectConfig: vi.fn().mockReturnValue({ projects: [] }), +})); + +// Import the functions we want to test - they are module-private so we test through exports +// We'll use a re-export approach by importing the raw module +// Since these functions aren't exported, we test them via the Hono app behavior instead + +import { getProjectConfig } from '../../../src/router/config.js'; + +describe('router config integration', () => { + it('getProjectConfig returns cached projects', () => { + vi.mocked(getProjectConfig).mockReturnValue({ + projects: [ + { + id: 'p1', + repo: 'owner/repo', + pmType: 'trello', + trello: { + boardId: 'board1', + lists: { briefing: 'list1', planning: 'list2', todo: 'list3', debug: 'list4' }, + labels: { readyToProcess: 'label1' }, + }, + }, + ], + }); + const config = getProjectConfig(); + expect(config.projects).toHaveLength(1); + expect(config.projects[0].id).toBe('p1'); + }); +}); diff --git a/tests/unit/trello/client.test.ts b/tests/unit/trello/client.test.ts index 92395d4d..cf67cdcd 100644 --- a/tests/unit/trello/client.test.ts +++ b/tests/unit/trello/client.test.ts @@ -8,53 +8,74 @@ vi.mock('../../../src/utils/logging.js', () => ({ }, })); -const { mockAddCardComment } = vi.hoisted(() => ({ - mockAddCardComment: vi.fn(), +// Use vi.hoisted to create all mock objects before factory functions run +const { mockCards, mockChecklists, mockLists } = vi.hoisted(() => ({ + mockCards: { + addCardComment: vi.fn(), + getCard: vi.fn(), + getCardActions: vi.fn(), + updateCard: vi.fn(), + addCardLabel: vi.fn(), + deleteCardLabel: vi.fn(), + createCardAttachment: vi.fn(), + createCardChecklist: vi.fn(), + getCardChecklists: vi.fn(), + updateCardCheckItem: vi.fn(), + createCard: vi.fn(), + }, + mockChecklists: { + createChecklistCheckItems: vi.fn(), + }, + mockLists: { + getListCards: vi.fn(), + }, })); // Mock trello.js client vi.mock('trello.js', () => ({ TrelloClient: vi.fn().mockImplementation(() => ({ - cards: { - addCardComment: mockAddCardComment, - }, + cards: mockCards, + checklists: mockChecklists, + lists: mockLists, })), })); import { TrelloClient } from 'trello.js'; -import { trelloClient, withTrelloCredentials } from '../../../src/trello/client.js'; - -const MockedTrelloClient = vi.mocked(TrelloClient); +import { + getTrelloCredentials, + trelloClient, + withTrelloCredentials, +} from '../../../src/trello/client.js'; describe('trelloClient', () => { const creds = { apiKey: 'test-key', token: 'test-token' }; beforeEach(() => { - vi.clearAllMocks(); - // Re-initialize the TrelloClient mock implementation after clearAllMocks - MockedTrelloClient.mockImplementation( - () => ({ cards: { addCardComment: mockAddCardComment } }) as unknown as TrelloClient, - ); + // Reset individual mock functions without clearing implementations + for (const fn of Object.values(mockCards)) fn.mockReset(); + for (const fn of Object.values(mockChecklists)) fn.mockReset(); + for (const fn of Object.values(mockLists)) fn.mockReset(); }); afterEach(() => { - vi.restoreAllMocks(); + // Don't call restoreAllMocks() as it would clear the Version3Client mock impl + vi.clearAllMocks(); }); describe('addComment', () => { it('returns the comment action ID from API response', async () => { - mockAddCardComment.mockResolvedValue({ id: 'action-abc123' }); + mockCards.addCardComment.mockResolvedValue({ id: 'action-abc123' }); const id = await withTrelloCredentials(creds, () => trelloClient.addComment('card-1', 'Hello world'), ); - expect(mockAddCardComment).toHaveBeenCalledWith({ id: 'card-1', text: 'Hello world' }); + expect(mockCards.addCardComment).toHaveBeenCalledWith({ id: 'card-1', text: 'Hello world' }); expect(id).toBe('action-abc123'); }); it('returns empty string when API response has no id', async () => { - mockAddCardComment.mockResolvedValue({}); + mockCards.addCardComment.mockResolvedValue({}); const id = await withTrelloCredentials(creds, () => trelloClient.addComment('card-1', 'Hello'), @@ -139,4 +160,207 @@ describe('trelloClient', () => { ); }); }); + + describe('getTrelloCredentials', () => { + it('throws when called outside scope', () => { + expect(() => getTrelloCredentials()).toThrow('No Trello credentials in scope'); + }); + + it('returns credentials when inside scope', async () => { + let captured: ReturnType | undefined; + await withTrelloCredentials(creds, async () => { + captured = getTrelloCredentials(); + }); + expect(captured).toEqual(creds); + }); + }); + + describe('getCard', () => { + it('returns a card with normalized fields', async () => { + mockCards.getCard.mockResolvedValue({ + id: 'card-1', + name: 'My Card', + desc: 'Card description', + url: 'https://trello.com/c/abc123', + shortUrl: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [{ id: 'label-1', name: 'Bug', color: 'red' }], + }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-1')); + + expect(result).toEqual({ + id: 'card-1', + name: 'My Card', + desc: 'Card description', + url: 'https://trello.com/c/abc123', + shortUrl: 'https://trello.com/c/abc', + idList: 'list-1', + labels: [{ id: 'label-1', name: 'Bug', color: 'red' }], + }); + expect(mockCards.getCard).toHaveBeenCalledWith({ id: 'card-1' }); + }); + + it('normalizes missing optional fields to empty strings', async () => { + mockCards.getCard.mockResolvedValue({ id: 'card-2' }); + + const result = await withTrelloCredentials(creds, () => trelloClient.getCard('card-2')); + + expect(result.name).toBe(''); + expect(result.desc).toBe(''); + expect(result.url).toBe(''); + expect(result.idList).toBe(''); + expect(result.labels).toEqual([]); + }); + + it('throws when called outside scope', async () => { + await expect(trelloClient.getCard('card-1')).rejects.toThrow( + 'No Trello credentials in scope', + ); + }); + }); + + describe('getCardComments', () => { + it('returns comments with mapped fields', async () => { + mockCards.getCardActions.mockResolvedValue([ + { + id: 'action-1', + date: '2026-01-01T00:00:00.000Z', + data: { text: 'Hello world' }, + memberCreator: { id: 'member-1', fullName: 'Alice', username: 'alice' }, + }, + ]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardComments('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'action-1', + date: '2026-01-01T00:00:00.000Z', + data: { text: 'Hello world' }, + memberCreator: { id: 'member-1', fullName: 'Alice', username: 'alice' }, + }); + }); + + it('returns empty array when no comments', async () => { + mockCards.getCardActions.mockResolvedValue([]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardComments('card-1'), + ); + + expect(result).toEqual([]); + }); + }); + + describe('updateCard', () => { + it('calls updateCard with name and desc', async () => { + mockCards.updateCard.mockResolvedValue({}); + + await withTrelloCredentials(creds, () => + trelloClient.updateCard('card-1', { name: 'New Title', desc: 'New desc' }), + ); + + expect(mockCards.updateCard).toHaveBeenCalledWith( + expect.objectContaining({ id: 'card-1', name: 'New Title', desc: 'New desc' }), + ); + }); + }); + + describe('createCard', () => { + it('returns a created card with normalized fields', async () => { + mockCards.createCard.mockResolvedValue({ + id: 'new-card', + name: 'New Feature', + desc: 'Description', + url: 'https://trello.com/c/new', + shortUrl: 'https://trello.com/c/new-short', + idList: 'list-todo', + labels: [], + }); + + const result = await withTrelloCredentials(creds, () => + trelloClient.createCard('list-todo', { name: 'New Feature', desc: 'Description' }), + ); + + expect(result.id).toBe('new-card'); + expect(result.name).toBe('New Feature'); + expect(mockCards.createCard).toHaveBeenCalledWith( + expect.objectContaining({ idList: 'list-todo', name: 'New Feature' }), + ); + }); + }); + + describe('getCardChecklists', () => { + it('returns checklists with check items', async () => { + mockCards.getCardChecklists.mockResolvedValue([ + { + id: 'cl-1', + name: 'Implementation Steps', + idCard: 'card-1', + checkItems: [ + { id: 'item-1', name: 'Step 1', state: 'complete' }, + { id: 'item-2', name: 'Step 2', state: 'incomplete' }, + ], + }, + ]); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardChecklists('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Implementation Steps'); + expect(result[0].checkItems[0].state).toBe('complete'); + expect(result[0].checkItems[1].state).toBe('incomplete'); + }); + }); + + describe('getCardAttachments', () => { + it('returns attachments via fetch', async () => { + const attachments = [ + { + id: 'att-1', + name: 'session.zip', + url: 'https://trello.com/attachments/att-1', + mimeType: 'application/zip', + bytes: 1024, + date: '2026-01-01T00:00:00.000Z', + }, + ]; + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify(attachments), { status: 200 })); + + const result = await withTrelloCredentials(creds, () => + trelloClient.getCardAttachments('card-1'), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'att-1', + name: 'session.zip', + url: 'https://trello.com/attachments/att-1', + mimeType: 'application/zip', + bytes: 1024, + date: '2026-01-01T00:00:00.000Z', + }); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('/1/cards/card-1/attachments'); + expect(url).toContain('key=test-key'); + expect(url).toContain('token=test-token'); + }); + + it('throws on non-OK response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Unauthorized', { status: 401 }), + ); + + await expect( + withTrelloCredentials(creds, () => trelloClient.getCardAttachments('card-1')), + ).rejects.toThrow('Failed to get attachments: 401'); + }); + }); }); diff --git a/tests/unit/utils/envScrub.test.ts b/tests/unit/utils/envScrub.test.ts new file mode 100644 index 00000000..be1c1480 --- /dev/null +++ b/tests/unit/utils/envScrub.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { scrubSensitiveEnv } from '../../../src/utils/envScrub.js'; + +describe('scrubSensitiveEnv', () => { + let savedEnv: Record; + + beforeEach(() => { + // Save original env values for restoration + savedEnv = { + CREDENTIAL_MASTER_KEY: process.env.CREDENTIAL_MASTER_KEY, + DATABASE_URL: process.env.DATABASE_URL, + DATABASE_SSL: process.env.DATABASE_SSL, + REDIS_URL: process.env.REDIS_URL, + CASCADE_CREDENTIALS: process.env.CASCADE_CREDENTIALS, + CASCADE_CREDENTIALS_PROJECT_ID: process.env.CASCADE_CREDENTIALS_PROJECT_ID, + }; + }); + + afterEach(() => { + // Restore original env values + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it('removes CREDENTIAL_MASTER_KEY from process.env', () => { + process.env.CREDENTIAL_MASTER_KEY = 'super-secret-key-abc123'; + scrubSensitiveEnv(); + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + }); + + it('removes DATABASE_URL from process.env', () => { + process.env.DATABASE_URL = 'postgresql://user:pass@host:5432/db'; + scrubSensitiveEnv(); + expect(process.env.DATABASE_URL).toBeUndefined(); + }); + + it('removes DATABASE_SSL from process.env', () => { + process.env.DATABASE_SSL = 'false'; + scrubSensitiveEnv(); + expect(process.env.DATABASE_SSL).toBeUndefined(); + }); + + it('removes REDIS_URL from process.env', () => { + process.env.REDIS_URL = 'redis://localhost:6379'; + scrubSensitiveEnv(); + expect(process.env.REDIS_URL).toBeUndefined(); + }); + + it('removes CASCADE_CREDENTIALS from process.env', () => { + process.env.CASCADE_CREDENTIALS = 'eyJzb21lIjoianNvbiJ9'; + scrubSensitiveEnv(); + expect(process.env.CASCADE_CREDENTIALS).toBeUndefined(); + }); + + it('removes CASCADE_CREDENTIALS_PROJECT_ID from process.env', () => { + process.env.CASCADE_CREDENTIALS_PROJECT_ID = 'my-project-id'; + scrubSensitiveEnv(); + expect(process.env.CASCADE_CREDENTIALS_PROJECT_ID).toBeUndefined(); + }); + + it('removes all sensitive keys in a single call', () => { + process.env.CREDENTIAL_MASTER_KEY = 'key1'; + process.env.DATABASE_URL = 'postgres://...'; + process.env.DATABASE_SSL = 'true'; + process.env.REDIS_URL = 'redis://...'; + process.env.CASCADE_CREDENTIALS = 'creds'; + process.env.CASCADE_CREDENTIALS_PROJECT_ID = 'proj-id'; + + scrubSensitiveEnv(); + + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + expect(process.env.DATABASE_URL).toBeUndefined(); + expect(process.env.DATABASE_SSL).toBeUndefined(); + expect(process.env.REDIS_URL).toBeUndefined(); + expect(process.env.CASCADE_CREDENTIALS).toBeUndefined(); + expect(process.env.CASCADE_CREDENTIALS_PROJECT_ID).toBeUndefined(); + }); + + it('does not remove non-sensitive environment variables', () => { + process.env.MY_APP_API_KEY = 'should-remain'; + process.env.PORT = '3000'; + + scrubSensitiveEnv(); + + expect(process.env.MY_APP_API_KEY).toBe('should-remain'); + expect(process.env.PORT).toBe('3000'); + + // Clean up test-specific vars + process.env.MY_APP_API_KEY = undefined; + process.env.PORT = undefined; + }); + + it('handles keys that were never set (undefined)', () => { + // Ensure they are undefined to start + process.env.CREDENTIAL_MASTER_KEY = undefined; + process.env.DATABASE_URL = undefined; + + // Should not throw + expect(() => scrubSensitiveEnv()).not.toThrow(); + + expect(process.env.CREDENTIAL_MASTER_KEY).toBeUndefined(); + expect(process.env.DATABASE_URL).toBeUndefined(); + }); + + it('scrubbing is idempotent — calling twice does not throw', () => { + process.env.DATABASE_URL = 'postgres://...'; + scrubSensitiveEnv(); + expect(() => scrubSensitiveEnv()).not.toThrow(); + }); +});