From 804c20599530b191b34916bbb956ee03a1a71ad4 Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Thu, 14 May 2026 19:20:40 +0530 Subject: [PATCH 1/2] CL-1753 | Add instant rollback feature support for Launch CLI --- .talismanrc | 2 + src/commands/launch/rollback.test.ts | 233 ++++++++++++++++++ src/commands/launch/rollback.ts | 351 +++++++++++++++++++++++++++ src/graphql/mutation.ts | 10 + src/graphql/queries.ts | 11 +- 5 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/commands/launch/rollback.test.ts create mode 100644 src/commands/launch/rollback.ts diff --git a/.talismanrc b/.talismanrc index fb07040..70638de 100644 --- a/.talismanrc +++ b/.talismanrc @@ -6,4 +6,6 @@ fileignoreconfig: checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62 - filename: package-lock.json checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 +- filename: src/commands/launch/rollback.test.ts + checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd version: "1.0" \ No newline at end of file diff --git a/src/commands/launch/rollback.test.ts b/src/commands/launch/rollback.test.ts new file mode 100644 index 00000000..9508fec --- /dev/null +++ b/src/commands/launch/rollback.test.ts @@ -0,0 +1,233 @@ +import Rollback from './rollback'; +import { Logger } from '../../util'; +import { cliux } from '@contentstack/cli-utilities'; + +jest.mock('../../util', () => { + const actual = jest.requireActual('../../util'); + return { + ...actual, + Logger: jest.fn(), + selectOrg: jest.fn(), + selectProject: jest.fn(), + }; +}); + +jest.mock('@contentstack/cli-utilities', () => { + const actual = jest.requireActual('@contentstack/cli-utilities'); + return { + ...actual, + configHandler: { + get: jest.fn((key) => { + if (key === 'authtoken') return 'dummy-token'; + if (key === 'authorisationType') return 'OAuth'; + if (key === 'oauthAccessToken') return 'dummy-oauth-token'; + return undefined; + }), + }, + cliux: { + ...actual.cliux, + inquire: jest.fn(), + print: jest.fn(), + }, + }; +}); + +const targetDeployment = { + uid: 'target-uid', + status: 'ARCHIVED', + gitBranch: 'main', + commitHash: 'abcdef1', + createdAt: '2026-04-29T00:00:00Z', + commitMessage: 'previous good build', + deploymentUrl: 'https://example.com', + deploymentNumber: 2, + isRollbackEligible: true, +}; + +const liveDeployment = { + ...targetDeployment, + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, +}; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { + edges: [ + { node: liveDeployment }, + { node: targetDeployment }, + ], + }, + }, + }, + ], + }, + }, +}; + +const buildCommand = (flags: Record = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => { + const cmd = new Rollback([], {} as any); + (cmd as any).flags = flags; + (cmd as any).log = jest.fn(); + (cmd as any).logger = { log: jest.fn() }; + (cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } }; + (cmd as any).apolloClient = { + query: queryImpl || jest.fn(), + mutate: mutateImpl || jest.fn(), + }; + return cmd; +}; + +describe('Rollback Command', () => { + let exitMock: jest.SpyInstance; + + beforeEach(() => { + (Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() })); + exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('exits when no rollback-eligible deployments are available', async () => { + const noEligibleResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: 'Default', + deployments: { edges: [{ node: liveDeployment }] }, + }, + }, + ], + }, + }, + }; + const query = jest.fn().mockResolvedValueOnce(noEligibleResponse); + const mutate = jest.fn(); + const cmd = buildCommand({ environment: 'Default' }, query, mutate); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'No rollback-eligible deployments are available for this environment.', + 'error', + ); + }); + + it('exits when --deployment flag does not match an eligible deployment', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'unknown-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).not.toHaveBeenCalled(); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Provided deployment UID is not rollback-eligible or does not exist.', + 'error', + ); + }); + + it('skips the mutation when the user does not confirm', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn(); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'audit' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt + + await (cmd as any).rollbackDeployment(); + + expect(mutate).not.toHaveBeenCalled(); + }); + + it('fires the rollback mutation and prints the success message', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const mutate = jest.fn().mockResolvedValueOnce({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid', reason: 'restoring' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock).mockResolvedValueOnce(true); + + await (cmd as any).rollbackDeployment(); + + expect(mutate).toHaveBeenCalledTimes(1); + const variables = mutate.mock.calls[0][0].variables; + expect(variables).toEqual({ + input: { + deployment: 'target-uid', + environment: 'env-uid', + reason: 'restoring', + }, + }); + expect(exitMock).not.toHaveBeenCalled(); + }); + + it('logs an error and exits when the rollback mutation fails', async () => { + const query = jest.fn().mockResolvedValueOnce(environmentsResponse); + const error = Object.assign(new Error('boom'), { + graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }], + }); + const mutate = jest.fn().mockRejectedValueOnce(error); + const cmd = buildCommand( + { environment: 'Default', deployment: 'target-uid' }, + query, + mutate, + ); + jest + .spyOn(cmd as any, 'fetchCurrentLiveDeployment') + .mockResolvedValueOnce(liveDeployment); + (cliux.inquire as jest.Mock) + .mockResolvedValueOnce('') // reason + .mockResolvedValueOnce(true); // confirm + + await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1'); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(exitMock).toHaveBeenCalledWith(1); + expect((cmd as any).log).toHaveBeenCalledWith( + 'Rollback failed. Please try again. (DeploymentRollbackFailed)', + 'error', + ); + }); +}); diff --git a/src/commands/launch/rollback.ts b/src/commands/launch/rollback.ts new file mode 100644 index 00000000..51b9b37 --- /dev/null +++ b/src/commands/launch/rollback.ts @@ -0,0 +1,351 @@ +import chalk from 'chalk'; +import map from 'lodash/map'; +import find from 'lodash/find'; +import filter from 'lodash/filter'; +import isEmpty from 'lodash/isEmpty'; +import { FlagInput, Flags, cliux as ux } from '@contentstack/cli-utilities'; + +import { BaseCommand } from '../../base-command'; +import { + environmentsQuery, + latestLiveDeploymentQuery, + rollbackDeploymentMutation, +} from '../../graphql'; +import { Logger, selectOrg, selectProject } from '../../util'; + +export default class Rollback extends BaseCommand { + static description = 'Roll back to previous deployment'; + + static examples = [ + '$ <%= config.bin %> <%= command.id %>', + '$ <%= config.bin %> <%= command.id %> -d "current working directory"', + '$ <%= config.bin %> <%= command.id %> -c "path to the local config file"', + // eslint-disable-next-line max-len + '$ <%= config.bin %> <%= command.id %> -e "environment number or uid" --deployment= --org= --project= --reason="restoring previous build"', + ]; + + static flags: FlagInput = { + org: Flags.string({ + description: '[Optional] Provide the organization UID', + }), + project: Flags.string({ + description: '[Optional] Provide the project UID', + }), + environment: Flags.string({ + char: 'e', + description: 'Environment name or UID', + }), + deployment: Flags.string({ + description: '[Optional] Deployment UID to roll back to', + }), + reason: Flags.string({ + description: '[Optional] Reason for the rollback (saved to audit log)', + }), + }; + + async init(): Promise { + await super.init(); + this.logger = new Logger(this.sharedConfig); + this.log = this.logger.log.bind(this.logger); + await this.prepareApiClients(); + } + + async run(): Promise { + if (!this.flags.environment) { + await this.getConfig(); + } + + if (!this.sharedConfig.currentConfig?.uid) { + await selectOrg({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + managementSdk: this.managementSdk, + }); + await this.prepareApiClients(); // NOTE update org-id in header + await selectProject({ + log: this.log, + flags: this.flags, + config: this.sharedConfig, + apolloClient: this.apolloClient, + }); + await this.prepareApiClients(); // NOTE update project-id in header + } + + await this.rollbackDeployment(); + } + + /** + * @method rollbackDeployment - resolve env, run select + review steps, fire mutation + * + * @memberof Rollback + */ + async rollbackDeployment(): Promise { + const environment = await this.resolveEnvironment(); + const currentLive = await this.fetchCurrentLiveDeployment(environment.uid); + const eligibleSorted = this.getEligibleSortedDeployments(environment, currentLive?.uid); + + if (isEmpty(eligibleSorted)) { + this.log('No rollback-eligible deployments are available for this environment.', 'error'); + process.exit(1); + } + + this.printSelectStep(environment, currentLive, eligibleSorted); + const target = await this.selectDeployment(eligibleSorted); + + this.printReviewStep(currentLive, target, eligibleSorted); + const reason = await this.promptReason(); + const confirmed = await ux.inquire({ + type: 'confirm', + name: 'confirm', + message: 'Confirm & Rollback?', + }); + + if (!confirmed) { + ux.print(chalk.yellow('Rollback aborted.')); + return; + } + + try { + await this.apolloClient.mutate({ + mutation: rollbackDeploymentMutation, + variables: { + input: { + deployment: target.uid, + environment: environment.uid, + ...(reason ? { reason } : {}), + }, + }, + }); + } catch (error: unknown) { + const err = error as { graphQLErrors?: { extensions?: { exception?: { name?: string } } }[]; message?: string }; + const code = err?.graphQLErrors?.[0]?.extensions?.exception?.name || err?.message; + this.log(`Rollback failed. Please try again. (${code})`, 'error'); + process.exit(1); + } + + ux.print(''); + ux.print( + `Promoting deployment ${chalk.cyan(`#${target.deploymentNumber}`)} ` + + chalk.dim(`(${target.uid})`) + '…', + ); + + ux.print(''); + ux.print(chalk.green('✔ Instant rollback to a previous deployment is successful.')); + const label = `${chalk.cyan(`#${target.deploymentNumber}`)} ${chalk.dim(`(${target.uid})`)}`; + ux.print(` Deployment ${label} is now ${chalk.green('LIVE')}.`); + ux.print(''); + } + + /** + * @method resolveEnvironment - resolve environment via flag, config, or prompt + * + * @memberof Rollback + */ + async resolveEnvironment(): Promise { + const environments = await this.apolloClient + .query({ + query: environmentsQuery, + variables: { skipRollbackData: false }, + }) + .then(({ data: { Environments } }) => map(Environments.edges, 'node')) + .catch((error) => { + this.log(error?.message, 'error'); + process.exit(1); + }); + + if (this.flags.environment) { + const environment = find( + environments, + ({ uid, name }) => uid === this.flags.environment || name === this.flags.environment, + ); + if (isEmpty(environment)) { + this.log('Environment(s) not found!', 'error'); + process.exit(1); + } + return environment; + } + + // NOTE: rollback is destructive; never auto-select from saved config — always prompt. + return ux + .inquire({ + type: 'search-list', + name: 'Environment', + choices: map(environments, (row) => ({ ...row, value: row.name })), + message: 'Choose an environment', + }) + .then((name: any) => find(environments, { name }) as Record); + } + + /** + * @method fetchCurrentLiveDeployment - fetch the currently live deployment for the environment + * + * @memberof Rollback + */ + async fetchCurrentLiveDeployment(environmentUid: string): Promise { + return this.apolloClient + .query({ + query: latestLiveDeploymentQuery, + variables: { query: { environment: environmentUid } }, + }) + .then(({ data }) => data?.latestLiveDeployment) + .catch(() => undefined); + } + + /** + * @method getEligibleSortedDeployments - eligible deployments excluding current live, sorted by number desc + * + * @memberof Rollback + */ + getEligibleSortedDeployments(environment: any, currentLiveUid?: string): any[] { + const deployments = map(environment?.deployments?.edges, 'node'); + const eligible = filter( + deployments, + (d) => d.isRollbackEligible && d.uid !== currentLiveUid, + ); + return [...eligible].sort((a, b) => (b.deploymentNumber || 0) - (a.deploymentNumber || 0)); + } + + /** + * @method selectDeployment - resolve target via --deployment flag or interactive picker + * + * @memberof Rollback + */ + async selectDeployment(eligibleSorted: any[]): Promise { + if (this.flags.deployment) { + const match = find(eligibleSorted, ({ uid }) => uid === this.flags.deployment); + if (isEmpty(match)) { + this.log('Provided deployment UID is not rollback-eligible or does not exist.', 'error'); + process.exit(1); + } + return match; + } + + const choices = map(eligibleSorted, (d) => { + const message = (d.commitMessage || '').split('\n')[0].trim() || '—'; + const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message; + return { + ...d, + name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`, + value: d.uid, + }; + }); + + const selectedUid = await ux.inquire({ + type: 'search-list', + name: 'Deployment', + choices, + message: 'Select a version to restore', + }); + + return find(eligibleSorted, { uid: selectedUid }) as Record; + } + + /** + * @method promptReason - prompt for rollback reason unless provided via --reason flag + * + * @memberof Rollback + */ + async promptReason(): Promise { + if (this.flags.reason) { + return this.flags.reason.trim() || undefined; + } + const input = await ux.inquire({ + type: 'input', + name: 'reason', + message: 'Reason (saved to audit log) — press enter to skip:', + }); + const trimmed = (input || '').trim(); + return trimmed ? trimmed : undefined; + } + + /** + * @method printSelectStep - mirror the UI "select" step heading and table + * + * @memberof Rollback + */ + printSelectStep(environment: any, currentLive: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Roll back to previous deployment')); + ux.print(`${chalk.dim('Environment:')} ${chalk.cyan(environment.name)}`); + ux.print(''); + ux.print(chalk.bold('Currently live')); + ux.print(` ${formatDeployment(currentLive)}`); + ux.print(''); + ux.print(chalk.bold('Select a version to restore')); + ux.print(chalk.dim('Choose a previously successful deployment to ensure stability.')); + const count = eligibleSorted.length; + ux.print(chalk.dim(`(${count} eligible deployment${count === 1 ? '' : 's'} available)`)); + ux.print(''); + } + + /** + * @method printReviewStep - mirror the UI "review" step warnings, skips info, and summary + * + * @memberof Rollback + */ + printReviewStep(currentLive: any, target: any, eligibleSorted: any[]): void { + ux.print(''); + ux.print(chalk.bold.underline('Review rollback')); + ux.print(''); + ux.print('You are about to replace your live site with the version below.'); + ux.print('This build will be pushed to the edge immediately.'); + ux.print(''); + ux.print( + `${chalk.yellow.bold('Note:')} The rolled back instance will use the environment variables`, + ); + ux.print(' associated with the selected deployment.'); + + const targetIndex = eligibleSorted.findIndex((d) => d.uid === target.uid); + const skipped = targetIndex > 0 ? eligibleSorted.slice(0, targetIndex) : []; + if (skipped.length > 0) { + const list = skipped.map((d) => `#${d.deploymentNumber}`).join(', '); + const noun = skipped.length === 1 ? 'good deployment' : 'good deployments'; + const verb = skipped.length === 1 ? 'stays' : 'stay'; + ux.print(''); + ux.print( + `${chalk.blue('ⓘ')} Selecting #${target.deploymentNumber} skips ${skipped.length} ${noun} — ${list}`, + ); + ux.print(` ${verb} in history and can be restored later.`); + } + + ux.print(''); + ux.print(` ${chalk.bold('Current Live')} ${formatDeployment(currentLive)}`); + ux.print(` ${chalk.bold('Roll back to')} ${formatDeployment(target)}`); + ux.print(''); + ux.print( + chalk.dim('A new deployment may be initiated if any automations/commits/webhooks are triggered.'), + ); + ux.print(''); + } +} + +function shortHash(hash?: string): string { + return hash ? hash.substring(0, 7) : ''; +} + +function sourceLabel(deployment?: any): string { + if (!deployment) { + return ''; + } + const hash = shortHash(deployment.commitHash); + if (deployment.gitBranch && hash) { + return `${deployment.gitBranch} - ${hash}`; + } + return deployment.gitBranch || hash || ''; +} + +function formatDeployment(deployment?: any): string { + if (!deployment) { + return chalk.dim('(none)'); + } + const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid; + const source = sourceLabel(deployment); + const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim(); + const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message; + const createdAt = deployment.createdAt || ''; + const numberCol = chalk.green(number.padEnd(6)); + const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22); + const messageCol = truncated || chalk.dim('—'); + return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`; +} diff --git a/src/graphql/mutation.ts b/src/graphql/mutation.ts index 97bc3f6..431ef2f 100755 --- a/src/graphql/mutation.ts +++ b/src/graphql/mutation.ts @@ -76,8 +76,18 @@ const importProjectMutation: DocumentNode = gql` } `; +const rollbackDeploymentMutation: DocumentNode = gql` + mutation RollbackDeployment($input: RollbackDeploymentInput!) { + rollbackDeployment(input: $input) { + status + environmentUid + } + } +`; + export { importProjectMutation, createDeploymentMutation, + rollbackDeploymentMutation, createSignedUploadUrlMutation, }; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index c27debe..1cb0e81 100755 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql` environment deploymentNumber deploymentUrl + status + gitBranch + commitHash + commitMessage + createdAt } } `; const environmentsQuery: DocumentNode = gql` - query Environments { + query Environments($skipRollbackData: Boolean = true) { Environments { edges { node { @@ -165,6 +170,10 @@ const environmentsQuery: DocumentNode = gql` commitMessage deploymentUrl deploymentNumber + status @skip(if: $skipRollbackData) + gitBranch @skip(if: $skipRollbackData) + commitHash @skip(if: $skipRollbackData) + isRollbackEligible @skip(if: $skipRollbackData) } } } From 9a49839c5b9d1ed0400b2a734d6797a80336e1fa Mon Sep 17 00:00:00 2001 From: Aryan Bansal Date: Fri, 15 May 2026 12:56:06 +0530 Subject: [PATCH 2/2] CL-1753 | Add unit tests for Rollback command in Launch CLI --- .talismanrc | 2 + test/unit/commands/rollback.test.ts | 209 ++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 test/unit/commands/rollback.test.ts diff --git a/.talismanrc b/.talismanrc index 70638de..f0cd810 100644 --- a/.talismanrc +++ b/.talismanrc @@ -8,4 +8,6 @@ fileignoreconfig: checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9 - filename: src/commands/launch/rollback.test.ts checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd +- filename: test/unit/commands/rollback.test.ts + checksum: d1f931f2d9a397131409399ad6463653e28b5a2224e870b641d9ba57c4418f18 version: "1.0" \ No newline at end of file diff --git a/test/unit/commands/rollback.test.ts b/test/unit/commands/rollback.test.ts new file mode 100644 index 00000000..ed03300 --- /dev/null +++ b/test/unit/commands/rollback.test.ts @@ -0,0 +1,209 @@ +import { describe, it, beforeEach, afterEach } from 'mocha'; +import type { ApolloClient } from '@apollo/client/core'; +import Rollback from '../../../src/commands/launch/rollback'; +import { cliux } from '@contentstack/cli-utilities'; +import { testFlags } from '../mock'; +import sinon, { stub } from 'sinon'; +import { config } from 'dotenv'; +import * as commonUtility from '../../../src/util/common-utility'; +import { BaseCommand } from '../../../src/base-command'; + +config(); + +const orgUid = process.env.ORG || 'test-org-uid'; +const projectUid = process.env.PROJECT || 'test-project-uid'; +const environmentName = process.env.ENVIRONMENT || 'Default'; +const targetDeploymentUid = process.env.ROLLBACK_DEPLOYMENT || 'target-deployment-uid'; + +const environmentsResponse = { + data: { + Environments: { + edges: [ + { + node: { + uid: 'env-uid', + name: environmentName, + deployments: { + edges: [ + { + node: { + uid: 'live-uid', + status: 'LIVE', + deploymentNumber: 3, + isRollbackEligible: true, + }, + }, + { + node: { + uid: targetDeploymentUid, + status: 'ARCHIVED', + deploymentNumber: 2, + isRollbackEligible: true, + }, + }, + ], + }, + }, + }, + ], + }, + }, +}; + +const projectsResponse = { + data: { + projects: { + edges: [{ node: { uid: projectUid, name: 'Test Project' } }], + }, + }, +}; + +const getFlagValue = (flag: unknown): string | undefined => { + if (flag === undefined || flag === null) { + return undefined; + } + return String(flag); +}; + +const createApolloClientStub = () => ({ + query: stub().callsFake(({ query }) => { + const queryBody = query?.loc?.source?.body ?? ''; + if (queryBody.includes('projects')) { + return Promise.resolve(projectsResponse); + } + if (queryBody.includes('Environments')) { + return Promise.resolve(environmentsResponse); + } + return Promise.resolve({ data: {} }); + }), + mutate: stub().resolves({ + data: { + rollbackDeployment: { status: 'PENDING', environmentUid: 'env-uid' }, + }, + }), +}); + +describe('Rollback', () => { + let rollbackDeploymentStub: sinon.SinonStub; + + beforeEach(() => { + stub(commonUtility, 'selectOrg').callsFake(async ({ config, flags }) => { + const orgFlag = getFlagValue(flags.org); + if (orgFlag) { + config.currentConfig.organizationUid = + orgFlag === testFlags.invalidOrg.uid ? testFlags.invalidOrg.uid : orgUid; + return; + } + config.currentConfig.organizationUid = orgUid; + }); + stub(commonUtility, 'selectProject').callsFake(async ({ config, flags }) => { + const projectFlag = getFlagValue(flags?.project) ?? config?.project; + if (projectFlag && projectFlag !== testFlags.invalidProj) { + config.currentConfig.uid = projectUid; + return; + } + if (!config.currentConfig.uid) { + await cliux.inquire({ + type: 'search-list', + name: 'Project', + message: 'Choose a project', + }); + config.currentConfig.uid = projectUid; + } + }); + stub(BaseCommand.prototype, 'prepareApiClients').callsFake(async function (this: BaseCommand) { + this.apolloClient = createApolloClientStub() as unknown as ApolloClient; + this.apolloLogsClient = {} as unknown as ApolloClient; + }); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').resolves(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('Should run the command when all the flags are passed', async function () { + const args = [ + '--org', + orgUid, + '-e', + environmentName, + '--project', + projectUid, + '--deployment', + targetDeploymentUid, + ]; + const inquireStub = stub(cliux, 'inquire'); + + await Rollback.run(args); + + sinon.assert.calledOnce(rollbackDeploymentStub); + sinon.assert.notCalled(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for org when org flag is not passed', async function () { + const args = ['-e', environmentName, '--project', projectUid]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when project flag is not passed', async function () { + const args = ['-e', environmentName, '--org', orgUid]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); + + it('Should ask for environment when environment flag is not passed', async function () { + rollbackDeploymentStub.restore(); + rollbackDeploymentStub = stub(Rollback.prototype, 'rollbackDeployment').callsFake(async function (this: Rollback) { + await this.resolveEnvironment(); + }); + + const args = ['--org', orgUid, '--project', projectUid]; + const inquireStub = stub(cliux, 'inquire').resolves(environmentName); + + await Rollback.run(args); + + sinon.assert.called(inquireStub); + inquireStub.restore(); + }); + + it('Should ask for organization with a warning when passed incorrect org uid', async function () { + const args = ['--org', testFlags.invalidOrg.uid, '--project', projectUid, '-e', environmentName]; + const mock = sinon.mock(Rollback); + const expectation = mock.expects('run'); + expectation.exactly(1); + const orgStub = stub(cliux, 'inquire').resolves(orgUid); + + await Rollback.run(args); + + sinon.assert.notCalled(orgStub); + orgStub.restore(); + mock.verify(); + mock.restore(); + }); + + it('Should ask for project when passed incorrect project name', async function () { + const args = ['--org', orgUid, '--project', testFlags.invalidProj, '-e', environmentName]; + const projectStub = stub(cliux, 'inquire').resolves('Test Project'); + + await Rollback.run(args); + + sinon.assert.calledOnce(projectStub); + projectStub.restore(); + }); +});