Skip to content

Commit eb70540

Browse files
authored
Merge pull request #5 from salesforcecli/t/tm-connect/updating-backend-api-call-pattern
W-21920947: Adopt new backend response, table output, and structured errors
2 parents 6f5e6a8 + 05b19d0 commit eb70540

5 files changed

Lines changed: 76 additions & 142 deletions

File tree

messages/license.provision.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ Path to a JSON file that contains the PSL provisioning request information.
4444

4545
<%= config.bin %> <%= command.id %> --target-org myScratchOrg --definition-file test/config/provisionPSLs.json
4646

47-
# success.provisioned
47+
# success.traceId
4848

49-
Provisioned %s licenses for the license definition '%s'
49+
Trace ID: %s
5050

5151
# error.missingLicenseFlag
5252

@@ -60,14 +60,18 @@ The --definition-file flag cannot be used with --namespace, --license, --quantit
6060

6161
The definition file must contain at least one license entry.
6262

63-
# error.invalidDateFormat
64-
65-
Invalid date format '%s' for --%s. Expected YYYY-MM-DD.
66-
6763
# error.provisionFailed
6864

6965
Failed to provision licenses. %s
7066

7167
# success
7268

7369
Success:
70+
71+
# success.column.licenseDefinition
72+
73+
License Definition
74+
75+
# success.column.provisionedQuantity
76+
77+
Provisioned Quantity

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
],
5454
"topics": {
5555
"license": {
56-
"description": "description for license"
56+
"description": "Commands to provision and manage Permission Set Licenses in a scratch org."
5757
}
5858
},
5959
"flexibleTaxonomy": true

schemas/license-provision.json

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,8 @@
88
"status": {
99
"type": "string"
1010
},
11-
"messages": {
12-
"type": "array",
13-
"items": {
14-
"type": "object",
15-
"properties": {
16-
"errorCode": {
17-
"type": "string"
18-
},
19-
"message": {
20-
"type": "string"
21-
}
22-
},
23-
"required": ["errorCode", "message"],
24-
"additionalProperties": false
25-
}
11+
"traceId": {
12+
"type": "string"
2613
}
2714
},
2815
"required": ["status"],

src/commands/license/provision.ts

Lines changed: 19 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import { Messages, SfError } from '@salesforce/core';
2121
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2222
const messages = Messages.loadMessages('@salesforce/plugin-license-management', 'license.provision');
2323

24-
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
25-
2624
type ProvisionLicenseSpec = {
2725
namespacePrefix?: string;
2826
permissionSetLicense?: string;
@@ -35,34 +33,23 @@ type ProvisionPslRequest = {
3533
licenses: ProvisionLicenseSpec[];
3634
};
3735

38-
type ProvisionErrorMessage = {
39-
errorCode: string;
40-
message: string;
41-
};
42-
4336
type ProvisionPslResponse = {
4437
status: string;
4538
licensesProvisioned?: number;
4639
message?: string;
47-
messages?: ProvisionErrorMessage[];
40+
traceId?: string;
4841
};
4942

5043
export type LicenseProvisionResult = {
5144
status: string;
52-
messages?: ProvisionErrorMessage[];
45+
traceId?: string;
5346
};
5447

5548
function getLicenseDefinitionName(spec: ProvisionLicenseSpec): string {
5649
const psl = spec.permissionSetLicense ?? '';
5750
return spec.namespacePrefix ? `${spec.namespacePrefix}__${psl}` : psl;
5851
}
5952

60-
function validateDate(dateStr: string, flagName: string): void {
61-
if (!DATE_REGEX.test(dateStr)) {
62-
throw messages.createError('error.invalidDateFormat', [dateStr, flagName]);
63-
}
64-
}
65-
6653
export default class LicenseProvision extends SfCommand<LicenseProvisionResult> {
6754
public static readonly summary = messages.getMessage('summary');
6855
public static readonly description = messages.getMessage('description');
@@ -153,11 +140,6 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
153140
? await LicenseProvision.loadSpecsFromFile(flags['definition-file'], flags)
154141
: LicenseProvision.buildSpecsFromFlags(flags);
155142

156-
for (const spec of licenseSpecs) {
157-
if (spec.startDate) validateDate(spec.startDate, 'start-date');
158-
if (spec.endDate) validateDate(spec.endDate, 'end-date');
159-
}
160-
161143
const endpoint = `/services/data/v${connection.getApiVersion()}/partnerdevelopment/permissionsetlicenses`;
162144
const requestBody: ProvisionPslRequest = { licenses: licenseSpecs };
163145

@@ -174,22 +156,28 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
174156
}
175157

176158
if (response.status !== 'SUCCESS') {
177-
const errorMessages: ProvisionErrorMessage[] =
178-
response.messages ?? (response.message ? [{ errorCode: 'PROVISION_ERROR', message: response.message }] : []);
179-
180-
const errorDetail = errorMessages.map((m) => m.message).join(' ');
181159
throw SfError.create({
182-
message: messages.getMessage('error.provisionFailed', [errorDetail]),
160+
message: messages.getMessage('error.provisionFailed', [response.message ?? 'Unknown error']),
183161
name: 'PROVISION_FAILED',
184-
data: { status: 'error', messages: errorMessages },
162+
data: { traceId: response.traceId },
185163
});
186164
}
187165

188-
this.log(messages.getMessage('success'));
189-
for (const spec of licenseSpecs) {
190-
this.log(messages.getMessage('success.provisioned', [spec.quantity ?? 0, getLicenseDefinitionName(spec)]));
191-
}
166+
this.display(licenseSpecs, response);
192167

193-
return { status: 'success' };
168+
return { status: 'success', traceId: response.traceId };
169+
}
170+
171+
private display(licenseSpecs: ProvisionLicenseSpec[], response: ProvisionPslResponse): void {
172+
this.table({
173+
data: licenseSpecs.map((spec) => ({
174+
[messages.getMessage('success.column.licenseDefinition')]: getLicenseDefinitionName(spec),
175+
[messages.getMessage('success.column.provisionedQuantity')]: String(spec.quantity ?? 0),
176+
})),
177+
title: messages.getMessage('success'),
178+
});
179+
if (response.traceId) {
180+
this.log(messages.getMessage('success.traceId', [response.traceId]));
181+
}
194182
}
195183
}

