diff --git a/packages/cli/src/commands/__tests__/checks-delete.spec.ts b/packages/cli/src/commands/__tests__/checks-delete.spec.ts new file mode 100644 index 000000000..072468f42 --- /dev/null +++ b/packages/cli/src/commands/__tests__/checks-delete.spec.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../../helpers/cli-mode', () => ({ + detectCliMode: vi.fn(() => 'agent'), +})) + +vi.mock('../../rest/api', () => ({ + checks: { get: vi.fn(), delete: vi.fn() }, + validateAuthentication: vi.fn().mockResolvedValue({ name: 'Test Account' }), +})) + +vi.mock('prompts', () => ({ + default: vi.fn(() => Promise.resolve({ confirm: true })), +})) + +import { detectCliMode } from '../../helpers/cli-mode.js' +import * as api from '../../rest/api.js' +import { NotFoundError } from '../../rest/errors.js' +import { AuthCommand } from '../authCommand.js' +import ChecksDelete from '../checks/delete.js' + +const check = { + id: '33333333-3333-3333-3333-333333333333', + name: 'Homepage', + checkType: 'BROWSER', +} + +function createCommandContext (Command: typeof AuthCommand, parsed: unknown) { + const logged: string[] = [] + let exitCodeValue: number | undefined + return { + parse: vi.fn().mockResolvedValue(parsed), + error: vi.fn((message: string) => { + throw new Error(message) + }), + log: vi.fn((msg?: string) => { + if (msg) logged.push(msg) + }), + exit: vi.fn((code: number) => { + exitCodeValue = code + throw new Error(`EXIT_${code}`) + }), + confirmOrAbort: AuthCommand.prototype.confirmOrAbort, + style: { + outputFormat: undefined, + shortSuccess: vi.fn(), + shortError: vi.fn(), + longError: vi.fn(), + }, + constructor: Command, + logged, + get exitCodeValue () { + return exitCodeValue + }, + } +} + +describe('checks delete command', () => { + beforeEach(() => { + vi.clearAllMocks() + process.exitCode = undefined + vi.mocked(api.checks.get).mockResolvedValue({ data: check } as any) + vi.mocked(api.checks.delete).mockResolvedValue({} as any) + }) + + it('exits 2 in agent mode without --force', async () => { + vi.mocked(detectCliMode).mockReturnValue('agent') + const ctx = createCommandContext(ChecksDelete, { + args: { id: check.id }, + flags: { 'force': false, 'dry-run': false }, + }) + + await expect( + ChecksDelete.prototype.run.call(ctx as any), + ).rejects.toThrow('EXIT_2') + + const output = JSON.parse(ctx.logged[0]) + expect(output.status).toBe('confirmation_required') + expect(output.command).toBe('checks delete') + expect(output.classification.destructive).toBe(true) + expect(output.confirmCommand).toContain('--force') + expect(output.confirmCommand).toContain(check.id) + expect(output.changes[0]).toContain(check.name) + expect(output.changes[1]).toContain('recreated on the next deploy') + expect(api.checks.delete).not.toHaveBeenCalled() + }) + + it('executes with --force in agent mode', async () => { + vi.mocked(detectCliMode).mockReturnValue('agent') + const ctx = createCommandContext(ChecksDelete, { + args: { id: check.id }, + flags: { 'force': true, 'dry-run': false }, + }) + + await ChecksDelete.prototype.run.call(ctx as any) + + expect(api.checks.delete).toHaveBeenCalledWith(check.id) + expect(ctx.style.shortSuccess).toHaveBeenCalledWith(`Check "${check.name}" deleted.`) + }) + + it('shows preview and exits 0 with --dry-run', async () => { + vi.mocked(detectCliMode).mockReturnValue('agent') + const ctx = createCommandContext(ChecksDelete, { + args: { id: check.id }, + flags: { 'force': false, 'dry-run': true }, + }) + + await expect( + ChecksDelete.prototype.run.call(ctx as any), + ).rejects.toThrow('EXIT_0') + + const output = JSON.parse(ctx.logged[0]) + expect(output.status).toBe('dry_run') + expect(api.checks.delete).not.toHaveBeenCalled() + }) + + it('reports a friendly message when the check does not exist', async () => { + vi.mocked(api.checks.get).mockRejectedValue(new NotFoundError({ + statusCode: 404, + error: 'Not Found', + message: 'Check not found', + })) + const ctx = createCommandContext(ChecksDelete, { + args: { id: check.id }, + flags: { 'force': true, 'dry-run': false }, + }) + + await ChecksDelete.prototype.run.call(ctx as any) + + expect(ctx.style.shortError).toHaveBeenCalledWith( + `Check "${check.id}" not found. It may have already been deleted.`, + ) + expect(process.exitCode).toBe(1) + expect(api.checks.delete).not.toHaveBeenCalled() + }) + + it('sets exit code 1 when the delete fails', async () => { + vi.mocked(api.checks.delete).mockRejectedValue(new Error('boom')) + const ctx = createCommandContext(ChecksDelete, { + args: { id: check.id }, + flags: { 'force': true, 'dry-run': false }, + }) + + await ChecksDelete.prototype.run.call(ctx as any) + + expect(ctx.style.longError).toHaveBeenCalledWith('Failed to delete check.', expect.any(Error)) + expect(process.exitCode).toBe(1) + }) + + it('has correct metadata', () => { + expect(ChecksDelete.readOnly).toBe(false) + expect(ChecksDelete.destructive).toBe(true) + expect(ChecksDelete.idempotent).toBe(true) + }) +}) diff --git a/packages/cli/src/commands/checks/delete.ts b/packages/cli/src/commands/checks/delete.ts new file mode 100644 index 000000000..4b49328bb --- /dev/null +++ b/packages/cli/src/commands/checks/delete.ts @@ -0,0 +1,68 @@ +import { Args } from '@oclif/core' +import { AuthCommand } from '../authCommand.js' +import { dryRunFlag, forceFlag } from '../../helpers/flags.js' +import * as api from '../../rest/api.js' +import { NotFoundError } from '../../rest/errors.js' + +export default class ChecksDelete extends AuthCommand { + static hidden = false + static destructive = true + static idempotent = true + static description = 'Delete a check by ID. Checks managed by a CLI project are recreated on the next deploy ' + + '— remove those from your project code instead.' + + static args = { + id: Args.string({ + name: 'id', + required: true, + description: 'The ID of the check to delete.', + }), + } + + static flags = { + 'force': forceFlag(), + 'dry-run': dryRunFlag(), + } + + async run (): Promise { + const { args, flags } = await this.parse(ChecksDelete) + + let check + try { + const { data } = await api.checks.get(args.id) + check = data + } catch (err: any) { + if (err instanceof NotFoundError) { + this.style.shortError(`Check "${args.id}" not found. It may have already been deleted.`) + } else { + this.style.longError('Failed to find check.', err) + } + process.exitCode = 1 + return + } + + await this.confirmOrAbort({ + command: 'checks delete', + description: 'Delete check', + changes: [ + `Delete check "${check.name}" (${check.checkType})`, + 'If this check is managed by a CLI project, it will be recreated on the next deploy', + ], + flags, + args: { id: args.id }, + classification: { + readOnly: ChecksDelete.readOnly, + destructive: ChecksDelete.destructive, + idempotent: ChecksDelete.idempotent, + }, + }, { force: flags.force, dryRun: flags['dry-run'] }) + + try { + await api.checks.delete(args.id) + this.style.shortSuccess(`Check "${check.name}" deleted.`) + } catch (err: any) { + this.style.longError('Failed to delete check.', err) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/rest/checks.ts b/packages/cli/src/rest/checks.ts index 44300846c..93ea74b12 100644 --- a/packages/cli/src/rest/checks.ts +++ b/packages/cli/src/rest/checks.ts @@ -84,6 +84,10 @@ class Checks { get (id: string) { return this.api.get(`/v1/checks/${id}`) } + + delete (id: string) { + return this.api.delete(`/v1/checks/${id}`) + } } export default Checks