From eb60fb8b176efa0777cd5285c80bb50ee9fea643 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 14:10:59 +0000 Subject: [PATCH] fix(cli): add email category support to integration-credential commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three CLI integration-credential commands (list, set, remove) hardcoded `options: ['pm', 'scm']`, actively rejecting `--category email` even though the tRPC API already accepts all three categories. Users with email integrations had no CLI path to inspect or manage linked credentials. Changes: - `projects/overrides.ts`: add 'email' to category options; default sweep now includes pm + scm + email - `projects/override-set.ts`: add 'email' to category options and type cast - `projects/override-rm.ts`: add 'email' to category options and type cast - `email/integration-set.ts`: fix IMAP guidance note to include `--category email` so users land on the right command Also fix pre-existing `noExcessiveCognitiveComplexity` lint warning in `agent-execution.ts` by extracting the validation failure handling block into a `notifyValidationFailure` helper (complexity 17 → 12). Tests: - New `tests/unit/cli/dashboard/projects/integration-credentials.test.ts` covers all three categories for list/set/remove, plus rejection of unknown values and JSON output - New `tests/unit/cli/dashboard/email/integration-set.test.ts` covers gmail/imap upsert, custom config JSON, and the IMAP credential guidance - Extended `tests/unit/api/routers/projects.test.ts` with email category cases for all three integrationCredentials router procedures Co-Authored-By: Claude Sonnet 4.6 --- src/cli/dashboard/email/integration-set.ts | 2 +- src/cli/dashboard/projects/override-rm.ts | 6 +- src/cli/dashboard/projects/override-set.ts | 6 +- src/cli/dashboard/projects/overrides.ts | 8 +- src/triggers/shared/agent-execution.ts | 59 +++- tests/unit/api/routers/projects.test.ts | 50 +++ .../dashboard/email/integration-set.test.ts | 128 ++++++++ .../projects/integration-credentials.test.ts | 293 ++++++++++++++++++ 8 files changed, 524 insertions(+), 28 deletions(-) create mode 100644 tests/unit/cli/dashboard/email/integration-set.test.ts create mode 100644 tests/unit/cli/dashboard/projects/integration-credentials.test.ts diff --git a/src/cli/dashboard/email/integration-set.ts b/src/cli/dashboard/email/integration-set.ts index c0155009..14e95545 100644 --- a/src/cli/dashboard/email/integration-set.ts +++ b/src/cli/dashboard/email/integration-set.ts @@ -50,7 +50,7 @@ export default class EmailIntegrationSet extends DashboardCommand { this.log('Note: Run "cascade email oauth" to authenticate Gmail.'); } else { this.log( - 'Note: Link IMAP credentials using "cascade projects integration-credential-set".', + 'Note: Link IMAP credentials using "cascade projects integration-credential-set --category email".', ); } } catch (err) { diff --git a/src/cli/dashboard/projects/override-rm.ts b/src/cli/dashboard/projects/override-rm.ts index 7a19dc1c..4e68116a 100644 --- a/src/cli/dashboard/projects/override-rm.ts +++ b/src/cli/dashboard/projects/override-rm.ts @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Integration category (pm or scm)', + description: 'Integration category (pm, scm, or email)', required: true, - options: ['pm', 'scm'], + options: ['pm', 'scm', 'email'], }), role: Flags.string({ description: 'Credential role to unlink (e.g. api_key, token, implementer_token)', @@ -29,7 +29,7 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand { try { await this.client.projects.integrationCredentials.remove.mutate({ projectId: args.id, - category: flags.category as 'pm' | 'scm', + category: flags.category as 'pm' | 'scm' | 'email', role: flags.role, }); diff --git a/src/cli/dashboard/projects/override-set.ts b/src/cli/dashboard/projects/override-set.ts index 9143fcac..2c315c11 100644 --- a/src/cli/dashboard/projects/override-set.ts +++ b/src/cli/dashboard/projects/override-set.ts @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Integration category (pm or scm)', + description: 'Integration category (pm, scm, or email)', required: true, - options: ['pm', 'scm'], + options: ['pm', 'scm', 'email'], }), role: Flags.string({ description: 'Credential role (e.g. api_key, token, implementer_token)', @@ -30,7 +30,7 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand { try { await this.client.projects.integrationCredentials.set.mutate({ projectId: args.id, - category: flags.category as 'pm' | 'scm', + category: flags.category as 'pm' | 'scm' | 'email', role: flags.role, credentialId: flags['credential-id'], }); diff --git a/src/cli/dashboard/projects/overrides.ts b/src/cli/dashboard/projects/overrides.ts index b4050182..5fb6e72e 100644 --- a/src/cli/dashboard/projects/overrides.ts +++ b/src/cli/dashboard/projects/overrides.ts @@ -13,8 +13,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Filter by integration category (pm or scm)', - options: ['pm', 'scm'], + description: 'Filter by integration category (pm, scm, or email)', + options: ['pm', 'scm', 'email'], }), }; @@ -23,8 +23,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand { try { const categories = flags.category - ? [flags.category as 'pm' | 'scm'] - : (['pm', 'scm'] as const); + ? [flags.category as 'pm' | 'scm' | 'email'] + : (['pm', 'scm', 'email'] as const); const allCreds: Array> = []; diff --git a/src/triggers/shared/agent-execution.ts b/src/triggers/shared/agent-execution.ts index a4f24f62..5ac4e307 100644 --- a/src/triggers/shared/agent-execution.ts +++ b/src/triggers/shared/agent-execution.ts @@ -7,7 +7,11 @@ import { handleAgentResultArtifacts } from './agent-result-handler.js'; import { checkBudgetExceeded } from './budget.js'; import { triggerDebugAnalysis } from './debug-runner.js'; import { shouldTriggerDebug } from './debug-trigger.js'; -import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; +import { + type ValidationResult, + formatValidationErrors, + validateIntegrations, +} from './integration-validation.js'; /** * Configuration for source-specific behavior in the agent execution pipeline. @@ -124,6 +128,36 @@ async function runPostAgentLifecycle( } } +/** + * Notify PM and GitHub when integration validation fails before the agent runs. + */ +async function notifyValidationFailure( + result: TriggerResult, + validation: ValidationResult, + lifecycle: PMLifecycleManager, + executionConfig: AgentExecutionConfig, + agentType: string, + projectId: string, +): Promise { + const errorMessage = formatValidationErrors(validation); + logger.error('Integration validation failed', { + agentType, + projectId, + errors: validation.errors, + }); + + // Only notify via PM if PM validation passed (otherwise PM isn't configured) + const pmFailed = validation.errors.some((e) => e.category === 'pm'); + if (result.workItemId && !pmFailed) { + await lifecycle.handleFailure(result.workItemId, errorMessage); + } + + // Call onFailure callback (for GitHub PR updates) + if (executionConfig.onFailure) { + await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage }); + } +} + /** * Shared agent execution pipeline. * @@ -164,23 +198,14 @@ export async function runAgentExecutionPipeline( // Pre-flight integration validation const validation = await validateIntegrations(project.id, agentType); if (!validation.valid) { - const errorMessage = formatValidationErrors(validation); - logger.error('Integration validation failed', { + await notifyValidationFailure( + result, + validation, + lifecycle, + executionConfig, agentType, - projectId: project.id, - errors: validation.errors, - }); - - // Only notify via PM if PM validation passed (otherwise PM isn't configured) - const pmFailed = validation.errors.some((e) => e.category === 'pm'); - if (result.workItemId && !pmFailed) { - await lifecycle.handleFailure(result.workItemId, errorMessage); - } - - // Call onFailure callback (for GitHub PR updates) - if (executionConfig.onFailure) { - await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage }); - } + project.id, + ); return; } diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts index 1384cef5..6542f2b8 100644 --- a/tests/unit/api/routers/projects.test.ts +++ b/tests/unit/api/routers/projects.test.ts @@ -339,6 +339,22 @@ describe('projectsRouter', () => { expect(result).toEqual([]); }); + + it('lists email credentials', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 }); + const creds = [{ role: 'gmail_refresh_token', credentialId: 7, credentialName: 'Gmail' }]; + mockListIntegrationCredentials.mockResolvedValue(creds); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + const result = await caller.integrationCredentials.list({ + projectId: 'p1', + category: 'email', + }); + + expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email'); + expect(result).toEqual(creds); + }); }); describe('set', () => { @@ -359,6 +375,24 @@ describe('projectsRouter', () => { expect(mockSetIntegrationCredential).toHaveBeenCalledWith(10, 'api_key', 42); }); + it('sets email credential role', async () => { + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // credential + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 }); + mockSetIntegrationCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrationCredentials.set({ + projectId: 'p1', + category: 'email', + role: 'imap_password', + credentialId: 7, + }); + + expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email'); + expect(mockSetIntegrationCredential).toHaveBeenCalledWith(20, 'imap_password', 7); + }); + it('throws NOT_FOUND when credential belongs to different org', async () => { mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project OK mockDbWhere.mockResolvedValueOnce([{ orgId: 'different-org' }]); // credential not owned @@ -390,6 +424,22 @@ describe('projectsRouter', () => { expect(mockRemoveIntegrationCredential).toHaveBeenCalledWith(10, 'api_key'); }); + + it('removes email credential role', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 }); + mockRemoveIntegrationCredential.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.integrationCredentials.remove({ + projectId: 'p1', + category: 'email', + role: 'imap_password', + }); + + expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email'); + expect(mockRemoveIntegrationCredential).toHaveBeenCalledWith(20, 'imap_password'); + }); }); }); }); diff --git a/tests/unit/cli/dashboard/email/integration-set.test.ts b/tests/unit/cli/dashboard/email/integration-set.test.ts new file mode 100644 index 00000000..a33cfb7a --- /dev/null +++ b/tests/unit/cli/dashboard/email/integration-set.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLoadConfig = vi.fn(); +const mockCreateDashboardClient = vi.fn(); + +vi.mock('../../../../../src/cli/dashboard/_shared/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), +})); + +vi.mock('../../../../../src/cli/dashboard/_shared/client.js', () => ({ + createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args), +})); + +vi.mock('chalk', () => ({ + default: { + bold: (s: string) => s, + blue: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + yellow: (s: string) => s, + dim: (s: string) => s, + }, +})); + +import EmailIntegrationSet from '../../../../../src/cli/dashboard/email/integration-set.js'; + +// oclif's Command.parse() calls this.config.runHook internally +const oclifConfig = { + runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }), +}; + +function makeClient() { + return { + projects: { + integrations: { + upsert: { mutate: vi.fn().mockResolvedValue(undefined) }, + }, + }, + }; +} + +const baseConfig = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + +describe('EmailIntegrationSet', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('upserts a gmail integration and prints oauth guidance', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const logMessages: string[] = []; + const cmd = new EmailIntegrationSet( + ['my-project', '--provider', 'gmail'], + oclifConfig as never, + ); + vi.spyOn(cmd, 'log').mockImplementation((msg?: string) => { + if (msg) logMessages.push(msg); + }); + + await cmd.run(); + + expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'my-project', + category: 'email', + provider: 'gmail', + }), + ); + + const oauthGuidance = logMessages.find((m) => m.includes('cascade email oauth')); + expect(oauthGuidance).toBeDefined(); + }); + + it('upserts an imap integration and prints credential-set guidance with --category email', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const logMessages: string[] = []; + const cmd = new EmailIntegrationSet(['my-project', '--provider', 'imap'], oclifConfig as never); + vi.spyOn(cmd, 'log').mockImplementation((msg?: string) => { + if (msg) logMessages.push(msg); + }); + + await cmd.run(); + + expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'my-project', + category: 'email', + provider: 'imap', + }), + ); + + const credGuidance = logMessages.find((m) => m.includes('integration-credential-set')); + expect(credGuidance).toBeDefined(); + expect(credGuidance).toContain('--category email'); + }); + + it('upserts with custom config JSON', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new EmailIntegrationSet( + ['my-project', '--provider', 'imap', '--config', '{"host":"mail.example.com"}'], + oclifConfig as never, + ); + vi.spyOn(cmd, 'log').mockImplementation(() => {}); + await cmd.run(); + + expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + config: { host: 'mail.example.com' }, + }), + ); + }); + + it('errors on invalid JSON config', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new EmailIntegrationSet( + ['my-project', '--provider', 'imap', '--config', 'not-json'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); +}); diff --git a/tests/unit/cli/dashboard/projects/integration-credentials.test.ts b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts new file mode 100644 index 00000000..2fc798e9 --- /dev/null +++ b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLoadConfig = vi.fn(); +const mockCreateDashboardClient = vi.fn(); + +vi.mock('../../../../../src/cli/dashboard/_shared/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), +})); + +vi.mock('../../../../../src/cli/dashboard/_shared/client.js', () => ({ + createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args), +})); + +vi.mock('chalk', () => ({ + default: { + bold: (s: string) => s, + blue: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + yellow: (s: string) => s, + dim: (s: string) => s, + }, +})); + +import ProjectsIntegrationCredentialRm from '../../../../../src/cli/dashboard/projects/override-rm.js'; +import ProjectsIntegrationCredentialSet from '../../../../../src/cli/dashboard/projects/override-set.js'; +import ProjectsIntegrationCredentials from '../../../../../src/cli/dashboard/projects/overrides.js'; + +// oclif's Command.parse() calls this.config.runHook internally +const oclifConfig = { + runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }), +}; + +function makeClient(overrides: Record = {}) { + return { + projects: { + integrationCredentials: { + list: { query: vi.fn().mockResolvedValue([]) }, + set: { mutate: vi.fn().mockResolvedValue(undefined) }, + remove: { mutate: vi.fn().mockResolvedValue(undefined) }, + }, + }, + ...overrides, + }; +} + +const baseConfig = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + +describe('ProjectsIntegrationCredentials (overrides)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('queries pm, scm, and email categories by default', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentials(['my-project'], oclifConfig as never); + await cmd.run(); + + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(3); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + }); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'scm', + }); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'email', + }); + }); + + it('queries only email when --category email is passed', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentials( + ['my-project', '--category', 'email'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(1); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'email', + }); + }); + + it('queries only pm when --category pm is passed', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentials( + ['my-project', '--category', 'pm'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(1); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + }); + }); + + it('queries only scm when --category scm is passed', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentials( + ['my-project', '--category', 'scm'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(1); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'scm', + }); + }); + + it('rejects unknown category values', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationCredentials( + ['my-project', '--category', 'billing'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); + + it('outputs email creds in JSON when --json flag is set', async () => { + const creds = [{ role: 'gmail_refresh_token', credentialId: 5, credentialName: 'Gmail' }]; + const client = makeClient(); + (client.projects.integrationCredentials.list.query as ReturnType) + .mockResolvedValueOnce([]) // pm + .mockResolvedValueOnce([]) // scm + .mockResolvedValueOnce(creds); // email + mockCreateDashboardClient.mockReturnValue(client); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cmd = new ProjectsIntegrationCredentials(['my-project', '--json'], oclifConfig as never); + await cmd.run(); + + const output = JSON.parse(consoleSpy.mock.calls[0][0] as string) as unknown[]; + expect(output).toEqual( + expect.arrayContaining([expect.objectContaining({ category: 'email' })]), + ); + consoleSpy.mockRestore(); + }); +}); + +describe('ProjectsIntegrationCredentialSet (override-set)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('links an email credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialSet( + ['my-project', '--category', 'email', '--role', 'imap_password', '--credential-id', '7'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.set.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'email', + role: 'imap_password', + credentialId: 7, + }); + }); + + it('links a pm credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialSet( + ['my-project', '--category', 'pm', '--role', 'api_key', '--credential-id', '3'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.set.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + role: 'api_key', + credentialId: 3, + }); + }); + + it('links a scm credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialSet( + ['my-project', '--category', 'scm', '--role', 'implementer_token', '--credential-id', '1'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.set.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'scm', + role: 'implementer_token', + credentialId: 1, + }); + }); + + it('rejects unknown category values', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationCredentialSet( + ['my-project', '--category', 'billing', '--role', 'key', '--credential-id', '1'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); +}); + +describe('ProjectsIntegrationCredentialRm (override-rm)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('unlinks an email credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialRm( + ['my-project', '--category', 'email', '--role', 'imap_password'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.remove.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'email', + role: 'imap_password', + }); + }); + + it('unlinks a pm credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialRm( + ['my-project', '--category', 'pm', '--role', 'api_key'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.remove.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'pm', + role: 'api_key', + }); + }); + + it('unlinks a scm credential role', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new ProjectsIntegrationCredentialRm( + ['my-project', '--category', 'scm', '--role', 'reviewer_token'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.projects.integrationCredentials.remove.mutate).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'scm', + role: 'reviewer_token', + }); + }); + + it('rejects unknown category values', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new ProjectsIntegrationCredentialRm( + ['my-project', '--category', 'billing', '--role', 'key'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); +});