Skip to content

Commit 32b4da9

Browse files
CL-1753 | add unit tests for rollback command and enhance environment resolution logic
1 parent 6023ff7 commit 32b4da9

4 files changed

Lines changed: 282 additions & 35 deletions

File tree

.talismanrc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ fileignoreconfig:
66
checksum: 9db6c02ad35a0367343cd753b916dd64db4a9efd24838201d2e1113ed19c9b62
77
- filename: package-lock.json
88
checksum: 43c0eecc2192095c8fb5bc524b7dafa33a6141ddd3923d41ffb15ec025bea9a9
9+
- filename: src/commands/launch/rollback.test.ts
10+
checksum: 561d709dfaa046af3afaf73e8570211d1b63ca8fdf23d3a6ffec0fff7587eacd
911
version: "1.0"
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import Rollback from './rollback';
2+
import { Logger } from '../../util';
3+
import { cliux } from '@contentstack/cli-utilities';
4+
5+
jest.mock('../../util', () => {
6+
const actual = jest.requireActual('../../util');
7+
return {
8+
...actual,
9+
Logger: jest.fn(),
10+
selectOrg: jest.fn(),
11+
selectProject: jest.fn(),
12+
};
13+
});
14+
15+
jest.mock('@contentstack/cli-utilities', () => {
16+
const actual = jest.requireActual('@contentstack/cli-utilities');
17+
return {
18+
...actual,
19+
configHandler: {
20+
get: jest.fn((key) => {
21+
if (key === 'authtoken') return 'dummy-token';
22+
if (key === 'authorisationType') return 'OAuth';
23+
if (key === 'oauthAccessToken') return 'dummy-oauth-token';
24+
return undefined;
25+
}),
26+
},
27+
cliux: {
28+
...actual.cliux,
29+
inquire: jest.fn(),
30+
print: jest.fn(),
31+
},
32+
};
33+
});
34+
35+
const targetDeployment = {
36+
uid: 'target-uid',
37+
status: 'ARCHIVED',
38+
gitBranch: 'main',
39+
commitHash: 'abcdef1',
40+
createdAt: '2026-04-29T00:00:00Z',
41+
commitMessage: 'previous good build',
42+
deploymentUrl: 'https://example.com',
43+
deploymentNumber: 2,
44+
isRollbackEligible: true,
45+
};
46+
47+
const liveDeployment = {
48+
...targetDeployment,
49+
uid: 'live-uid',
50+
status: 'LIVE',
51+
deploymentNumber: 3,
52+
};
53+
54+
const environmentsResponse = {
55+
data: {
56+
Environments: {
57+
edges: [
58+
{
59+
node: {
60+
uid: 'env-uid',
61+
name: 'Default',
62+
deployments: {
63+
edges: [
64+
{ node: liveDeployment },
65+
{ node: targetDeployment },
66+
],
67+
},
68+
},
69+
},
70+
],
71+
},
72+
},
73+
};
74+
75+
const buildCommand = (flags: Record<string, any> = {}, queryImpl?: jest.Mock, mutateImpl?: jest.Mock) => {
76+
const cmd = new Rollback([], {} as any);
77+
(cmd as any).flags = flags;
78+
(cmd as any).log = jest.fn();
79+
(cmd as any).logger = { log: jest.fn() };
80+
(cmd as any).sharedConfig = { currentConfig: { uid: 'project-uid' } };
81+
(cmd as any).apolloClient = {
82+
query: queryImpl || jest.fn(),
83+
mutate: mutateImpl || jest.fn(),
84+
};
85+
return cmd;
86+
};
87+
88+
describe('Rollback Command', () => {
89+
let exitMock: jest.SpyInstance;
90+
91+
beforeEach(() => {
92+
(Logger as jest.Mock).mockImplementation(() => ({ log: jest.fn() }));
93+
exitMock = jest.spyOn(process, 'exit').mockImplementation(((code?: number) => {
94+
throw new Error(`process.exit:${code}`);
95+
}) as any);
96+
});
97+
98+
afterEach(() => {
99+
jest.clearAllMocks();
100+
});
101+
102+
it('exits when no rollback-eligible deployments are available', async () => {
103+
const noEligibleResponse = {
104+
data: {
105+
Environments: {
106+
edges: [
107+
{
108+
node: {
109+
uid: 'env-uid',
110+
name: 'Default',
111+
deployments: { edges: [{ node: liveDeployment }] },
112+
},
113+
},
114+
],
115+
},
116+
},
117+
};
118+
const query = jest.fn().mockResolvedValueOnce(noEligibleResponse);
119+
const mutate = jest.fn();
120+
const cmd = buildCommand({ environment: 'Default' }, query, mutate);
121+
jest
122+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
123+
.mockResolvedValueOnce(liveDeployment);
124+
125+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
126+
127+
expect(mutate).not.toHaveBeenCalled();
128+
expect(exitMock).toHaveBeenCalledWith(1);
129+
expect((cmd as any).log).toHaveBeenCalledWith(
130+
'No rollback-eligible deployments are available for this environment.',
131+
'error',
132+
);
133+
});
134+
135+
it('exits when --deployment flag does not match an eligible deployment', async () => {
136+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
137+
const mutate = jest.fn();
138+
const cmd = buildCommand(
139+
{ environment: 'Default', deployment: 'unknown-uid' },
140+
query,
141+
mutate,
142+
);
143+
jest
144+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
145+
.mockResolvedValueOnce(liveDeployment);
146+
147+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
148+
149+
expect(mutate).not.toHaveBeenCalled();
150+
expect(exitMock).toHaveBeenCalledWith(1);
151+
expect((cmd as any).log).toHaveBeenCalledWith(
152+
'Provided deployment UID is not rollback-eligible or does not exist.',
153+
'error',
154+
);
155+
});
156+
157+
it('skips the mutation when the user does not confirm', async () => {
158+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
159+
const mutate = jest.fn();
160+
const cmd = buildCommand(
161+
{ environment: 'Default', deployment: 'target-uid', reason: 'audit' },
162+
query,
163+
mutate,
164+
);
165+
jest
166+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
167+
.mockResolvedValueOnce(liveDeployment);
168+
(cliux.inquire as jest.Mock).mockResolvedValueOnce(false); // confirm prompt
169+
170+
await (cmd as any).rollbackDeployment();
171+
172+
expect(mutate).not.toHaveBeenCalled();
173+
});
174+
175+
it('fires the rollback mutation and polls until LIVE on success', async () => {
176+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
177+
const mutate = jest.fn().mockResolvedValueOnce({
178+
data: { deployment: { ...targetDeployment, status: 'QUEUED' } },
179+
});
180+
const cmd = buildCommand(
181+
{ environment: 'Default', deployment: 'target-uid', reason: 'restoring' },
182+
query,
183+
mutate,
184+
);
185+
jest
186+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
187+
.mockResolvedValueOnce(liveDeployment);
188+
jest.spyOn(cmd as any, 'pollDeploymentStatus').mockResolvedValueOnce('LIVE');
189+
(cliux.inquire as jest.Mock).mockResolvedValueOnce(true);
190+
191+
await (cmd as any).rollbackDeployment();
192+
193+
expect(mutate).toHaveBeenCalledTimes(1);
194+
const variables = mutate.mock.calls[0][0].variables;
195+
expect(variables).toEqual({
196+
input: {
197+
deployment: 'target-uid',
198+
environment: 'env-uid',
199+
reason: 'restoring',
200+
},
201+
});
202+
expect((cmd as any).pollDeploymentStatus).toHaveBeenCalledWith('env-uid', 'target-uid');
203+
expect(exitMock).not.toHaveBeenCalled();
204+
});
205+
206+
it('logs an error and exits when the rollback mutation fails', async () => {
207+
const query = jest.fn().mockResolvedValueOnce(environmentsResponse);
208+
const error = Object.assign(new Error('boom'), {
209+
graphQLErrors: [{ extensions: { exception: { name: 'DeploymentRollbackFailed' } } }],
210+
});
211+
const mutate = jest.fn().mockRejectedValueOnce(error);
212+
const cmd = buildCommand(
213+
{ environment: 'Default', deployment: 'target-uid' },
214+
query,
215+
mutate,
216+
);
217+
jest
218+
.spyOn(cmd as any, 'fetchCurrentLiveDeployment')
219+
.mockResolvedValueOnce(liveDeployment);
220+
(cliux.inquire as jest.Mock)
221+
.mockResolvedValueOnce('') // reason
222+
.mockResolvedValueOnce(true); // confirm
223+
224+
await expect((cmd as any).rollbackDeployment()).rejects.toThrow('process.exit:1');
225+
226+
expect(mutate).toHaveBeenCalledTimes(1);
227+
expect(exitMock).toHaveBeenCalledWith(1);
228+
expect((cmd as any).log).toHaveBeenCalledWith(
229+
'Rollback failed. Please try again. (DeploymentRollbackFailed)',
230+
'error',
231+
);
232+
});
233+
});

