From 5d9fec06cef9b7eaddb3da499cc0750faaa4060f Mon Sep 17 00:00:00 2001 From: CharithaT07 Date: Fri, 27 Mar 2026 08:52:15 +0530 Subject: [PATCH] @W-21720001 update resource profile support for sandboxes --- docs/cli/sandbox.md | 8 +- .../b2c-cli/src/commands/sandbox/update.ts | 32 ++- .../test/commands/sandbox/update.test.ts | 237 ++++++++++++++++++ 3 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 packages/b2c-cli/test/commands/sandbox/update.test.ts diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 8c7728ab..adc84a51 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -483,7 +483,7 @@ b2c sandbox reset zzzv-123 --json ## b2c sandbox update -Update a sandbox's TTL, scheduling, tags, or notification emails. +Update a sandbox's TTL, scheduling, resource profile, tags, or notification emails. ### Usage @@ -503,6 +503,7 @@ b2c sandbox update [FLAGS] |------|-------------| | `--ttl` | Number of hours to add to sandbox lifetime (0 or less for infinite). Must adhere to the maximum TTL configuration together with previous extensions. | | `--auto-scheduled` / `--no-auto-scheduled` | Enable or disable automatic start/stop scheduling | +| `--resource-profile` | Resource profile (`medium`, `large`, `xlarge`, `xxlarge`) | | `--tags` | Comma-separated list of tags | | `--emails` | Comma-separated list of notification email addresses | @@ -526,11 +527,14 @@ b2c sandbox update zzzv-123 --no-auto-scheduled # Set tags b2c sandbox update zzzv-123 --tags ci,nightly +# Update resource profile +b2c sandbox update zzzv-123 --resource-profile large + # Set notification emails b2c sandbox update zzzv-123 --emails dev@example.com,qa@example.com # Combine multiple updates -b2c sandbox update zzzv-123 --ttl 48 --tags ci,nightly +b2c sandbox update zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly # Output as JSON b2c sandbox update zzzv-123 --ttl 48 --json diff --git a/packages/b2c-cli/src/commands/sandbox/update.ts b/packages/b2c-cli/src/commands/sandbox/update.ts index fc203044..bb48f95c 100644 --- a/packages/b2c-cli/src/commands/sandbox/update.ts +++ b/packages/b2c-cli/src/commands/sandbox/update.ts @@ -11,6 +11,7 @@ import {t, withDocs} from '../../i18n/index.js'; type SandboxModel = OdsComponents['schemas']['SandboxModel']; type SandboxUpdateRequestModel = OdsComponents['schemas']['SandboxUpdateRequestModel']; +type SandboxResourceProfile = OdsComponents['schemas']['SandboxResourceProfile']; /** * Command to update an on-demand sandbox. @@ -26,7 +27,10 @@ export default class SandboxUpdate extends OdsCommand { }; static description = withDocs( - t('commands.sandbox.update.description', 'Update a sandbox (extend TTL, change scheduling, update tags or emails)'), + t( + 'commands.sandbox.update.description', + 'Update a sandbox (extend TTL, change scheduling, update resource xprofile, tags, or emails)', + ), '/cli/sandbox.html#b2c-sandbox-update', ); @@ -37,9 +41,10 @@ export default class SandboxUpdate extends OdsCommand { '<%= config.bin %> <%= command.id %> zzzv-123 --ttl 0', '<%= config.bin %> <%= command.id %> zzzv-123 --auto-scheduled', '<%= config.bin %> <%= command.id %> zzzv-123 --no-auto-scheduled', + '<%= config.bin %> <%= command.id %> zzzv-123 --resource-profile large', '<%= config.bin %> <%= command.id %> zzzv-123 --tags tag1,tag2', '<%= config.bin %> <%= command.id %> zzzv-123 --emails user@example.com,dev@example.com', - '<%= config.bin %> <%= command.id %> zzzv-123 --ttl 48 --tags ci,nightly --json', + '<%= config.bin %> <%= command.id %> zzzv-123 --ttl 48 --resource-profile xlarge --tags ci,nightly --json', ]; static flags = { @@ -50,6 +55,10 @@ export default class SandboxUpdate extends OdsCommand { description: 'Enable or disable automatic start/stop scheduling', allowNo: true, }), + 'resource-profile': Flags.string({ + description: 'Resource profile (medium, large, xlarge, xxlarge)', + options: ['medium', 'large', 'xlarge', 'xxlarge'], + }), tags: Flags.string({ description: 'Comma-separated list of tags', }), @@ -60,11 +69,19 @@ export default class SandboxUpdate extends OdsCommand { async run(): Promise { const sandboxId = await this.resolveSandboxId(this.args.sandboxId); - const {ttl, 'auto-scheduled': autoScheduled, tags, emails} = this.flags; + const {ttl, 'auto-scheduled': autoScheduled, 'resource-profile': resourceProfile, tags, emails} = this.flags; // Require at least one update flag - if (ttl === undefined && autoScheduled === undefined && tags === undefined && emails === undefined) { - this.error('At least one update flag is required. Use --ttl, --auto-scheduled, --tags, or --emails.'); + if ( + ttl === undefined && + autoScheduled === undefined && + resourceProfile === undefined && + tags === undefined && + emails === undefined + ) { + this.error( + 'At least one update flag is required. Use --ttl, --auto-scheduled, --resource-profile, --tags, or --emails.', + ); } const body: SandboxUpdateRequestModel = {}; @@ -77,6 +94,10 @@ export default class SandboxUpdate extends OdsCommand { body.autoScheduled = autoScheduled; } + if (resourceProfile !== undefined) { + body.resourceProfile = resourceProfile as SandboxResourceProfile; + } + if (tags !== undefined) { body.tags = tags.split(',').map((tag) => tag.trim()); } @@ -120,6 +141,7 @@ export default class SandboxUpdate extends OdsCommand { ['Realm', sandbox.realm], ['Instance', sandbox.instance], ['State', sandbox.state], + ['Profile', sandbox.resourceProfile], ['Auto Scheduled', sandbox.autoScheduled?.toString()], ['EOL', sandbox.eol ? new Date(sandbox.eol).toLocaleString() : undefined], ]; diff --git a/packages/b2c-cli/test/commands/sandbox/update.test.ts b/packages/b2c-cli/test/commands/sandbox/update.test.ts new file mode 100644 index 00000000..a03f4b83 --- /dev/null +++ b/packages/b2c-cli/test/commands/sandbox/update.test.ts @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import SandboxUpdate from '../../../src/commands/sandbox/update.js'; +import { + createIsolatedConfigHooks, + createTestCommand, + makeCommandThrowOnError, + runSilent, + stubJsonEnabled, +} from '../../helpers/test-setup.js'; + +function stubOdsClient(command: any, client: Partial<{PATCH: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function stubOdsHost(command: any, host = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'odsHost', { + value: host, + configurable: true, + }); +} + +describe('sandbox update', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + }); + + afterEach(() => { + sinon.restore(); + hooks.afterEach(); + }); + + async function setupCommand(flags: Record, args: Record): Promise { + const config = hooks.getConfig(); + const command = await createTestCommand(SandboxUpdate as any, config, flags, args); + + stubOdsHost(command); + (command as any).log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('sends resourceProfile in PATCH body when --resource-profile is set', async () => { + const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, true); + + let requestUrl: string | undefined; + let requestOptions: any; + + stubOdsClient(command, { + async PATCH(url: string, options: any) { + requestUrl = url; + requestOptions = options; + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + resourceProfile: 'large', + }, + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestUrl).to.equal('/sandboxes/{sandboxId}'); + expect(requestOptions).to.have.nested.property('params.path.sandboxId', 'sb-uuid-123'); + expect(requestOptions).to.have.nested.property('body.resourceProfile', 'large'); + expect(result.resourceProfile).to.equal('large'); + }); + + it('allows combining --resource-profile with other update flags', async () => { + const command = await setupCommand( + {'resource-profile': 'xlarge', ttl: 48, tags: 'ci,nightly'}, + {sandboxId: 'zzzz-001'}, + ); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, true); + + let requestOptions: any; + stubOdsClient(command, { + async PATCH(_: string, options: any) { + requestOptions = options; + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + resourceProfile: 'xlarge', + tags: ['ci', 'nightly'], + }, + }, + }; + }, + }); + + await runSilent(() => command.run()); + + expect(requestOptions.body).to.include({ + ttl: 48, + resourceProfile: 'xlarge', + }); + expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']); + }); + + it('supports --no-auto-scheduled and sends autoScheduled=false', async () => { + const command = await setupCommand({'auto-scheduled': false}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, true); + + let requestOptions: any; + stubOdsClient(command, { + async PATCH(_: string, options: any) { + requestOptions = options; + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + autoScheduled: false, + }, + }, + }; + }, + }); + + await runSilent(() => command.run()); + + expect(requestOptions.body).to.include({ + autoScheduled: false, + }); + }); + + it('trims tags and emails when combined with --resource-profile', async () => { + const command = await setupCommand( + { + 'resource-profile': 'xxlarge', + tags: ' ci , nightly ', + emails: ' dev@example.com , qa@example.com ', + }, + {sandboxId: 'zzzz-001'}, + ); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubJsonEnabled(command, true); + + let requestOptions: any; + stubOdsClient(command, { + async PATCH(_: string, options: any) { + requestOptions = options; + return { + data: { + data: { + id: 'sb-uuid-123', + realm: 'zzzz', + state: 'started', + resourceProfile: 'xxlarge', + tags: ['ci', 'nightly'], + emails: ['dev@example.com', 'qa@example.com'], + }, + }, + }; + }, + }); + + const result: any = await runSilent(() => command.run()); + + expect(requestOptions.body.resourceProfile).to.equal('xxlarge'); + expect(requestOptions.body.tags).to.deep.equal(['ci', 'nightly']); + expect(requestOptions.body.emails).to.deep.equal(['dev@example.com', 'qa@example.com']); + expect(result.tags).to.deep.equal(['ci', 'nightly']); + expect(result.emails).to.deep.equal(['dev@example.com', 'qa@example.com']); + }); + + it('requires at least one update flag including --resource-profile', async () => { + const command = await setupCommand({}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubOdsClient(command, { + async PATCH() { + throw new Error('PATCH should not be called when no flags are provided'); + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected command to error when no update flags are provided'); + } catch (error: any) { + expect(error.message).to.include('At least one update flag is required'); + expect(error.message).to.include('--resource-profile'); + } + }); + + it('throws a helpful error when API update fails', async () => { + const command = await setupCommand({'resource-profile': 'large'}, {sandboxId: 'zzzz-001'}); + + sinon.stub(command as any, 'resolveSandboxId').resolves('sb-uuid-123'); + stubOdsClient(command, { + async PATCH() { + return { + data: undefined, + error: {error: {message: 'Profile update not allowed in current state'}}, + response: {statusText: 'Bad Request'}, + }; + }, + }); + + try { + await runSilent(() => command.run()); + expect.fail('Expected command to throw on API error'); + } catch (error: any) { + expect(error.message).to.include('Failed to update sandbox'); + expect(error.message).to.match(/Profile update not allowed|Bad Request/); + } + }); +});