test/commands/license/provision.test.ts

Lines changed: 44 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { stubSfCommandUx } from '@salesforce/sf-plugins-core';
2323
import { Connection, Org } from '@salesforce/core';
2424
import LicenseProvision from '../../../src/commands/license/provision.js';
2525

26-
const SUCCESS_RESPONSE = { status: 'SUCCESS', licensesProvisioned: 5, message: 'OK' };
26+
const SUCCESS_RESPONSE = { status: 'SUCCESS', licensesProvisioned: 5, message: 'OK', traceId: 'tm_test123' };
2727

2828
describe('license provision', () => {
2929
const $$ = new TestContext();
@@ -71,21 +71,22 @@ describe('license provision', () => {
7171
'2027-03-30',
7272
]);
7373

74-
const output = sfCommandStubs.log
75-
.getCalls()
76-
.flatMap((c) => c.args)
77-
.join('\n');
78-
expect(output).to.equal("Success:\nProvisioned 5 licenses for the license definition 'demo__newLicense'");
74+
const tableCall = sfCommandStubs.table.firstCall.args[0] as { data: Array<Record<string, string>>; title: string };
75+
expect(tableCall.title).to.equal('Success:');
76+
expect(tableCall.data).to.deep.include({ 'License Definition': 'demo__newLicense', 'Provisioned Quantity': '5' });
77+
expect(
78+
sfCommandStubs.log
79+
.getCalls()
80+
.flatMap((c) => c.args)
81+
.join('\n')
82+
).to.include('Trace ID: tm_test123');
7983
});
8084

8185
it('provisions a PSL without a namespace', async () => {
8286
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense', '--quantity', '3']);
8387

84-
const output = sfCommandStubs.log
85-
.getCalls()
86-
.flatMap((c) => c.args)
87-
.join('\n');
88-
expect(output).to.include("Provisioned 3 licenses for the license definition 'myLicense'");
88+
const tableCall = sfCommandStubs.table.firstCall.args[0] as { data: Array<Record<string, string>> };
89+
expect(tableCall.data).to.deep.include({ 'License Definition': 'myLicense', 'Provisioned Quantity': '3' });
8990
});
9091

9192
it('sends the correct POST request payload', async () => {
@@ -130,9 +131,9 @@ describe('license provision', () => {
130131
expect(body.licenses[0].startDate).to.equal(today);
131132
});
132133

133-
it('returns status:success result', async () => {
134+
it('returns status:success result with traceId', async () => {
134135
const result = await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'myLicense']);
135-
expect(result).to.deep.equal({ status: 'success' });
136+
expect(result).to.deep.equal({ status: 'success', traceId: 'tm_test123' });
136137
});
137138

138139
// ─── Success: definition file ────────────────────────────────────────────────
@@ -161,12 +162,22 @@ describe('license provision', () => {
161162

162163
await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]);
163164

164-
const output = sfCommandStubs.log
165-
.getCalls()
166-
.flatMap((c) => c.args)
167-
.join('\n');
168-
expect(output).to.include("Provisioned 5 licenses for the license definition 'demo__newLicense'");
169-
expect(output).to.include("Provisioned 8 licenses for the license definition 'demo__premiumLicense'");
165+
const tableCall = sfCommandStubs.table.firstCall.args[0] as {
166+
data: Array<Record<string, string>>;
167+
title: string;
168+
};
169+
expect(tableCall.title).to.equal('Success:');
170+
expect(tableCall.data).to.deep.include({ 'License Definition': 'demo__newLicense', 'Provisioned Quantity': '5' });
171+
expect(tableCall.data).to.deep.include({
172+
'License Definition': 'demo__premiumLicense',
173+
'Provisioned Quantity': '8',
174+
});
175+
expect(
176+
sfCommandStubs.log
177+
.getCalls()
178+
.flatMap((c) => c.args)
179+
.join('\n')
180+
).to.include('Trace ID: tm_test123');
170181
});
171182