src/commands/launch/rollback.ts

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -194,37 +194,37 @@ export default class Rollback extends BaseCommand<typeof Rollback> {
194194
*/
195195
async resolveEnvironment(): Promise<any> {
196196
const environments = await this.apolloClient
197-
.query({ query: environmentsQuery })
197+
.query({
198+
query: environmentsQuery,
199+
variables: { skipRollbackData: false },
200+
})
198201
.then(({ data: { Environments } }) => map(Environments.edges, 'node'))
199202
.catch((error) => {
200203
this.log(error?.message, 'error');
201204
process.exit(1);
202205
});
203206

204-
let environment = find(
205-
environments,
206-
({ uid, name }) =>
207-
uid === this.flags.environment ||
208-
name === this.flags.environment ||
209-
uid === this.sharedConfig.currentConfig?.environments?.[0]?.uid,
210-
);
211-
212-
if (isEmpty(environment) && (this.flags.environment || this.sharedConfig.currentConfig?.environments?.[0]?.uid)) {
213-
this.log('Environment(s) not found!', 'error');
214-
process.exit(1);
215-
} else if (isEmpty(environment)) {
216-
environment = await ux
217-
.inquire({
218-
type: 'search-list',
219-
name: 'Environment',
220-
choices: map(environments, (row) => ({ ...row, value: row.name })),
221-
message: 'Choose an environment',
222-
})
223-
.then((name: any) => find(environments, { name }) as Record<string, any>);
207+
if (this.flags.environment) {
208+
const environment = find(
209+
environments,
210+
({ uid, name }) => uid === this.flags.environment || name === this.flags.environment,
211+
);
212+
if (isEmpty(environment)) {
213+
this.log('Environment(s) not found!', 'error');
214+
process.exit(1);
215+
}
216+
return environment;
224217
}
225218

226-
this.sharedConfig.environment = environment;
227-
return environment;
219+
// NOTE: rollback is destructive; never auto-select from saved config — always prompt.
220+
return ux
221+
.inquire({
222+
type: 'search-list',
223+
name: 'Environment',
224+
choices: map(environments, (row) => ({ ...row, value: row.name })),
225+
message: 'Choose an environment',
226+
})
227+
.then((name: any) => find(environments, { name }) as Record<string, any>);
228228
}
229229

230230
/**
@@ -271,11 +271,15 @@ export default class Rollback extends BaseCommand<typeof Rollback> {
271271
return match;
272272
}
273273

274-
const choices = map(eligibleSorted, (d) => ({
275-
...d,
276-
name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${d.createdAt}`,
277-
value: d.uid,
278-
}));
274+
const choices = map(eligibleSorted, (d) => {
275+
const message = (d.commitMessage || '').split('\n')[0].trim() || '—';
276+
const truncated = message.length > 60 ? `${message.slice(0, 57)}…` : message;
277+
return {
278+
...d,
279+
name: `#${d.deploymentNumber} | ${sourceLabel(d) || '—'} | ${truncated} | ${d.createdAt}`,
280+
value: d.uid,
281+
};
282+
});
279283

280284
const selectedUid = await ux.inquire<string>({
281285
type: 'search-list',
@@ -387,8 +391,11 @@ function formatDeployment(deployment?: any): string {
387391
}
388392
const number = deployment.deploymentNumber ? `#${deployment.deploymentNumber}` : deployment.uid;
389393
const source = sourceLabel(deployment);
394+
const message = ((deployment.commitMessage || '').split('\n')[0] || '').trim();
395+
const truncated = message.length > 40 ? `${message.slice(0, 37)}…` : message;
390396
const createdAt = deployment.createdAt || '';
391397
const numberCol = chalk.green(number.padEnd(6));
392-
const sourceCol = source ? chalk.cyan(source.padEnd(28)) : ''.padEnd(28);
393-
return `${numberCol} ${sourceCol} ${chalk.dim(createdAt)}`;
398+
const sourceCol = source ? chalk.cyan(source.padEnd(22)) : ''.padEnd(22);
399+
const messageCol = truncated || chalk.dim('—');
400+
return `${numberCol} ${sourceCol} ${messageCol} ${chalk.dim(createdAt)}`;
394401
}

src/graphql/queries.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,17 @@ const latestLiveDeploymentQuery: DocumentNode = gql`
145145
environment
146146
deploymentNumber
147147
deploymentUrl
148+
status
149+
gitBranch
150+
commitHash
151+
commitMessage
152+
createdAt
148153
}
149154
}
150155
`;
151156

152157
const environmentsQuery: DocumentNode = gql`
153-
query Environments {
158+
query Environments($skipRollbackData: Boolean = true) {
154159
Environments {
155160
edges {
156161
node {
@@ -161,14 +166,14 @@ const environmentsQuery: DocumentNode = gql`
161166
edges {
162167
node {
163168
uid
164-
status
165-
gitBranch
166-
commitHash
167169
createdAt
168170
commitMessage
169171
deploymentUrl
170172
deploymentNumber
171-
isRollbackEligible
173+
status @skip(if: $skipRollbackData)
174+
gitBranch @skip(if: $skipRollbackData)
175+
commitHash @skip(if: $skipRollbackData)
176+
isRollbackEligible @skip(if: $skipRollbackData)
172177
}
173178
}
174179
}

0 commit comments

Comments
 (0)