From ca8b3137c9293975b7206e62fddb2de89fc41c84 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 17 Mar 2026 18:59:19 +0000 Subject: [PATCH] test(cli): add unit tests for PM/SCM commands and cliCommandFactory --- tests/unit/cli/cli-command-factory.test.ts | 465 +++++++++++++++++++++ tests/unit/cli/pm/pm-commands.test.ts | 337 +++++++++++++++ tests/unit/cli/scm/scm-commands.test.ts | 401 ++++++++++++++++++ 3 files changed, 1203 insertions(+) create mode 100644 tests/unit/cli/cli-command-factory.test.ts create mode 100644 tests/unit/cli/pm/pm-commands.test.ts create mode 100644 tests/unit/cli/scm/scm-commands.test.ts diff --git a/tests/unit/cli/cli-command-factory.test.ts b/tests/unit/cli/cli-command-factory.test.ts new file mode 100644 index 00000000..eb2a4664 --- /dev/null +++ b/tests/unit/cli/cli-command-factory.test.ts @@ -0,0 +1,465 @@ +/** + * Unit tests for cliCommandFactory. + * + * Tests: + * - Flag generation for all param types (string, number, boolean, enum, array, object) + * - gadgetOnly params are excluded from generated CLI flags + * - File-input resolution (--text-file reads file, prefers file over inline) + * - owner/repo auto-resolution from env vars + * - JSON success/error output format + * - Error handling (success: false, error: message) + */ + +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock credential-scoping dependencies +// --------------------------------------------------------------------------- +vi.mock('../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), +})); +vi.mock('../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn( + (_creds: { apiKey: string; token: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn( + (_creds: { email: string; apiToken: string; baseUrl: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../src/pm/index.js', () => ({ + createPMProvider: vi.fn(() => ({})), + withPMProvider: vi.fn((_provider: unknown, fn: () => Promise) => fn()), +})); + +import { createCLICommand } from '../../../src/gadgets/shared/cliCommandFactory.js'; +import type { ToolDefinition } from '../../../src/gadgets/shared/toolDefinition.js'; + +/** Minimal oclif config to satisfy this.parse() */ +const mockConfig = { runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }) }; + +let tmpDir: string; + +/** Create a fresh minimal oclif config to satisfy this.parse() in each test */ +function makeMockConfig() { + return { runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }) }; +} + +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'cascade-factory-test-')); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** Write content to a temp file and return the path. */ +function writeTempFile(filename: string, content: string): string { + const filePath = join(tmpDir, filename); + writeFileSync(filePath, content); + return filePath; +} + +// --------------------------------------------------------------------------- +// Helper — minimal ToolDefinition factory +// --------------------------------------------------------------------------- +function makeToolDef(overrides: Partial = {}): ToolDefinition { + return { + name: 'TestTool', + description: 'A test tool', + parameters: {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Flag generation for all parameter types +// --------------------------------------------------------------------------- +describe('cliCommandFactory — flag generation', () => { + it('generates string flags correctly', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + message: { type: 'string', describe: 'A string param', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--message', 'hello'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ message: 'hello' })); + }); + + it('generates number (integer) flags correctly', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + count: { type: 'number', describe: 'A number param', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--count', '42'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ count: 42 })); + }); + + it('generates boolean flags correctly', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + verbose: { type: 'boolean', describe: 'A boolean param', optional: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--verbose'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ verbose: true })); + }); + + it('generates boolean flags with --no- negation when allowNo is set', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + enabled: { + type: 'boolean', + describe: 'A boolean with allowNo', + optional: true, + default: true, + allowNo: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--no-enabled'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + + it('generates enum flags with restricted options', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + status: { + type: 'enum', + options: ['open', 'closed', 'draft'], + describe: 'An enum param', + required: true, + }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--status', 'open'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ status: 'open' })); + }); + + it('generates array flags (multiple values)', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + tags: { type: 'array', items: 'string', describe: 'An array param', optional: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--tags', 'a', '--tags', 'b', '--tags', 'c'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ tags: ['a', 'b', 'c'] })); + }); + + it('generates object flags (JSON string)', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + config: { type: 'object', describe: 'An object param (JSON string)', optional: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--config', '{"key":"value","num":42}'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith( + expect.objectContaining({ config: { key: 'value', num: 42 } }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// gadgetOnly exclusion +// --------------------------------------------------------------------------- +describe('cliCommandFactory — gadgetOnly param exclusion', () => { + it('does not pass gadgetOnly params to the CLI flags', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + comment: { type: 'string', describe: 'Rationale', required: true, gadgetOnly: true }, + message: { type: 'string', describe: 'CLI param', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + + // Passing --message only should work (gadgetOnly "comment" has no CLI flag) + const cmd = new Cmd(['--message', 'hello'], makeMockConfig() as never); + await cmd.run(); + + // coreFn is called without the gadgetOnly "comment" field + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ message: 'hello' })); + // "comment" should NOT be present in the resolved params + const callArg = vi.mocked(coreFn).mock.calls[0][0] as Record; + expect(callArg).not.toHaveProperty('comment'); + }); + + it('raises an error when passing a gadgetOnly param as a flag', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + comment: { type: 'string', describe: 'Rationale', required: true, gadgetOnly: true }, + message: { type: 'string', describe: 'CLI param', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd( + ['--comment', 'rationale', '--message', 'hello'], + makeMockConfig() as never, + ); + + // The --comment flag doesn't exist in generated CLI flags, so oclif should throw + await expect(cmd.run()).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// File-input resolution +// --------------------------------------------------------------------------- +describe('cliCommandFactory — file-input resolution', () => { + it('reads param value from file when file flag is provided', async () => { + const filePath = writeTempFile('text.md', 'Content from file'); + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + text: { type: 'string', describe: 'The text', required: true }, + }, + cli: { + fileInputAlternatives: [ + { paramName: 'text', fileFlag: 'text-file', description: 'Read text from file' }, + ], + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--text-file', filePath], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ text: 'Content from file' })); + }); + + it('prefers file flag over inline flag when both provided', async () => { + const filePath = writeTempFile('text.md', 'from file'); + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + text: { type: 'string', describe: 'The text', required: true }, + }, + cli: { + fileInputAlternatives: [ + { paramName: 'text', fileFlag: 'text-file', description: 'Read text from file' }, + ], + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd( + ['--text', 'from inline', '--text-file', filePath], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ text: 'from file' })); + }); + + it('uses inline value when only inline flag is provided', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + text: { type: 'string', describe: 'The text', required: true }, + }, + cli: { + fileInputAlternatives: [ + { paramName: 'text', fileFlag: 'text-file', description: 'Read text from file' }, + ], + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--text', 'from inline'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith(expect.objectContaining({ text: 'from inline' })); + }); + + it('errors when required file-input param has neither inline nor file value', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + text: { type: 'string', describe: 'The text', required: true }, + }, + cli: { + fileInputAlternatives: [ + { paramName: 'text', fileFlag: 'text-file', description: 'Read text from file' }, + ], + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd([], makeMockConfig() as never); + + await expect(cmd.run()).rejects.toThrow('Either --text or --text-file is required'); + }); + + it('handles files with special characters (quotes, backticks, $)', async () => { + const content = 'Use `code` and "quotes" and $(command) and < { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { + ...originalEnv, + CASCADE_REPO_OWNER: 'env-owner', + CASCADE_REPO_NAME: 'env-repo', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('resolves owner/repo from CASCADE_REPO_OWNER/CASCADE_REPO_NAME env vars', async () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + parameters: { + owner: { type: 'string', describe: 'Repo owner', required: true }, + repo: { type: 'string', describe: 'Repo name', required: true }, + prNumber: { type: 'number', describe: 'PR number', required: true }, + }, + cli: { + autoResolved: [ + { paramName: 'owner', envVar: 'CASCADE_REPO_OWNER', resolvedFrom: 'git-remote' }, + { paramName: 'repo', envVar: 'CASCADE_REPO_NAME', resolvedFrom: 'git-remote' }, + ], + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--prNumber', '5'], makeMockConfig() as never); + await cmd.run(); + + expect(coreFn).toHaveBeenCalledWith( + expect.objectContaining({ owner: 'env-owner', repo: 'env-repo', prNumber: 5 }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// JSON output format +// --------------------------------------------------------------------------- +describe('cliCommandFactory — JSON output format', () => { + it('outputs { success: true, data: result } on success', async () => { + const coreFn = vi.fn().mockResolvedValue({ id: 'result-1' }); + const def = makeToolDef({ + parameters: { + name: { type: 'string', describe: 'A name', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--name', 'test'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output).toEqual({ success: true, data: { id: 'result-1' } }); + }); + + it('outputs { success: false, error: message } on error', async () => { + const coreFn = vi.fn().mockRejectedValue(new Error('Something went wrong')); + const def = makeToolDef({ + parameters: { + name: { type: 'string', describe: 'A name', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--name', 'test'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + + // Should not throw (error is caught internally) but may call this.exit(1) + try { + await cmd.run(); + } catch { + // this.exit(1) throws in test environment — that's expected + } + + expect(logSpy).toHaveBeenCalledTimes(1); + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output).toEqual({ success: false, error: 'Something went wrong' }); + }); + + it('handles non-Error throws and outputs error string', async () => { + const coreFn = vi.fn().mockRejectedValue('string error'); + const def = makeToolDef({ + parameters: { + name: { type: 'string', describe: 'A name', required: true }, + }, + }); + const Cmd = createCLICommand(def, coreFn); + const cmd = new Cmd(['--name', 'test'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + + try { + await cmd.run(); + } catch { + // this.exit(1) may throw + } + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(false); + expect(output.error).toBe('string error'); + }); + + it('includes the description from the ToolDefinition', () => { + const coreFn = vi.fn().mockResolvedValue('ok'); + const def = makeToolDef({ + description: 'My test tool description', + parameters: {}, + }); + const Cmd = createCLICommand(def, coreFn); + + // Access static property directly on the class + expect((Cmd as { description?: string }).description).toBe('My test tool description'); + }); +}); diff --git a/tests/unit/cli/pm/pm-commands.test.ts b/tests/unit/cli/pm/pm-commands.test.ts new file mode 100644 index 00000000..32dfd25e --- /dev/null +++ b/tests/unit/cli/pm/pm-commands.test.ts @@ -0,0 +1,337 @@ +/** + * Unit tests for PM CLI commands. + * + * Tests the CLI → core function wiring for: + * - read-work-item + * - list-work-items + * - move-work-item + * - delete-checklist-item + * - update-checklist-item + * - create-work-item (basic param-passing) + * - post-comment (basic param-passing) + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock credential-scoping dependencies (same as file-input-flags.test.ts) +// --------------------------------------------------------------------------- +vi.mock('../../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), +})); +vi.mock('../../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn( + (_creds: { apiKey: string; token: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn( + (_creds: { email: string; apiToken: string; baseUrl: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../../src/pm/index.js', () => ({ + createPMProvider: vi.fn(() => ({})), + withPMProvider: vi.fn((_provider: unknown, fn: () => Promise) => fn()), +})); + +// --------------------------------------------------------------------------- +// Mock all PM gadget core functions +// --------------------------------------------------------------------------- +vi.mock('../../../../src/gadgets/pm/core/readWorkItem.js', () => ({ + readWorkItem: vi.fn().mockResolvedValue({ id: 'wi-1', title: 'Work Item' }), +})); +vi.mock('../../../../src/gadgets/pm/core/listWorkItems.js', () => ({ + listWorkItems: vi.fn().mockResolvedValue([{ id: 'wi-1' }]), +})); +vi.mock('../../../../src/gadgets/pm/core/moveWorkItem.js', () => ({ + moveWorkItem: vi.fn().mockResolvedValue({ id: 'wi-1', status: 'moved' }), +})); +vi.mock('../../../../src/gadgets/pm/core/deleteChecklistItem.js', () => ({ + deleteChecklistItem: vi.fn().mockResolvedValue({ success: true }), +})); +vi.mock('../../../../src/gadgets/pm/core/updateChecklistItem.js', () => ({ + updateChecklistItem: vi.fn().mockResolvedValue({ state: 'complete' }), +})); +vi.mock('../../../../src/gadgets/pm/core/createWorkItem.js', () => ({ + createWorkItem: vi.fn().mockResolvedValue({ id: 'wi-new' }), +})); +vi.mock('../../../../src/gadgets/pm/core/postComment.js', () => ({ + postComment: vi.fn().mockResolvedValue({ id: 'comment-1' }), +})); + +import { createWorkItem } from '../../../../src/gadgets/pm/core/createWorkItem.js'; +import { deleteChecklistItem } from '../../../../src/gadgets/pm/core/deleteChecklistItem.js'; +import { listWorkItems } from '../../../../src/gadgets/pm/core/listWorkItems.js'; +import { moveWorkItem } from '../../../../src/gadgets/pm/core/moveWorkItem.js'; +import { postComment } from '../../../../src/gadgets/pm/core/postComment.js'; +import { readWorkItem } from '../../../../src/gadgets/pm/core/readWorkItem.js'; +import { updateChecklistItem } from '../../../../src/gadgets/pm/core/updateChecklistItem.js'; + +import CreateWorkItem from '../../../../src/cli/pm/create-work-item.js'; +import DeleteChecklistItem from '../../../../src/cli/pm/delete-checklist-item.js'; +import ListWorkItems from '../../../../src/cli/pm/list-work-items.js'; +import MoveWorkItem from '../../../../src/cli/pm/move-work-item.js'; +import PostComment from '../../../../src/cli/pm/post-comment.js'; +import ReadWorkItem from '../../../../src/cli/pm/read-work-item.js'; +import UpdateChecklistItem from '../../../../src/cli/pm/update-checklist-item.js'; + +/** Create a fresh minimal oclif config to satisfy this.parse() in each test */ +function makeMockConfig() { + return { runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }) }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// read-work-item +// --------------------------------------------------------------------------- +describe('ReadWorkItem command', () => { + it('passes workItemId and default includeComments to readWorkItem', async () => { + // Default value of includeComments is true from the definition + const cmd = new ReadWorkItem(['--workItemId', 'card-123'], makeMockConfig() as never); + await cmd.run(); + + expect(readWorkItem).toHaveBeenCalledWith('card-123', true); + }); + + it('passes includeComments=true when --includeComments is set', async () => { + const cmd = new ReadWorkItem( + ['--workItemId', 'card-123', '--includeComments'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(readWorkItem).toHaveBeenCalledWith('card-123', true); + }); + + it('passes includeComments=false when --no-includeComments is set', async () => { + const cmd = new ReadWorkItem( + ['--workItemId', 'card-123', '--no-includeComments'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(readWorkItem).toHaveBeenCalledWith('card-123', false); + }); + + it('outputs JSON success result', async () => { + vi.mocked(readWorkItem).mockResolvedValue({ id: 'card-123', title: 'Test Card' } as never); + const cmd = new ReadWorkItem(['--workItemId', 'card-123'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"success":true')); + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 'card-123', title: 'Test Card' }); + }); +}); + +// --------------------------------------------------------------------------- +// list-work-items +// --------------------------------------------------------------------------- +describe('ListWorkItems command', () => { + it('passes containerId to listWorkItems', async () => { + const cmd = new ListWorkItems(['--containerId', 'list-456'], makeMockConfig() as never); + await cmd.run(); + + expect(listWorkItems).toHaveBeenCalledWith('list-456'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(listWorkItems).mockResolvedValue([{ id: 'wi-1' }, { id: 'wi-2' }] as never); + const cmd = new ListWorkItems(['--containerId', 'list-456'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual([{ id: 'wi-1' }, { id: 'wi-2' }]); + }); +}); + +// --------------------------------------------------------------------------- +// move-work-item +// --------------------------------------------------------------------------- +describe('MoveWorkItem command', () => { + it('passes workItemId and destination to moveWorkItem', async () => { + const cmd = new MoveWorkItem( + ['--workItemId', 'card-123', '--destination', 'list-done'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(moveWorkItem).toHaveBeenCalledWith({ + workItemId: 'card-123', + destination: 'list-done', + }); + }); + + it('works with JIRA status destinations', async () => { + const cmd = new MoveWorkItem( + ['--workItemId', 'PROJ-42', '--destination', 'In Progress'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(moveWorkItem).toHaveBeenCalledWith({ + workItemId: 'PROJ-42', + destination: 'In Progress', + }); + }); + + it('outputs JSON success result', async () => { + vi.mocked(moveWorkItem).mockResolvedValue({ id: 'card-123', moved: true } as never); + const cmd = new MoveWorkItem( + ['--workItemId', 'card-123', '--destination', 'list-done'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// delete-checklist-item +// --------------------------------------------------------------------------- +describe('DeleteChecklistItem command', () => { + it('passes workItemId and checkItemId to deleteChecklistItem', async () => { + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-123', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(deleteChecklistItem).toHaveBeenCalledWith('card-123', 'item-456'); + }); + + it('works with JIRA subtask key format', async () => { + const cmd = new DeleteChecklistItem( + ['--workItemId', 'PROJ-42', '--checkItemId', 'PROJ-48'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(deleteChecklistItem).toHaveBeenCalledWith('PROJ-42', 'PROJ-48'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(deleteChecklistItem).mockResolvedValue({ success: true } as never); + const cmd = new DeleteChecklistItem( + ['--workItemId', 'card-123', '--checkItemId', 'item-456'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// update-checklist-item +// --------------------------------------------------------------------------- +describe('UpdateChecklistItem command', () => { + it('passes workItemId, checkItemId, and state=true for "complete"', async () => { + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-123', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(updateChecklistItem).toHaveBeenCalledWith('card-123', 'item-456', true); + }); + + it('passes workItemId, checkItemId, and state=false for "incomplete"', async () => { + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-123', '--checkItemId', 'item-456', '--state', 'incomplete'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(updateChecklistItem).toHaveBeenCalledWith('card-123', 'item-456', false); + }); + + it('outputs JSON success result', async () => { + vi.mocked(updateChecklistItem).mockResolvedValue({ state: 'complete' } as never); + const cmd = new UpdateChecklistItem( + ['--workItemId', 'card-123', '--checkItemId', 'item-456', '--state', 'complete'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ state: 'complete' }); + }); +}); + +// --------------------------------------------------------------------------- +// create-work-item (basic param-passing test) +// --------------------------------------------------------------------------- +describe('CreateWorkItem command (basic params)', () => { + it('passes containerId and title to createWorkItem', async () => { + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(createWorkItem).toHaveBeenCalledWith( + expect.objectContaining({ + containerId: 'list-1', + title: 'New Card', + }), + ); + }); + + it('outputs JSON success result', async () => { + vi.mocked(createWorkItem).mockResolvedValue({ id: 'new-wi' } as never); + const cmd = new CreateWorkItem( + ['--containerId', 'list-1', '--title', 'New Card'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 'new-wi' }); + }); +}); + +// --------------------------------------------------------------------------- +// post-comment (basic param-passing test) +// --------------------------------------------------------------------------- +describe('PostComment command (basic params)', () => { + it('passes workItemId and text to postComment', async () => { + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Hello world'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(postComment).toHaveBeenCalledWith('card-1', 'Hello world'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(postComment).mockResolvedValue({ id: 'comment-new' } as never); + const cmd = new PostComment( + ['--workItemId', 'card-1', '--text', 'Hello world'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 'comment-new' }); + }); +}); diff --git a/tests/unit/cli/scm/scm-commands.test.ts b/tests/unit/cli/scm/scm-commands.test.ts new file mode 100644 index 00000000..e8918ee3 --- /dev/null +++ b/tests/unit/cli/scm/scm-commands.test.ts @@ -0,0 +1,401 @@ +/** + * Unit tests for SCM CLI commands. + * + * Tests the CLI → core function wiring for: + * - get-pr-details + * - get-pr-diff + * - get-pr-checks + * - get-pr-comments + * - get-ci-run-logs + * - post-pr-comment (owner/repo auto-resolution) + * - reply-to-review-comment + * - update-pr-comment + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock credential-scoping dependencies +// --------------------------------------------------------------------------- +vi.mock('../../../../src/github/client.js', () => ({ + withGitHubToken: vi.fn((_token: string, fn: () => Promise) => fn()), +})); +vi.mock('../../../../src/trello/client.js', () => ({ + withTrelloCredentials: vi.fn( + (_creds: { apiKey: string; token: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../../src/jira/client.js', () => ({ + withJiraCredentials: vi.fn( + (_creds: { email: string; apiToken: string; baseUrl: string }, fn: () => Promise) => fn(), + ), +})); +vi.mock('../../../../src/pm/index.js', () => ({ + createPMProvider: vi.fn(() => ({})), + withPMProvider: vi.fn((_provider: unknown, fn: () => Promise) => fn()), +})); + +// --------------------------------------------------------------------------- +// Mock all SCM gadget core functions +// --------------------------------------------------------------------------- +vi.mock('../../../../src/gadgets/github/core/getPRDetails.js', () => ({ + getPRDetails: vi.fn().mockResolvedValue({ number: 42, title: 'My PR' }), +})); +vi.mock('../../../../src/gadgets/github/core/getPRDiff.js', () => ({ + getPRDiff: vi.fn().mockResolvedValue([{ filename: 'src/foo.ts', additions: 5 }]), +})); +vi.mock('../../../../src/gadgets/github/core/getPRChecks.js', () => ({ + getPRChecks: vi.fn().mockResolvedValue([{ name: 'CI', status: 'completed' }]), +})); +vi.mock('../../../../src/gadgets/github/core/getPRComments.js', () => ({ + getPRComments: vi.fn().mockResolvedValue([{ id: 1, body: 'Nice work' }]), +})); +vi.mock('../../../../src/gadgets/github/core/getCIRunLogs.js', () => ({ + getCIRunLogs: vi.fn().mockResolvedValue({ failedJobs: [] }), +})); +vi.mock('../../../../src/gadgets/github/core/postPRComment.js', () => ({ + postPRComment: vi.fn().mockResolvedValue({ id: 100 }), +})); +vi.mock('../../../../src/gadgets/github/core/replyToReviewComment.js', () => ({ + replyToReviewComment: vi.fn().mockResolvedValue({ id: 200 }), +})); +vi.mock('../../../../src/gadgets/github/core/updatePRComment.js', () => ({ + updatePRComment: vi.fn().mockResolvedValue({ id: 300, body: 'Updated' }), +})); + +import { getCIRunLogs } from '../../../../src/gadgets/github/core/getCIRunLogs.js'; +import { getPRChecks } from '../../../../src/gadgets/github/core/getPRChecks.js'; +import { getPRComments } from '../../../../src/gadgets/github/core/getPRComments.js'; +import { getPRDetails } from '../../../../src/gadgets/github/core/getPRDetails.js'; +import { getPRDiff } from '../../../../src/gadgets/github/core/getPRDiff.js'; +import { postPRComment } from '../../../../src/gadgets/github/core/postPRComment.js'; +import { replyToReviewComment } from '../../../../src/gadgets/github/core/replyToReviewComment.js'; +import { updatePRComment } from '../../../../src/gadgets/github/core/updatePRComment.js'; + +import GetCIRunLogs from '../../../../src/cli/scm/get-ci-run-logs.js'; +import GetPRChecks from '../../../../src/cli/scm/get-pr-checks.js'; +import GetPRComments from '../../../../src/cli/scm/get-pr-comments.js'; +import GetPRDetails from '../../../../src/cli/scm/get-pr-details.js'; +import GetPRDiff from '../../../../src/cli/scm/get-pr-diff.js'; +import PostPRComment from '../../../../src/cli/scm/post-pr-comment.js'; +import ReplyToReviewComment from '../../../../src/cli/scm/reply-to-review-comment.js'; +import UpdatePRComment from '../../../../src/cli/scm/update-pr-comment.js'; + +/** Create a fresh minimal oclif config to satisfy this.parse() in each test */ +function makeMockConfig() { + return { runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }) }; +} + +const originalEnv = process.env; + +beforeEach(() => { + // Set env vars for owner/repo auto-resolution in each test + process.env = { + ...originalEnv, + CASCADE_REPO_OWNER: 'owner', + CASCADE_REPO_NAME: 'repo', + }; +}); + +afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// get-pr-details +// --------------------------------------------------------------------------- +describe('GetPRDetails command', () => { + it('passes owner, repo, prNumber to getPRDetails', async () => { + const cmd = new GetPRDetails(['--prNumber', '42'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRDetails).toHaveBeenCalledWith('owner', 'repo', 42); + }); + + it('resolves owner/repo from CASCADE_REPO_OWNER/CASCADE_REPO_NAME env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'my-org'; + process.env.CASCADE_REPO_NAME = 'my-repo'; + const cmd = new GetPRDetails(['--prNumber', '10'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRDetails).toHaveBeenCalledWith('my-org', 'my-repo', 10); + }); + + it('outputs JSON success result', async () => { + vi.mocked(getPRDetails).mockResolvedValue({ number: 42, title: 'Test PR' } as never); + const cmd = new GetPRDetails(['--prNumber', '42'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ number: 42, title: 'Test PR' }); + }); +}); + +// --------------------------------------------------------------------------- +// get-pr-diff +// --------------------------------------------------------------------------- +describe('GetPRDiff command', () => { + it('passes owner, repo, prNumber to getPRDiff', async () => { + const cmd = new GetPRDiff(['--prNumber', '15'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRDiff).toHaveBeenCalledWith('owner', 'repo', 15); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'acme'; + process.env.CASCADE_REPO_NAME = 'webapp'; + const cmd = new GetPRDiff(['--prNumber', '99'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRDiff).toHaveBeenCalledWith('acme', 'webapp', 99); + }); + + it('outputs JSON success result', async () => { + vi.mocked(getPRDiff).mockResolvedValue([{ filename: 'test.ts' }] as never); + const cmd = new GetPRDiff(['--prNumber', '15'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// get-pr-checks +// --------------------------------------------------------------------------- +describe('GetPRChecks command', () => { + it('passes owner, repo, prNumber to getPRChecks', async () => { + const cmd = new GetPRChecks(['--prNumber', '7'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRChecks).toHaveBeenCalledWith('owner', 'repo', 7); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'org-x'; + process.env.CASCADE_REPO_NAME = 'project-y'; + const cmd = new GetPRChecks(['--prNumber', '21'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRChecks).toHaveBeenCalledWith('org-x', 'project-y', 21); + }); + + it('outputs JSON success result', async () => { + vi.mocked(getPRChecks).mockResolvedValue([{ name: 'CI', conclusion: 'success' }] as never); + const cmd = new GetPRChecks(['--prNumber', '7'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual([{ name: 'CI', conclusion: 'success' }]); + }); +}); + +// --------------------------------------------------------------------------- +// get-pr-comments +// --------------------------------------------------------------------------- +describe('GetPRComments command', () => { + it('passes owner, repo, prNumber to getPRComments', async () => { + const cmd = new GetPRComments(['--prNumber', '33'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRComments).toHaveBeenCalledWith('owner', 'repo', 33); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'company'; + process.env.CASCADE_REPO_NAME = 'app'; + const cmd = new GetPRComments(['--prNumber', '5'], makeMockConfig() as never); + await cmd.run(); + + expect(getPRComments).toHaveBeenCalledWith('company', 'app', 5); + }); + + it('outputs JSON success result', async () => { + vi.mocked(getPRComments).mockResolvedValue([{ id: 1, body: 'LGTM' }] as never); + const cmd = new GetPRComments(['--prNumber', '33'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// get-ci-run-logs +// --------------------------------------------------------------------------- +describe('GetCIRunLogs command', () => { + it('passes owner, repo, ref to getCIRunLogs', async () => { + const cmd = new GetCIRunLogs(['--ref', 'abc1234567890def'], makeMockConfig() as never); + await cmd.run(); + + expect(getCIRunLogs).toHaveBeenCalledWith('owner', 'repo', 'abc1234567890def'); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'my-user'; + process.env.CASCADE_REPO_NAME = 'my-project'; + const cmd = new GetCIRunLogs(['--ref', 'deadbeef'], makeMockConfig() as never); + await cmd.run(); + + expect(getCIRunLogs).toHaveBeenCalledWith('my-user', 'my-project', 'deadbeef'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(getCIRunLogs).mockResolvedValue({ failedJobs: ['unit-tests'] } as never); + const cmd = new GetCIRunLogs(['--ref', 'abc123'], makeMockConfig() as never); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ failedJobs: ['unit-tests'] }); + }); +}); + +// --------------------------------------------------------------------------- +// post-pr-comment (owner/repo auto-resolution) +// --------------------------------------------------------------------------- +describe('PostPRComment command — owner/repo auto-resolution', () => { + it('resolves owner/repo from CASCADE_REPO_OWNER/CASCADE_REPO_NAME env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'env-owner'; + process.env.CASCADE_REPO_NAME = 'env-repo'; + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Test comment'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(postPRComment).toHaveBeenCalledWith('env-owner', 'env-repo', 42, 'Test comment'); + }); + + it('passes prNumber and body to postPRComment', async () => { + const cmd = new PostPRComment( + ['--prNumber', '7', '--body', 'Working on it...'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(postPRComment).toHaveBeenCalledWith('owner', 'repo', 7, 'Working on it...'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(postPRComment).mockResolvedValue({ id: 999 } as never); + const cmd = new PostPRComment( + ['--prNumber', '42', '--body', 'Done!'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 999 }); + }); +}); + +// --------------------------------------------------------------------------- +// reply-to-review-comment +// --------------------------------------------------------------------------- +describe('ReplyToReviewComment command', () => { + it('passes owner, repo, prNumber, commentId, body to replyToReviewComment', async () => { + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '123456', '--body', 'Fixed the issue'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(replyToReviewComment).toHaveBeenCalledWith( + 'owner', + 'repo', + 42, + 123456, + 'Fixed the issue', + ); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'acme-org'; + process.env.CASCADE_REPO_NAME = 'acme-app'; + const cmd = new ReplyToReviewComment( + ['--prNumber', '10', '--commentId', '9876', '--body', 'Thanks for the feedback'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(replyToReviewComment).toHaveBeenCalledWith( + 'acme-org', + 'acme-app', + 10, + 9876, + 'Thanks for the feedback', + ); + }); + + it('outputs JSON success result', async () => { + vi.mocked(replyToReviewComment).mockResolvedValue({ id: 77 } as never); + const cmd = new ReplyToReviewComment( + ['--prNumber', '42', '--commentId', '123', '--body', 'Fixed!'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 77 }); + }); +}); + +// --------------------------------------------------------------------------- +// update-pr-comment +// --------------------------------------------------------------------------- +describe('UpdatePRComment command', () => { + it('passes owner, repo, commentId, body to updatePRComment', async () => { + const cmd = new UpdatePRComment( + ['--commentId', '111222333', '--body', 'Updated comment body'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(updatePRComment).toHaveBeenCalledWith( + 'owner', + 'repo', + 111222333, + 'Updated comment body', + ); + }); + + it('resolves owner/repo from env vars', async () => { + process.env.CASCADE_REPO_OWNER = 'big-co'; + process.env.CASCADE_REPO_NAME = 'platform'; + const cmd = new UpdatePRComment( + ['--commentId', '555', '--body', 'New content'], + makeMockConfig() as never, + ); + await cmd.run(); + + expect(updatePRComment).toHaveBeenCalledWith('big-co', 'platform', 555, 'New content'); + }); + + it('outputs JSON success result', async () => { + vi.mocked(updatePRComment).mockResolvedValue({ id: 555, body: 'New content' } as never); + const cmd = new UpdatePRComment( + ['--commentId', '555', '--body', 'New content'], + makeMockConfig() as never, + ); + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + const output = JSON.parse(logSpy.mock.calls[0][0] as string); + expect(output.success).toBe(true); + expect(output.data).toEqual({ id: 555, body: 'New content' }); + }); +});