172183
it('sends all PSLs from the definition file in a single request', async () => {
@@ -240,40 +251,6 @@ describe('license provision', () => {
240251
}
241252
});
242253

243-
it('throws for an invalid start-date format', async () => {
244-
try {
245-
await LicenseProvision.run([
246-
'--target-org',
247-
testOrg.username,
248-
'--license',
249-
'myLicense',
250-
'--start-date',
251-
'30-03-2026',
252-
]);
253-
expect.fail('Expected an error to be thrown');
254-
} catch (error: unknown) {
255-
expect((error as Error).message).to.include('30-03-2026');
256-
expect((error as Error).message).to.include('start-date');
257-
}
258-
});
259-
260-
it('throws for an invalid end-date format', async () => {
261-
try {
262-
await LicenseProvision.run([
263-
'--target-org',
264-
testOrg.username,
265-
'--license',
266-
'myLicense',
267-
'--end-date',
268-
'March 30 2027',
269-
]);
270-
expect.fail('Expected an error to be thrown');
271-
} catch (error: unknown) {
272-
expect((error as Error).message).to.include('March 30 2027');
273-
expect((error as Error).message).to.include('end-date');
274-
}
275-
});
276-
277254
it('throws when definition file contains no license entries', async () => {
278255
const tmpFilePath = join(tmpdir(), `provision-empty-${Date.now()}.json`);
279256
await writeFile(tmpFilePath, JSON.stringify({ licenses: [] }));
@@ -289,56 +266,34 @@ describe('license provision', () => {
289266

290267
// ─── API error responses ─────────────────────────────────────────────────────
291268

292-
it('throws with the server error message when status is error', async () => {
293-
requestStub.resolves({
294-
status: 'error',
295-
messages: [
296-
{ errorCode: 'INVALID_LICENSE_DEFINITION', message: "License definition not found for 'demo__badLicense'" },
297-
],
298-
});
269+
it('wraps a HTTP 400 provisioning error as SfError', async () => {
270+
requestStub.rejects(
271+
Object.assign(new Error("Invalid endDate format for permissionSetLicense 'premium'"), {
272+
name: 'INVALID_END_DATE',
273+
})
274+
);
299275

300276
try {
301-
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'badLicense', '--namespace', 'demo']);
277+
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'premium']);
302278
expect.fail('Expected an error to be thrown');
303279
} catch (error: unknown) {
304-
expect((error as Error).message).to.include("License definition not found for 'demo__badLicense'");
280+
expect((error as Error).message).to.include('Invalid endDate format');
305281
}
306282
});
307283

308-
it('includes all error messages when multiple PSLs fail', async () => {
309-
requestStub.resolves({
310-
status: 'error',
311-
messages: [
312-
{ errorCode: 'INVALID_LICENSE_DEFINITION', message: "License definition not found for 'demo__badLicense'" },
313-
{ errorCode: 'INVALID_QUANTITY', message: "Quantity cannot be negative for 'demo__negativeLicense'" },
314-
],
315-
});
316-
317-
const tmpFilePath = join(tmpdir(), `provision-multi-err-${Date.now()}.json`);
318-
await writeFile(
319-
tmpFilePath,
320-
JSON.stringify({
321-
licenses: [
322-
{ namespacePrefix: 'demo', permissionSetLicense: 'badLicense', quantity: 5 },
323-
{ namespacePrefix: 'demo', permissionSetLicense: 'negativeLicense', quantity: -1 },
324-
],
325-
})
326-
);
284+
it('throws with a generic message when non-SUCCESS response has no message', async () => {
285+
requestStub.resolves({ status: 'ERROR' });
327286

328287
try {
329-
await LicenseProvision.run(['--target-org', testOrg.username, '--definition-file', tmpFilePath]);
288+
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']);
330289
expect.fail('Expected an error to be thrown');
331290
} catch (error: unknown) {
332-
const msg = (error as Error).message;
333-
expect(msg).to.include("License definition not found for 'demo__badLicense'");
334-
expect(msg).to.include("Quantity cannot be negative for 'demo__negativeLicense'");
335-
} finally {
336-
await unlink(tmpFilePath).catch(() => {});
291+
expect((error as Error).message).to.include('Unknown error');
337292
}
338293
});
339294

340-
it('falls back to the message field when messages array is absent', async () => {
341-
requestStub.resolves({ status: 'error', message: 'An unexpected error occurred' });
295+
it('falls back gracefully when non-200 response has no structured error body', async () => {
296+
requestStub.resolves({ status: 'ERROR', message: 'An unexpected error occurred' });
342297

343298
try {
344299
await LicenseProvision.run(['--target-org', testOrg.username, '--license', 'anyLicense']);

0 commit comments

Comments
 (0)