diff --git a/packages/b2c-cli/src/commands/ods/list.ts b/packages/b2c-cli/src/commands/ods/list.ts index d0deaa41..a7415bc9 100644 --- a/packages/b2c-cli/src/commands/ods/list.ts +++ b/packages/b2c-cli/src/commands/ods/list.ts @@ -142,15 +142,17 @@ export default class OdsList extends OdsCommand { }, }); - if (!result.data?.data) { + if (result.error) { + const errorResponse = result.error as OdsComponents['schemas']['ErrorResponse'] | undefined; + const errorMessage = errorResponse?.error?.message || result.response?.statusText || 'Unknown error'; this.error( t('commands.ods.list.error', 'Failed to fetch sandboxes: {{message}}', { - message: result.response?.statusText || 'Unknown error', + message: errorMessage, }), ); } - const sandboxes = result.data.data; + const sandboxes = result.data?.data ?? []; const response: OdsListResponse = { count: sandboxes.length, data: sandboxes, diff --git a/packages/b2c-cli/test/commands/ods/create.test.ts b/packages/b2c-cli/test/commands/ods/create.test.ts new file mode 100644 index 00000000..40c2e5de --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/create.test.ts @@ -0,0 +1,320 @@ +/* + * 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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any, unicorn/consistent-function-scoping */ +import {expect} from 'chai'; +import OdsCreate from '../../../src/commands/ods/create.js'; +import { + makeCommandThrowOnError, + stubCommandConfigAndLogger, + stubOdsClient, + stubResolvedConfig, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS create command CLI logic. + * Tests settings building, permission logic, wait/poll logic. + * SDK tests cover the actual API calls. + */ +describe('ods create', () => { + describe('buildSettings', () => { + it('should return undefined when set-permissions is false', () => { + const command = new OdsCreate([], {} as any); + (command as any).flags = {'set-permissions': false}; + + // Accessing private method for testing + const settings = (command as any).buildSettings(false); + + expect(settings).to.be.undefined; + }); + + it('should return undefined when no client ID is configured', () => { + const command = new OdsCreate([], {} as any); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, {}); + + const settings = (command as any).buildSettings(true); + + expect(settings).to.be.undefined; + }); + + it('should build settings with OCAPI and WebDAV permissions', () => { + const command = new OdsCreate([], {} as any); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, {clientId: 'test-client-id'}); + + const settings = (command as any).buildSettings(true); + + expect(settings).to.exist; + expect(settings).to.have.property('ocapi'); + expect(settings).to.have.property('webdav'); + expect(settings.ocapi).to.be.an('array').with.length.greaterThan(0); + expect(settings.webdav).to.be.an('array').with.length.greaterThan(0); + expect(settings.ocapi[0]).to.have.property('client_id'); + expect(settings.webdav[0]).to.have.property('client_id'); + }); + + it('should include default OCAPI resources', () => { + const command = new OdsCreate([], {} as any); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, {clientId: 'test-client-id'}); + + const settings = (command as any).buildSettings(true); + + const resources = settings.ocapi[0].resources; + expect(resources).to.be.an('array'); + expect(resources.some((r: any) => r.resource_id === '/code_versions')).to.be.true; + expect(resources.some((r: any) => r.resource_id.includes('/jobs/'))).to.be.true; + }); + + it('should include default WebDAV permissions', () => { + const command = new OdsCreate([], {} as any); + stubCommandConfigAndLogger(command); + stubResolvedConfig(command, {clientId: 'test-client-id'}); + + const settings = (command as any).buildSettings(true); + + const permissions = settings.webdav[0].permissions; + expect(permissions).to.be.an('array'); + expect(permissions.some((p: any) => p.path === '/impex')).to.be.true; + expect(permissions.some((p: any) => p.path === '/cartridges')).to.be.true; + }); + }); + + describe('flag defaults', () => { + it('should have correct default TTL', () => { + expect(OdsCreate.flags.ttl.default).to.equal(24); + }); + + it('should have correct default profile', () => { + expect(OdsCreate.flags.profile.default).to.equal('medium'); + }); + + it('should have correct default for set-permissions', () => { + expect(OdsCreate.flags['set-permissions'].default).to.equal(true); + }); + + it('should have correct default for auto-scheduled', () => { + expect(OdsCreate.flags['auto-scheduled'].default).to.equal(false); + }); + + it('should have correct default for wait', () => { + expect(OdsCreate.flags.wait.default).to.equal(false); + }); + + it('should have correct default poll interval', () => { + expect(OdsCreate.flags['poll-interval'].default).to.equal(10); + }); + + it('should have correct default timeout', () => { + expect(OdsCreate.flags.timeout.default).to.equal(600); + }); + }); + + describe('profile options', () => { + it('should only allow valid profile values', () => { + const validProfiles = ['medium', 'large', 'xlarge', 'xxlarge']; + expect(OdsCreate.flags.profile.options).to.deep.equal(validProfiles); + }); + }); + + describe('run()', () => { + function setupCreateCommand(): OdsCreate { + const command = new OdsCreate([], {} as any); + + stubCommandConfigAndLogger(command); + + // Mock log & error + command.log = () => {}; + makeCommandThrowOnError(command); + + return command; + } + + it('should create sandbox successfully without wait', async () => { + const command = setupCreateCommand(); + + (command as any).flags = { + realm: 'abcd', + ttl: 24, + profile: 'medium', + 'auto-scheduled': false, + wait: false, + 'set-permissions': false, + json: true, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: { + data: { + id: 'sb-123', + realm: 'abcd', + state: 'creating', + }, + }, + }), + }); + + const result = await command.run(); + + expect(result.id).to.equal('sb-123'); + }); + + it('should throw error when sandbox creation fails', async () => { + const command = setupCreateCommand(); + + (command as any).flags = { + realm: 'abcd', + ttl: 24, + profile: 'medium', + wait: false, + 'set-permissions': false, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: undefined, + error: { + error: {message: 'Invalid realm'}, + }, + response: { + statusText: 'Bad Request', + }, + }), + }); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create sandbox'); + } + }); + + it('should not include settings when set-permissions is false', async () => { + const command = setupCreateCommand(); + + (command as any).flags = { + realm: 'abcd', + ttl: 24, + profile: 'medium', + wait: false, + 'set-permissions': false, + }; + + let requestBody: any; + + stubOdsClient(command, { + async POST(_url: string, options: any) { + requestBody = options.body; + return { + data: {data: {id: 'sb-1', state: 'creating'}}, + }; + }, + }); + + await command.run(); + + expect(requestBody.settings).to.be.undefined; + }); + + describe('waitForSandbox()', () => { + it('should wait until sandbox reaches started state', async () => { + const command = setupCreateCommand(); + let calls = 0; + + const mockClient = { + async GET() { + calls++; + return { + data: { + data: { + state: calls < 2 ? 'creating' : 'started', + }, + }, + }; + }, + }; + + stubOdsClient(command, mockClient); + + const result = await (command as any).waitForSandbox('sb-1', 0, 5); + + expect(result.state).to.equal('started'); + }); + + it('should error when sandbox enters failed state', async () => { + const command = setupCreateCommand(); + + stubOdsClient(command, { + GET: async () => ({ + data: {data: {state: 'failed'}}, + }), + }); + + try { + await (command as any).waitForSandbox('sb-1', 0, 5); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Sandbox creation failed'); + } + }); + + it('should error when sandbox is deleted', async () => { + const command = setupCreateCommand(); + + stubOdsClient(command, { + GET: async () => ({ + data: {data: {state: 'deleted'}}, + }), + }); + + try { + await (command as any).waitForSandbox('sb-1', 0, 5); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Sandbox was deleted'); + } + }); + + it('should timeout if sandbox never reaches terminal state', async () => { + const command = setupCreateCommand(); + + stubOdsClient(command, { + GET: async () => ({ + data: {data: {state: 'creating'}}, + }), + }); + + try { + await (command as any).waitForSandbox('sb-1', 0, 1); + expect.fail('Expected timeout'); + } catch (error: any) { + expect(error.message).to.include('Timeout waiting for sandbox'); + } + }); + + it('should error if polling API returns no data', async () => { + const command = setupCreateCommand(); + + stubOdsClient(command, { + GET: async () => ({ + data: undefined, + response: {statusText: 'Internal Error'}, + }), + }); + + try { + await (command as any).waitForSandbox('sb-1', 0, 5); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch sandbox status'); + } + }); + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/delete.test.ts b/packages/b2c-cli/test/commands/ods/delete.test.ts new file mode 100644 index 00000000..96e54240 --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/delete.test.ts @@ -0,0 +1,243 @@ +/* + * 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'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import OdsDelete from '../../../src/commands/ods/delete.js'; +import { + makeCommandThrowOnError, + stubCommandConfigAndLogger, + stubJsonEnabled, + stubOdsClient, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS delete command CLI logic. + * Tests confirmation logic and flag handling. + * SDK tests cover the actual API calls. + */ +describe('ods delete', () => { + describe('command structure', () => { + it('should require sandboxId as argument', () => { + expect(OdsDelete.args).to.have.property('sandboxId'); + expect(OdsDelete.args.sandboxId.required).to.be.true; + }); + + it('should have force flag', () => { + expect(OdsDelete.flags).to.have.property('force'); + expect(OdsDelete.flags.force.char).to.equal('f'); + }); + + it('should have correct description', () => { + expect(OdsDelete.description).to.be.a('string'); + expect(OdsDelete.description.toLowerCase()).to.include('delete'); + }); + + it('should have examples', () => { + expect(OdsDelete.examples).to.be.an('array'); + expect(OdsDelete.examples.length).to.be.greaterThan(0); + }); + }); + + describe('flag defaults', () => { + it('should have force flag default to false', () => { + expect(OdsDelete.flags.force.default).to.be.false; + }); + }); + + describe('output formatting', () => { + it('should delete successfully with --force flag', async () => { + const command = new OdsDelete([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'delete' as const, + operationState: 'running' as const, + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: {id: 'sandbox-123', realm: 'zzzv', instance: 'zzzv-001'}}, + response: new Response(), + }), + DELETE: async () => ({ + data: {data: mockOperation}, + response: new Response(null, {status: 202}), + }), + }); + + await command.run(); + + expect(logs.length).to.be.greaterThan(0); + }); + + it('should log messages in non-JSON mode', async () => { + const command = new OdsDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: {id: 'sandbox-123', realm: 'zzzv'}}, + response: new Response(), + }), + DELETE: async () => ({ + data: {data: {}}, + response: new Response(null, {status: 202}), + }), + }); + + await command.run(); + + expect(logs.length).to.be.greaterThan(0); + }); + + it('should error when sandbox not found', async () => { + const command = new OdsDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'nonexistent'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: undefined}, + response: new Response(null, {status: 404}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Sandbox not found'); + } + }); + + it('should error when delete operation fails', async () => { + const command = new OdsDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + command.log = () => {}; + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: {id: 'sandbox-123', realm: 'zzzv'}}, + response: new Response(), + }), + DELETE: async () => ({ + data: undefined, + error: {error: {message: 'Operation failed'}}, + response: new Response(null, {status: 500}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete sandbox'); + expect(error.message).to.include('Operation failed'); + } + }); + + it('should handle null sandbox data', async () => { + const command = new OdsDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: null as any, + response: new Response(null, {status: 500}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Sandbox not found'); + } + }); + + it('should error on non-202 response status', async () => { + const command = new OdsDelete([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + (command as any).flags = {force: true}; + stubCommandConfigAndLogger(command); + command.log = () => {}; + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: {id: 'sandbox-123', realm: 'zzzv'}}, + response: new Response(), + }), + DELETE: async () => ({ + data: {data: {}}, + response: new Response(null, {status: 400, statusText: 'Bad Request'}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete sandbox'); + expect(error.message).to.include('Bad Request'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/get.test.ts b/packages/b2c-cli/test/commands/ods/get.test.ts new file mode 100644 index 00000000..b0743fbd --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/get.test.ts @@ -0,0 +1,185 @@ +/* + * 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'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import OdsGet from '../../../src/commands/ods/get.js'; +import { + makeCommandThrowOnError, + stubJsonEnabled, + stubOdsClient, + stubCommandConfigAndLogger, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS get command CLI logic. + * Tests output formatting. + * SDK tests cover the actual API calls. + */ +describe('ods get', () => { + describe('command structure', () => { + it('should require sandboxId as argument', () => { + expect(OdsGet.args).to.have.property('sandboxId'); + expect(OdsGet.args.sandboxId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OdsGet.description).to.be.a('string'); + expect(OdsGet.description.length).to.be.greaterThan(0); + }); + + it('should enable JSON flag', () => { + expect(OdsGet.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should return sandbox data in JSON mode', async () => { + const command = new OdsGet([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockSandbox = { + id: 'sandbox-123', + realm: 'zzzv', + state: 'started' as const, + hostName: 'zzzv-001.dx.commercecloud.salesforce.com', + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: mockSandbox}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockSandbox); + expect(result.id).to.equal('sandbox-123'); + expect(result.state).to.equal('started'); + }); + + it('should return sandbox data in non-JSON mode', async () => { + const command = new OdsGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, false); + + const mockSandbox = { + id: 'sandbox-123', + realm: 'zzzv', + state: 'started' as const, + hostName: 'zzzv-001.test.com', + createdAt: '2025-01-01T00:00:00Z', + }; + + stubOdsClient(command, { + GET: async () => ({ + data: {data: mockSandbox}, + response: new Response(), + }), + }); + + const result = await command.run(); + + // Command returns the sandbox data regardless of JSON mode + expect(result.id).to.equal('sandbox-123'); + expect(result.state).to.equal('started'); + }); + + it('should handle missing sandbox data', async () => { + const command = new OdsGet([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'nonexistent'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: undefined}, + response: new Response(null, {status: 404}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch sandbox/); + } + }); + + it('should handle null sandbox data', async () => { + const command = new OdsGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sb-null'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: null as any, + response: new Response(null, {status: 500}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch sandbox/); + } + }); + + it('should handle API errors with error message', async () => { + const command = new OdsGet([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sb-error'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: undefined, + error: {error: {message: 'Sandbox not found'}}, + response: new Response(null, {status: 404, statusText: 'Not Found'}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch sandbox'); + // Error message uses API error message or status text + expect(error.message).to.match(/Sandbox not found|Not Found/); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/info.test.ts b/packages/b2c-cli/test/commands/ods/info.test.ts new file mode 100644 index 00000000..042bec4e --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/info.test.ts @@ -0,0 +1,221 @@ +/* + * 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'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import OdsInfo from '../../../src/commands/ods/info.js'; +import { + makeCommandThrowOnError, + stubCommandConfigAndLogger, + stubJsonEnabled, + stubOdsClientGet, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS info command CLI logic. + * Tests output formatting and data combination. + * SDK tests cover the actual API calls. + */ +describe('ods info', () => { + describe('command structure', () => { + it('should have correct description', () => { + expect(OdsInfo.description).to.be.a('string'); + expect(OdsInfo.description).to.include('information'); + }); + + it('should enable JSON flag', () => { + expect(OdsInfo.enableJsonFlag).to.be.true; + }); + }); + + describe('output formatting', () => { + it('should combine user and system info in JSON mode', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + const mockUserInfo = { + data: { + user: {id: 'user-1', name: 'Test User'}, + realms: ['zzzv'], + }, + }; + + const mockSystemInfo = { + data: { + region: 'us-east-1', + sandboxIps: ['1.2.3.4'], + }, + }; + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return {data: mockUserInfo, response: new Response()}; + } + if (path === '/system') { + return {data: mockSystemInfo, response: new Response()}; + } + throw new Error(`Unexpected path: ${path}`); + }); + + const result = await command.run(); + + expect(result).to.have.property('user'); + expect(result).to.have.property('system'); + expect(result.user).to.deep.equal(mockUserInfo.data); + expect(result.system).to.deep.equal(mockSystemInfo.data); + }); + + it('should display formatted info in non-JSON mode', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + + stubCommandConfigAndLogger(command); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) logs.push(msg); + }; + + const mockUserInfo = { + data: { + user: {id: 'user-1', email: 'test@example.com'}, + realms: ['zzzv', 'abcd'], + }, + }; + + const mockSystemInfo = { + data: { + region: 'us-east-1', + sandboxIps: ['1.2.3.4', '5.6.7.8'], + }, + }; + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return {data: mockUserInfo, response: new Response()}; + } + if (path === '/system') { + return {data: mockSystemInfo, response: new Response()}; + } + throw new Error(`Unexpected path: ${path}`); + }); + + const result = await command.run(); + + expect(result).to.have.property('user'); + expect(result).to.have.property('system'); + // Should have logged information + expect(logs.length).to.be.greaterThan(0); + }); + + it('should error when user info fails', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return {data: {data: {user: {id: 'user-1'}}}, response: new Response()}; + } + if (path === '/system') { + return { + data: undefined, + response: new Response(null, {status: 500, statusText: 'Internal Server Error'}), + }; + } + throw new Error(`Unexpected path: ${path}`); + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch system info/); + } + }); + + it('should error when system info fails', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return {data: {data: {user: {id: 'user-1'}}}, response: new Response()}; + } + if (path === '/system') { + return { + data: undefined, + response: new Response(null, {status: 500, statusText: 'Internal Server Error'}), + }; + } + throw new Error(`Unexpected path: ${path}`); + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch system info/); + } + }); + + it('should handle null user info data', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + + stubCommandConfigAndLogger(command); + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return {data: {data: null}, response: new Response()}; + } + if (path === '/system') { + return {data: {data: {region: 'us-east-1'}}, response: new Response()}; + } + throw new Error(`Unexpected path: ${path}`); + }); + + const result = await command.run(); + expect(result.user).to.equal(null); + expect(result.system).to.deep.equal({region: 'us-east-1'}); + }); + + it('should handle API errors with error messages', async () => { + const command = new OdsInfo([], {} as any); + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + stubOdsClientGet(command, async (path: string) => { + if (path === '/me') { + return { + data: undefined, + response: new Response(null, {status: 401, statusText: 'Unauthorized'}), + }; + } + if (path === '/system') { + return {data: {data: {region: 'us-east-1'}}, response: new Response()}; + } + throw new Error(`Unexpected path: ${path}`); + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.include('Failed to fetch user info'); + expect(error.message).to.include('Unauthorized'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/list.test.ts b/packages/b2c-cli/test/commands/ods/list.test.ts new file mode 100644 index 00000000..599a67bd --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/list.test.ts @@ -0,0 +1,256 @@ +/* + * 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 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {expect} from 'chai'; +import OdsList from '../../../src/commands/ods/list.js'; +import { + makeCommandThrowOnError, + stubCommandConfigAndLogger, + stubJsonEnabled, + stubOdsClient, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS list command CLI logic. + * Tests column selection, filter building, output formatting. + * SDK tests cover the actual API calls. + */ +describe('ods list', () => { + describe('getSelectedColumns', () => { + it('should return default columns when no flags provided', () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['realm', 'instance', 'state', 'profile', 'created', 'eol', 'id']); + }); + + it('should return all columns when --extended flag is set', () => { + const command = new OdsList([], {} as any); + (command as any).flags = {extended: true}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.include('realm'); + expect(columns).to.include('hostname'); + expect(columns).to.include('createdBy'); + expect(columns).to.include('autoScheduled'); + }); + + it('should return custom columns when --columns flag is set', () => { + const command = new OdsList([], {} as any); + (command as any).flags = {columns: 'id,state,hostname'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.deep.equal(['id', 'state', 'hostname']); + }); + + it('should ignore invalid column names', () => { + const command = new OdsList([], {} as any); + (command as any).flags = {columns: 'id,invalid,state'}; + const columns = (command as any).getSelectedColumns(); + + expect(columns).to.not.include('invalid'); + expect(columns).to.include('id'); + expect(columns).to.include('state'); + }); + }); + + describe('filter parameter building', () => { + it('should build filter params from realm flag', () => { + const command = new OdsList([], {} as any); + (command as any).flags = {realm: 'zzzv'}; + + const realm = (command as any).flags.realm; + expect(realm).to.equal('zzzv'); + }); + + it('should combine realm and custom filter params', () => { + const command = new OdsList([], {} as any); + (command as any).flags = { + realm: 'zzzv', + 'filter-params': 'state=started', + }; + + const parts: string[] = []; + if ((command as any).flags.realm) parts.push(`realm=${(command as any).flags.realm}`); + if ((command as any).flags['filter-params']) parts.push((command as any).flags['filter-params']); + const filterParams = parts.join('&'); + + expect(filterParams).to.equal('realm=zzzv&state=started'); + }); + }); + + describe('output formatting', () => { + it('should return count and data in JSON mode', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + + stubOdsClient(command, { + GET: async () => ({ + data: { + data: [ + {id: '1', realm: 'zzzv', state: 'started'}, + {id: '2', realm: 'zzzv', state: 'stopped'}, + ], + }, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.have.property('count', 2); + expect(result).to.have.property('data'); + expect(result.data).to.have.lengthOf(2); + }); + + it('should handle empty results', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: []}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result.count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + + it('should return data in non-JSON mode', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubOdsClient(command, { + GET: async () => ({ + data: { + data: [{id: 'sb-1', realm: 'zzzv', state: 'started', hostName: 'host1.test.com'}], + }, + response: new Response(), + }), + }); + + const result = await command.run(); + + // Command returns data regardless of JSON mode + expect(result).to.have.property('count', 1); + expect(result.data).to.have.lengthOf(1); + expect(result.data[0].id).to.equal('sb-1'); + }); + + it('should error on null data', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + stubOdsClient(command, { + GET: async () => ({ + data: null as any, + error: {error: {}}, + response: new Response(null, {status: 500, statusText: 'Internal Server Error'}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown an error'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch sandboxes/); + expect(error.message).to.include('Internal Server Error'); + } + }); + + it('should handle undefined data as empty list', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, true); + stubCommandConfigAndLogger(command); + stubOdsClient(command, { + GET: async () => ({ + data: {data: undefined as any}, + response: new Response(null, {status: 200}), + }), + }); + + const result = await command.run(); + + // Should treat undefined as empty list, not error + expect(result.count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + + it('should handle empty API response gracefully in non-JSON mode', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubJsonEnabled(command, false); + stubCommandConfigAndLogger(command); + stubOdsClient(command, { + GET: async () => ({ + data: {}, + response: {statusText: 'OK'}, + }), + }); + + const result = await command.run(); + + expect(result.count).to.equal(0); + expect(result.data).to.deep.equal([]); + }); + + it('should error when result.data is completely missing', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + + stubOdsClient(command, { + GET: async () => ({ + data: null as any, + error: {error: {message: 'Internal error'}}, + response: new Response(null, {status: 500, statusText: 'Internal Server Error'}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch sandboxes/); + expect(error.message).to.include('Internal error'); + } + }); + + it('should handle API errors gracefully', async () => { + const command = new OdsList([], {} as any); + (command as any).flags = {realm: 'invalid'}; + stubCommandConfigAndLogger(command); + makeCommandThrowOnError(command); + stubOdsClient(command, { + GET: async () => ({ + data: undefined, + error: {error: {message: 'Invalid realm'}}, + response: new Response(null, {status: 400, statusText: 'Bad Request'}), + }), + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch (error: any) { + expect(error.message).to.match(/Failed to fetch sandboxes/); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/operations.test.ts b/packages/b2c-cli/test/commands/ods/operations.test.ts new file mode 100644 index 00000000..61d0aa88 --- /dev/null +++ b/packages/b2c-cli/test/commands/ods/operations.test.ts @@ -0,0 +1,368 @@ +/* + * 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 OdsStart from '../../../src/commands/ods/start.js'; + +import OdsStop from '../../../src/commands/ods/stop.js'; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import OdsRestart from '../../../src/commands/ods/restart.js'; +import { + makeCommandThrowOnError, + stubCommandConfigAndLogger, + stubJsonEnabled, + stubOdsClient, +} from '../../helpers/ods.js'; + +/** + * Unit tests for ODS operation commands CLI logic. + * Tests start, stop, restart command structure and output. + * SDK tests cover the actual API calls. + */ +describe('ods operations', () => { + describe('ods start', () => { + it('should require sandboxId as argument', () => { + expect(OdsStart.args).to.have.property('sandboxId'); + expect(OdsStart.args.sandboxId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OdsStart.description).to.be.a('string'); + expect(OdsStart.description.toLowerCase()).to.include('start'); + }); + + it('should enable JSON flag', () => { + expect(OdsStart.enableJsonFlag).to.be.true; + }); + + it('should return operation data in JSON mode', async () => { + const command = new OdsStart([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'start' as const, + operationState: 'running' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(result.operation).to.equal('start'); + }); + + it('should log success message in non-JSON mode', async () => { + const command = new OdsStart([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) { + logs.push(msg); + } + }; + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'start' as const, + operationState: 'running' as const, + sandboxState: 'starting' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(logs.length).to.be.greaterThan(0); + }); + + it('should throw when API returns no operation data', async () => { + const command = new OdsStart([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + command.log = () => {}; + makeCommandThrowOnError(command); + stubOdsClient(command, { + POST: async () => ({ + data: undefined, + error: {error: {message: 'Bad request'}}, + response: {statusText: 'Bad Request'}, + }), + }); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to start sandbox'); + expect(error.message).to.include('Bad request'); + } + }); + }); + + describe('ods stop', () => { + it('should require sandboxId as argument', () => { + expect(OdsStop.args).to.have.property('sandboxId'); + expect(OdsStop.args.sandboxId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OdsStop.description).to.be.a('string'); + expect(OdsStop.description.toLowerCase()).to.include('stop'); + }); + + it('should enable JSON flag', () => { + expect(OdsStop.enableJsonFlag).to.be.true; + }); + + it('should return operation data in JSON mode', async () => { + const command = new OdsStop([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'stop' as const, + operationState: 'running' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(result.operation).to.equal('stop'); + }); + + it('should log success message in non-JSON mode', async () => { + const command = new OdsStop([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) { + logs.push(msg); + } + }; + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'stop' as const, + operationState: 'running' as const, + sandboxState: 'stopping' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(logs.length).to.be.greaterThan(0); + }); + + it('should throw when API returns no operation data', async () => { + const command = new OdsStop([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + command.log = () => {}; + makeCommandThrowOnError(command); + stubOdsClient(command, { + POST: async () => ({ + data: undefined, + error: {error: {message: 'Bad request'}}, + response: {statusText: 'Bad Request'}, + }), + }); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to stop sandbox'); + expect(error.message).to.include('Bad request'); + } + }); + }); + + describe('ods restart', () => { + it('should require sandboxId as argument', () => { + expect(OdsRestart.args).to.have.property('sandboxId'); + expect(OdsRestart.args.sandboxId.required).to.be.true; + }); + + it('should have correct description', () => { + expect(OdsRestart.description).to.be.a('string'); + expect(OdsRestart.description.toLowerCase()).to.include('restart'); + }); + + it('should enable JSON flag', () => { + expect(OdsRestart.enableJsonFlag).to.be.true; + }); + + it('should return operation data in JSON mode', async () => { + const command = new OdsRestart([], {} as any); + + // Mock args + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + stubJsonEnabled(command, true); + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'restart' as const, + operationState: 'running' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(result.operation).to.equal('restart'); + }); + + it('should log success message in non-JSON mode', async () => { + const command = new OdsRestart([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + + const logs: string[] = []; + command.log = (msg?: string) => { + if (msg !== undefined) { + logs.push(msg); + } + }; + + const mockOperation = { + id: 'op-123', + sandboxId: 'sandbox-123', + operation: 'restart' as const, + operationState: 'running' as const, + sandboxState: 'restarting' as const, + }; + + stubOdsClient(command, { + POST: async () => ({ + data: {data: mockOperation}, + response: new Response(), + }), + }); + + const result = await command.run(); + + expect(result).to.deep.equal(mockOperation); + expect(logs.length).to.be.greaterThan(0); + }); + + it('should throw when API returns no operation data', async () => { + const command = new OdsRestart([], {} as any); + + Object.defineProperty(command, 'args', { + value: {sandboxId: 'sandbox-123'}, + configurable: true, + }); + + stubCommandConfigAndLogger(command); + command.log = () => {}; + makeCommandThrowOnError(command); + stubOdsClient(command, { + POST: async () => ({ + data: undefined, + error: {error: {message: 'Bad request'}}, + response: {statusText: 'Bad Request'}, + }), + }); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error: any) { + expect(error.message).to.include('Failed to restart sandbox'); + expect(error.message).to.include('Bad request'); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/helpers/ods.ts b/packages/b2c-cli/test/helpers/ods.ts new file mode 100644 index 00000000..802dba6f --- /dev/null +++ b/packages/b2c-cli/test/helpers/ods.ts @@ -0,0 +1,56 @@ +/* + * 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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +export function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +export function stubOdsClientGet(command: any, handler: (path: string) => Promise): void { + Object.defineProperty(command, 'odsClient', { + value: { + GET: handler, + }, + configurable: true, + }); +} + +export function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +export function stubResolvedConfig(command: any, resolvedConfig: Record): void { + Object.defineProperty(command, 'resolvedConfig', { + get: () => resolvedConfig, + configurable: true, + }); +} + +export function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} diff --git a/packages/b2c-tooling-sdk/test/clients/ods.test.ts b/packages/b2c-tooling-sdk/test/clients/ods.test.ts new file mode 100644 index 00000000..14bc2651 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/ods.test.ts @@ -0,0 +1,508 @@ +/* + * 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 {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createOdsClient} from '../../src/clients/ods.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const TEST_HOST = 'admin.test.dx.commercecloud.salesforce.com'; +const BASE_URL = `https://${TEST_HOST}/api/v1`; + +describe('ODS Client', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + let odsClient: ReturnType; + let mockAuth: MockAuthStrategy; + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + odsClient = createOdsClient({host: TEST_HOST}, mockAuth); + }); + + describe('client creation', () => { + it('should create client with default host', () => { + const auth = new MockAuthStrategy(); + const client = createOdsClient({}, auth); + expect(client).to.exist; + }); + + it('should create client with custom host', () => { + const auth = new MockAuthStrategy(); + const client = createOdsClient({host: 'custom.host.com'}, auth); + expect(client).to.exist; + }); + + it('should create client with extra params', () => { + const auth = new MockAuthStrategy(); + const client = createOdsClient( + { + extraParams: { + query: {debug: 'true'}, + body: {_internal: {trace: true}}, + }, + }, + auth, + ); + expect(client).to.exist; + }); + }); + + describe('GET /sandboxes', () => { + it('should list sandboxes', async () => { + const mockSandboxes = [ + {id: 'sb-1', realm: 'aaab', state: 'started'}, + {id: 'sb-2', realm: 'aaaa', state: 'stopped'}, + ]; + + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json({data: mockSandboxes}); + }), + ); + + const {data, error} = await odsClient.GET('/sandboxes', {}); + + expect(error).to.be.undefined; + expect(data?.data).to.have.lengthOf(2); + expect(data?.data?.[0].id).to.equal('sb-1'); + }); + + it('should handle empty sandbox list', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json({data: []}); + }), + ); + + const {data, error} = await odsClient.GET('/sandboxes', {}); + + expect(error).to.be.undefined; + expect(data?.data).to.be.an('array').with.lengthOf(0); + }); + + it('should pass query parameters correctly', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('include_deleted')).to.equal('true'); + expect(url.searchParams.get('filter_params')).to.equal('realm=zzzv'); + return HttpResponse.json({data: []}); + }), + ); + + const {data, error} = await odsClient.GET('/sandboxes', { + params: { + query: { + include_deleted: true, + filter_params: 'realm=zzzv', + }, + }, + }); + + expect(error).to.be.undefined; + expect(data?.data).to.be.an('array').with.lengthOf(0); + }); + + it('should handle 401 error', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json( + { + error: { + message: 'Unauthorized', + code: 'UNAUTHORIZED', + }, + }, + {status: 401}, + ); + }), + ); + + const {data, error, response} = await odsClient.GET('/sandboxes', {}); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(401); + }); + + it('should handle 500 error', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, () => { + return new HttpResponse(null, {status: 500, statusText: 'Internal Server Error'}); + }), + ); + + const {data, error, response} = await odsClient.GET('/sandboxes', {}); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(500); + }); + }); + + describe('GET /sandboxes/{sandboxId}', () => { + it('should get sandbox by ID', async () => { + const mockSandbox = { + id: 'sb-123', + realm: 'zzzv', + state: 'started', + }; + + server.use( + http.get(`${BASE_URL}/sandboxes/sb-123`, () => { + return HttpResponse.json({data: mockSandbox}); + }), + ); + + const {data, error} = await odsClient.GET('/sandboxes/{sandboxId}', { + params: { + path: {sandboxId: 'sb-123'}, + }, + }); + + expect(error).to.be.undefined; + expect(data?.data?.id).to.equal('sb-123'); + expect(data?.data?.state).to.equal('started'); + expect(data?.data?.realm).to.equal('zzzv'); + }); + + it('should handle 404 not found', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes/nonexistent`, () => { + return HttpResponse.json( + { + error: { + message: 'Sandbox not found', + code: 'NOT_FOUND', + }, + }, + {status: 404}, + ); + }), + ); + + const {data, error, response} = await odsClient.GET('/sandboxes/{sandboxId}', { + params: { + path: {sandboxId: 'nonexistent'}, + }, + }); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(404); + }); + }); + + describe('POST /sandboxes', () => { + it('should create a sandbox', async () => { + server.use( + http.post(`${BASE_URL}/sandboxes`, async ({request}) => { + const body = (await request.json()) as {realm: string; ttl: number; resourceProfile: string}; + expect(body.realm).to.equal('zzzv'); + expect(body.ttl).to.equal(24); + expect(body.resourceProfile).to.equal('medium'); + return HttpResponse.json({ + data: { + id: 'new-sb-id', + realm: body.realm, + state: 'creating', + resourceProfile: body.resourceProfile, + }, + }); + }), + ); + + const {data, error} = await odsClient.POST('/sandboxes', { + body: { + realm: 'zzzv', + ttl: 24, + resourceProfile: 'medium', + autoScheduled: false, + analyticsEnabled: false, + }, + }); + + expect(error).to.be.undefined; + expect(data?.data?.id).to.equal('new-sb-id'); + expect(data?.data?.state).to.equal('creating'); + }); + + it('should include settings in request', async () => { + server.use( + http.post(`${BASE_URL}/sandboxes`, async ({request}) => { + const body = (await request.json()) as { + settings?: {ocapi?: unknown[]; webdav?: unknown[]}; + }; + expect(body.settings).to.exist; + expect(body.settings?.ocapi).to.be.an('array'); + expect(body.settings?.webdav).to.be.an('array'); + return HttpResponse.json({ + data: {id: 'new-sb-id', realm: 'zzzv', state: 'creating'}, + }); + }), + ); + + const {data, error} = await odsClient.POST('/sandboxes', { + body: { + realm: 'zzzv', + ttl: 24, + resourceProfile: 'medium', + autoScheduled: false, + analyticsEnabled: false, + settings: { + ocapi: [{client_id: 'test-client', resources: []}], + webdav: [{client_id: 'test-client', permissions: []}], + }, + }, + }); + + expect(error).to.be.undefined; + expect(data?.data?.id).to.equal('new-sb-id'); + expect(data?.data?.state).to.equal('creating'); + }); + + it('should handle validation errors', async () => { + server.use( + http.post(`${BASE_URL}/sandboxes`, () => { + return HttpResponse.json( + { + error: { + message: 'Invalid realm', + code: 'VALIDATION_ERROR', + }, + }, + {status: 400}, + ); + }), + ); + + const {data, error, response} = await odsClient.POST('/sandboxes', { + body: { + realm: 'invalid', + ttl: 24, + resourceProfile: 'medium', + autoScheduled: false, + analyticsEnabled: false, + }, + }); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(400); + }); + }); + + describe('POST /sandboxes/{sandboxId}/operations', () => { + ['start', 'stop', 'restart'].forEach((operation) => { + it(`should ${operation} a sandbox`, async () => { + server.use( + http.post(`${BASE_URL}/sandboxes/sb-123/operations`, async ({request}) => { + const body = (await request.json()) as {operation: string}; + expect(body.operation).to.equal(operation); + return HttpResponse.json({ + data: { + id: `op-${operation}-123`, + sandboxId: 'sb-123', + operation, + operationState: 'running', + }, + }); + }), + ); + + const {data, error} = await odsClient.POST('/sandboxes/{sandboxId}/operations', { + params: { + path: {sandboxId: 'sb-123'}, + }, + body: { + operation: operation as 'start' | 'stop' | 'restart', + }, + }); + + expect(error).to.be.undefined; + expect(data?.data?.operationState).to.equal('running'); + }); + }); + + it('should handle invalid state transitions', async () => { + server.use( + http.post(`${BASE_URL}/sandboxes/sb-123/operations`, () => { + return HttpResponse.json( + { + error: { + message: 'Invalid state transition', + code: 'INVALID_STATE', + }, + }, + {status: 400}, + ); + }), + ); + + const {data, error, response} = await odsClient.POST('/sandboxes/{sandboxId}/operations', { + params: { + path: {sandboxId: 'sb-123'}, + }, + body: { + operation: 'start', + }, + }); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(400); + }); + }); + + describe('DELETE /sandboxes/{sandboxId}', () => { + it('should delete a sandbox', async () => { + server.use( + http.delete(`${BASE_URL}/sandboxes/sb-123`, () => { + return HttpResponse.json({ + data: { + id: 'op-123', + sandboxId: 'sb-123', + status: 'deleting', + }, + }); + }), + ); + + const {error} = await odsClient.DELETE('/sandboxes/{sandboxId}', { + params: { + path: {sandboxId: 'sb-123'}, + }, + }); + + expect(error).to.be.undefined; + }); + + it('should handle sandbox not found', async () => { + server.use( + http.delete(`${BASE_URL}/sandboxes/nonexistent`, () => { + return HttpResponse.json( + { + error: { + message: 'Sandbox not found', + code: 'NOT_FOUND', + }, + }, + {status: 404}, + ); + }), + ); + + const {data, error, response} = await odsClient.DELETE('/sandboxes/{sandboxId}', { + params: { + path: {sandboxId: 'nonexistent'}, + }, + }); + + expect(data).to.be.undefined; + expect(error).to.exist; + expect(response.status).to.equal(404); + }); + }); + + describe('GET /me', () => { + it('should get user info', async () => { + const mockUserInfo = { + data: { + user: { + id: 'user-123', + name: 'Test User', + email: 'test@example.com', + }, + realms: ['zzzv', 'aaaa'], + sandboxes: ['sb-1', 'sb-2'], + }, + }; + + server.use( + http.get(`${BASE_URL}/me`, () => { + return HttpResponse.json(mockUserInfo); + }), + ); + + const {data, error} = await odsClient.GET('/me', {}); + + expect(error).to.be.undefined; + expect(data?.data?.user?.name).to.equal('Test User'); + expect(data?.data?.realms).to.have.lengthOf(2); + }); + }); + + describe('GET /system', () => { + it('should get system info', async () => { + const mockSystemInfo = { + data: { + region: 'us-east-1', + inboundIps: ['1.2.3.4'], + outboundIps: ['5.6.7.8'], + sandboxIps: ['9.10.11.12', '13.14.15.16'], + }, + }; + + server.use( + http.get(`${BASE_URL}/system`, () => { + return HttpResponse.json(mockSystemInfo); + }), + ); + + const {data, error} = await odsClient.GET('/system', {}); + + expect(error).to.be.undefined; + expect(data?.data?.region).to.equal('us-east-1'); + expect(data?.data?.sandboxIps).to.have.lengthOf(2); + }); + }); + + describe('authentication', () => { + it('should include Bearer token in requests', async () => { + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const authHeader = request.headers.get('Authorization'); + expect(authHeader).to.equal('Bearer test-token'); + return HttpResponse.json({data: []}); + }), + ); + + const {error} = await odsClient.GET('/sandboxes', {}); + + expect(error).to.be.undefined; + }); + + it('should use custom token when provided', async () => { + const customAuth = new MockAuthStrategy('custom-token-123'); + const customClient = createOdsClient({host: TEST_HOST}, customAuth); + + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const authHeader = request.headers.get('Authorization'); + expect(authHeader).to.equal('Bearer custom-token-123'); + return HttpResponse.json({data: []}); + }), + ); + + const {error} = await customClient.GET('/sandboxes', {}); + expect(error).to.be.undefined; + }); + }); +});