diff --git a/packages/b2c-tooling-sdk/.c8rc.json b/packages/b2c-tooling-sdk/.c8rc.json index d4459b30..96aaa560 100644 --- a/packages/b2c-tooling-sdk/.c8rc.json +++ b/packages/b2c-tooling-sdk/.c8rc.json @@ -4,7 +4,8 @@ "exclude": [ "src/clients/*.generated.ts", "test/**", - "**/*.d.ts" + "**/*.d.ts", + "src/**/*types.ts" ], "reporter": ["text", "text-summary", "html", "lcov"], "report-dir": "coverage", diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts new file mode 100644 index 00000000..be88137c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -0,0 +1,590 @@ +/* + * 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 {Config} from '@oclif/core'; +import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a concrete test command class +class TestBaseCommand extends BaseCommand { + static id = 'test:base'; + static description = 'Test base command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testGetExtraParams() { + return this.getExtraParams(); + } + + public testCatch(err: Error & {exitCode?: number}) { + return this.catch(err); + } +} + +// Type for mocking command properties in tests +type MockableBaseCommand = TestBaseCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; + logger: { + info: ((message: string, context?: Record) => void) & + ((context: Record, message: string) => void); + warn: ((message: string, context?: Record) => void) & + ((context: Record, message: string) => void); + error: ((message: string, context?: Record) => void) & + ((context: Record, message: string) => void); + debug: ((message: string, context?: Record) => void) & + ((context: Record, message: string) => void); + }; +}; + +describe('cli/base-command', () => { + let config: Config; + let command: TestBaseCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestBaseCommand([], config); + }); + + describe('init', () => { + it('initializes command with default flags', async () => { + // Mock parse method + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + + expect(cmd.flags).to.be.an('object'); + expect(cmd.args).to.be.an('object'); + expect(cmd.resolvedConfig).to.be.an('object'); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + + it('handles log-level flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'log-level': 'debug'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + + it('handles debug flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {debug: true}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + + it('handles json flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {json: true}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.json).to.be.true; + + cmd.parse = originalParse; + }); + + it('handles lang flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {lang: 'de'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.lang).to.equal('de'); + + cmd.parse = originalParse; + }); + + it('handles config flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {config: '/custom/dw.json'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.config).to.equal('/custom/dw.json'); + + cmd.parse = originalParse; + }); + + it('handles instance flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {instance: 'test-instance'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.instance).to.equal('test-instance'); + + cmd.parse = originalParse; + }); + }); + + describe('configureLogging', () => { + it('configures logger with default level', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + + it('configures logger with log-level flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'log-level': 'warn'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + + it('configures logger with debug flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {debug: true}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.logger).to.exist; + expect(cmd.logger.info).to.be.a('function'); + + cmd.parse = originalParse; + }); + }); + + describe('log', () => { + it('logs message using logger', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let loggedMessage = ''; + const originalInfo = cmd.logger.info.bind(cmd.logger); + cmd.logger.info = ((message: string) => { + loggedMessage = message; + }) as typeof cmd.logger.info; + + cmd.log('Test message'); + expect(loggedMessage).to.equal('Test message'); + + cmd.logger.info = originalInfo; + cmd.parse = originalParse; + }); + + it('logs message with args', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let loggedMessage = ''; + const originalInfo = cmd.logger.info.bind(cmd.logger); + cmd.logger.info = ((message: string) => { + loggedMessage = message; + }) as typeof cmd.logger.info; + + cmd.log('Test message', 'arg1', 'arg2'); + expect(loggedMessage).to.include('Test message'); + expect(loggedMessage).to.include('arg1'); + expect(loggedMessage).to.include('arg2'); + + cmd.logger.info = originalInfo; + cmd.parse = originalParse; + }); + }); + + describe('warn', () => { + it('warns with string message', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let warnedMessage = ''; + const originalWarn = cmd.logger.warn.bind(cmd.logger); + cmd.logger.warn = ((msg: string) => { + warnedMessage = msg; + }) as typeof cmd.logger.warn; + + const result = cmd.warn('Warning message'); + expect(result).to.equal('Warning message'); + expect(warnedMessage).to.equal('Warning message'); + + cmd.logger.warn = originalWarn; + cmd.parse = originalParse; + }); + + it('warns with Error object', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let warnedMessage = ''; + const originalWarn = cmd.logger.warn.bind(cmd.logger); + cmd.logger.warn = ((msg: string) => { + warnedMessage = msg; + }) as typeof cmd.logger.warn; + + const error = new Error('Error message'); + const result = cmd.warn(error); + expect(result).to.equal(error); + expect(warnedMessage).to.equal('Error message'); + + cmd.logger.warn = originalWarn; + cmd.parse = originalParse; + }); + }); + + describe('getExtraParams', () => { + it('returns undefined when no extra params', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const params = command.testGetExtraParams(); + expect(params).to.be.undefined; + + cmd.parse = originalParse; + }); + + it('parses extra-query flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'extra-query': '{"debug":"true"}'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const params = command.testGetExtraParams(); + expect(params?.query).to.deep.equal({debug: 'true'}); + + cmd.parse = originalParse; + }); + + it('parses extra-body flag', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'extra-body': '{"_internal":true}'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const params = command.testGetExtraParams(); + expect(params?.body).to.deep.equal({_internal: true}); + + cmd.parse = originalParse; + }); + + it('parses both extra-query and extra-body', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: { + 'extra-query': '{"debug":"true"}', + 'extra-body': '{"_internal":true}', + }, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const params = command.testGetExtraParams(); + expect(params?.query).to.deep.equal({debug: 'true'}); + expect(params?.body).to.deep.equal({_internal: true}); + + cmd.parse = originalParse; + }); + + it('throws error for invalid JSON in extra-query', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'extra-query': 'invalid-json'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testGetExtraParams(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + + it('throws error for invalid JSON in extra-body', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'extra-body': 'invalid-json'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testGetExtraParams(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + }); + + describe('baseCommandTest', () => { + it('logs test message', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let loggedMessage = ''; + const originalInfo = cmd.logger.info.bind(cmd.logger); + cmd.logger.info = ((message: string) => { + loggedMessage = message; + }) as typeof cmd.logger.info; + + cmd.baseCommandTest(); + expect(loggedMessage).to.include('BaseCommand initialized'); + + cmd.logger.info = originalInfo; + cmd.parse = originalParse; + }); + }); + + describe('shouldColorize', () => { + it('respects NO_COLOR environment variable', async () => { + const originalNoColor = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // shouldColorize is private, so we test it indirectly through configureLogging + // The logger will be configured with colorize=false when NO_COLOR is set + expect(cmd.logger).to.exist; + + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } + + cmd.parse = originalParse; + }); + }); + + describe('catch', () => { + it('handles errors with exit code', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {json: false}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + const error = new Error('Test error') as Error & {exitCode?: number}; + error.exitCode = 2; + + try { + await command.testCatch(error); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + + it('outputs JSON error in JSON mode', async () => { + const cmd = command as MockableBaseCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {json: true}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + + // Mock jsonEnabled to return true + const originalJsonEnabled = cmd.jsonEnabled?.bind(command); + cmd.jsonEnabled = () => true; + + let writtenData = ''; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk: string | Buffer) => { + writtenData += chunk.toString(); + return true; + }; + + const originalExit = process.exit.bind(process); + let exitCode: number | undefined; + process.exit = (code?: number) => { + exitCode = code; + throw new Error('Exit called'); + }; + + const error = new Error('Test error'); + + try { + await command.testCatch(error); + } catch { + // Expected + } + + // In JSON mode, error should be written to stderr as JSON + expect(writtenData).to.include('error'); + expect(writtenData).to.include('Test error'); + expect(exitCode).to.equal(1); + + process.stderr.write = originalWrite; + process.exit = originalExit; + if (originalJsonEnabled) { + cmd.jsonEnabled = originalJsonEnabled; + } + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts b/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts new file mode 100644 index 00000000..c78fcdc5 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/cartridge-command.test.ts @@ -0,0 +1,281 @@ +/* + * 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 {Config} from '@oclif/core'; +import {CartridgeCommand} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestCartridgeCommand extends CartridgeCommand { + static id = 'test:cartridge'; + static description = 'Test cartridge command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testCartridgePath() { + return this.cartridgePath; + } + + public testCartridgeOptions() { + return this.cartridgeOptions; + } + + public testFindCartridgesWithProviders(directory?: string, options?: {include?: string[]; exclude?: string[]}) { + return this.findCartridgesWithProviders(directory, options); + } +} + +// Type for mocking command properties in tests +type MockableCartridgeCommand = TestCartridgeCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; +}; + +describe('cli/cartridge-command', () => { + let config: Config; + let command: TestCartridgeCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestCartridgeCommand([], config); + }); + + describe('init', () => { + it('initializes command with cartridge flags', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags).to.be.an('object'); + expect(cmd.args).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('handles cartridgePath argument', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '/custom/path'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.args.cartridgePath).to.equal('/custom/path'); + + cmd.parse = originalParse; + }); + + it('uses default cartridgePath when not specified', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.args.cartridgePath).to.equal('.'); + + cmd.parse = originalParse; + }); + + it('handles cartridge flag', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {cartridge: ['cart1', 'cart2']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.cartridge).to.be.an('array'); + + cmd.parse = originalParse; + }); + + it('handles exclude-cartridge flag', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {'exclude-cartridge': ['cart1', 'cart2']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['exclude-cartridge']).to.be.an('array'); + + cmd.parse = originalParse; + }); + }); + + describe('cartridgePath', () => { + it('returns default path when not specified', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testCartridgePath(); + expect(path).to.equal('.'); + + cmd.parse = originalParse; + }); + + it('returns custom path from args', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '/custom/path'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testCartridgePath(); + expect(path).to.equal('/custom/path'); + + cmd.parse = originalParse; + }); + }); + + describe('cartridgeOptions', () => { + it('returns empty options when no flags', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const options = command.testCartridgeOptions(); + expect(options.include).to.be.undefined; + expect(options.exclude).to.be.undefined; + + cmd.parse = originalParse; + }); + + it('returns include options from flag', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {cartridge: ['cart1', 'cart2']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const options = command.testCartridgeOptions(); + expect(options.include).to.be.an('array'); + expect(options.include).to.include('cart1'); + expect(options.include).to.include('cart2'); + + cmd.parse = originalParse; + }); + + it('returns exclude options from flag', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {'exclude-cartridge': ['cart1', 'cart2']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const options = command.testCartridgeOptions(); + expect(options.exclude).to.be.an('array'); + expect(options.exclude).to.include('cart1'); + expect(options.exclude).to.include('cart2'); + + cmd.parse = originalParse; + }); + }); + + describe('findCartridgesWithProviders', () => { + it('returns default cartridges when no providers', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // This will use the actual findCartridges function which may not find cartridges + // in the test environment, so we just verify it doesn't throw + try { + await command.testFindCartridgesWithProviders(); + } catch { + // Expected if no cartridges found + } + + cmd.parse = originalParse; + }); + + it('accepts custom directory', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + await command.testFindCartridgesWithProviders('/custom/dir'); + } catch { + // Expected if no cartridges found + } + + cmd.parse = originalParse; + }); + + it('accepts custom options', async () => { + const cmd = command as MockableCartridgeCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {cartridgePath: '.'}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + await command.testFindCartridgesWithProviders(undefined, {include: ['cart1']}); + } catch { + // Expected if no cartridges found + } + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/cartridge-providers.test.ts b/packages/b2c-tooling-sdk/test/cli/cartridge-providers.test.ts new file mode 100644 index 00000000..6471d74f --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/cartridge-providers.test.ts @@ -0,0 +1,389 @@ +/* + * 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 { + CartridgeProviderRunner, + type CartridgeProvider, + type CartridgeTransformer, + type CartridgeDiscoveryOptions, +} from '../../src/cli/cartridge-providers.js'; +import type {CartridgeMapping} from '@salesforce/b2c-tooling-sdk/operations/code'; + +describe('cli/cartridge-providers', () => { + describe('CartridgeProviderRunner', () => { + describe('constructor', () => { + it('creates a runner without logger', () => { + const runner = new CartridgeProviderRunner(); + expect(runner.providerCount).to.equal(0); + expect(runner.transformerCount).to.equal(0); + }); + + it('creates a runner with logger', () => { + const logger = {debug: () => {}}; + const runner = new CartridgeProviderRunner(logger); + expect(runner.providerCount).to.equal(0); + expect(runner.transformerCount).to.equal(0); + }); + }); + + describe('addProviders', () => { + it('adds providers to the runner', () => { + const runner = new CartridgeProviderRunner(); + const provider: CartridgeProvider = { + name: 'test-provider', + priority: 'before', + async findCartridges() { + return []; + }, + }; + + runner.addProviders([provider]); + expect(runner.providerCount).to.equal(1); + }); + + it('adds multiple providers', () => { + const runner = new CartridgeProviderRunner(); + const providers: CartridgeProvider[] = [ + { + name: 'provider1', + priority: 'before', + async findCartridges() { + return []; + }, + }, + { + name: 'provider2', + priority: 'after', + async findCartridges() { + return []; + }, + }, + ]; + + runner.addProviders(providers); + expect(runner.providerCount).to.equal(2); + }); + }); + + describe('addTransformers', () => { + it('adds transformers to the runner', () => { + const runner = new CartridgeProviderRunner(); + const transformer: CartridgeTransformer = { + name: 'test-transformer', + async transform(cartridges: CartridgeMapping[]): Promise { + return cartridges; + }, + }; + + runner.addTransformers([transformer]); + expect(runner.transformerCount).to.equal(1); + }); + + it('adds multiple transformers', () => { + const runner = new CartridgeProviderRunner(); + const transformers: CartridgeTransformer[] = [ + { + name: 'transformer1', + async transform(cartridges: CartridgeMapping[]) { + return cartridges; + }, + }, + { + name: 'transformer2', + async transform(cartridges: CartridgeMapping[]) { + return cartridges; + }, + }, + ]; + + runner.addTransformers(transformers); + expect(runner.transformerCount).to.equal(2); + }); + }); + + describe('providerCount', () => { + it('returns zero for empty runner', () => { + const runner = new CartridgeProviderRunner(); + expect(runner.providerCount).to.equal(0); + }); + + it('returns correct count after adding providers', () => { + const runner = new CartridgeProviderRunner(); + runner.addProviders([ + { + name: 'p1', + priority: 'before', + async findCartridges() { + return []; + }, + }, + ]); + expect(runner.providerCount).to.equal(1); + }); + }); + + describe('transformerCount', () => { + it('returns zero for empty runner', () => { + const runner = new CartridgeProviderRunner(); + expect(runner.transformerCount).to.equal(0); + }); + + it('returns correct count after adding transformers', () => { + const runner = new CartridgeProviderRunner(); + runner.addTransformers([ + { + name: 't1', + async transform(cartridges: CartridgeMapping[]) { + return cartridges; + }, + }, + ]); + expect(runner.transformerCount).to.equal(1); + }); + }); + + describe('findCartridges', () => { + const defaultCartridges: CartridgeMapping[] = [ + {name: 'default1', src: '/path/default1', dest: 'default1'}, + {name: 'default2', src: '/path/default2', dest: 'default2'}, + ]; + + const options: CartridgeDiscoveryOptions = { + directory: '/test', + include: undefined, + exclude: undefined, + }; + + it('returns default cartridges when no providers or transformers', async () => { + const runner = new CartridgeProviderRunner(); + const result = await runner.findCartridges(defaultCartridges, options); + + expect(result).to.deep.equal(defaultCartridges); + }); + + it('runs before providers first', async () => { + const runner = new CartridgeProviderRunner(); + const beforeCartridges: CartridgeMapping[] = [{name: 'before1', src: '/path/before1', dest: 'before1'}]; + const provider: CartridgeProvider = { + name: 'before-provider', + priority: 'before', + async findCartridges() { + return beforeCartridges; + }, + }; + + runner.addProviders([provider]); + const result = await runner.findCartridges(defaultCartridges, options); + + expect(result).to.have.length(3); + expect(result[0].name).to.equal('before1'); + expect(result[1].name).to.equal('default1'); + expect(result[2].name).to.equal('default2'); + }); + + it('runs after providers after defaults', async () => { + const runner = new CartridgeProviderRunner(); + const afterCartridges: CartridgeMapping[] = [{name: 'after1', src: '/path/after1', dest: 'after1'}]; + const provider: CartridgeProvider = { + name: 'after-provider', + priority: 'after', + async findCartridges() { + return afterCartridges; + }, + }; + + runner.addProviders([provider]); + const result = await runner.findCartridges(defaultCartridges, options); + + expect(result).to.have.length(3); + expect(result[0].name).to.equal('default1'); + expect(result[1].name).to.equal('default2'); + expect(result[2].name).to.equal('after1'); + }); + + it('runs providers in correct order', async () => { + const runner = new CartridgeProviderRunner(); + const callOrder: string[] = []; + const providers: CartridgeProvider[] = [ + { + name: 'before1', + priority: 'before', + async findCartridges() { + callOrder.push('before1'); + return [{name: 'before1', src: '/path/before1', dest: 'before1'}]; + }, + }, + { + name: 'before2', + priority: 'before', + async findCartridges() { + callOrder.push('before2'); + return [{name: 'before2', src: '/path/before2', dest: 'before2'}]; + }, + }, + { + name: 'after1', + priority: 'after', + async findCartridges() { + callOrder.push('after1'); + return [{name: 'after1', src: '/path/after1', dest: 'after1'}]; + }, + }, + ]; + + runner.addProviders(providers); + await runner.findCartridges(defaultCartridges, options); + + expect(callOrder).to.deep.equal(['before1', 'before2', 'after1']); + }); + + it('deduplicates cartridges by name (first wins)', async () => { + const runner = new CartridgeProviderRunner(); + const providers: CartridgeProvider[] = [ + { + name: 'before-provider', + priority: 'before', + async findCartridges() { + return [{name: 'duplicate', src: '/path/before', dest: 'duplicate'}]; + }, + }, + { + name: 'after-provider', + priority: 'after', + async findCartridges() { + return [{name: 'duplicate', src: '/path/after', dest: 'duplicate'}]; + }, + }, + ]; + + runner.addProviders(providers); + const result = await runner.findCartridges( + [{name: 'duplicate', src: '/path/default', dest: 'duplicate'}], + options, + ); + + expect(result).to.have.length(1); + expect(result[0].src).to.equal('/path/before'); // First provider wins + }); + + it('applies transformers in order', async () => { + const runner = new CartridgeProviderRunner(); + const transformOrder: string[] = []; + const transformers: CartridgeTransformer[] = [ + { + name: 'transformer1', + async transform(cartridges: CartridgeMapping[]) { + transformOrder.push('transformer1'); + return cartridges.map((c: CartridgeMapping) => ({...c, dest: `${c.dest}_t1`})); + }, + }, + { + name: 'transformer2', + async transform(cartridges: CartridgeMapping[]) { + transformOrder.push('transformer2'); + return cartridges.map((c: CartridgeMapping) => ({...c, dest: `${c.dest}_t2`})); + }, + }, + ]; + + runner.addTransformers(transformers); + const result = await runner.findCartridges(defaultCartridges, options); + + expect(transformOrder).to.deep.equal(['transformer1', 'transformer2']); + expect(result[0].dest).to.equal('default1_t1_t2'); + expect(result[1].dest).to.equal('default2_t1_t2'); + }); + + it('handles provider errors gracefully', async () => { + const logger = { + debug: (msg: string) => { + expect(msg).to.include('failed'); + }, + }; + const runnerWithLogger = new CartridgeProviderRunner(logger); + const provider: CartridgeProvider = { + name: 'error-provider', + priority: 'before', + async findCartridges() { + throw new Error('Provider error'); + }, + }; + + runnerWithLogger.addProviders([provider]); + const result = await runnerWithLogger.findCartridges(defaultCartridges, options); + + // Should still return default cartridges + expect(result).to.deep.equal(defaultCartridges); + }); + + it('handles transformer errors gracefully', async () => { + const logger = { + debug: (msg: string) => { + expect(msg).to.include('failed'); + }, + }; + const runnerWithLogger = new CartridgeProviderRunner(logger); + const transformer: CartridgeTransformer = { + name: 'error-transformer', + async transform(_cartridges: CartridgeMapping[]) { + throw new Error('Transformer error'); + }, + }; + + runnerWithLogger.addTransformers([transformer]); + const result = await runnerWithLogger.findCartridges(defaultCartridges, options); + + // Should still return cartridges (transformer failed, so original passed through) + expect(result).to.have.length(2); + }); + + it('passes options to providers', async () => { + const runner = new CartridgeProviderRunner(); + let receivedOptions: CartridgeDiscoveryOptions | undefined; + const provider: CartridgeProvider = { + name: 'test-provider', + priority: 'before', + async findCartridges(opts: CartridgeDiscoveryOptions) { + receivedOptions = opts; + return []; + }, + }; + + runner.addProviders([provider]); + const customOptions: CartridgeDiscoveryOptions = { + directory: '/custom', + include: ['cart1'], + exclude: ['cart2'], + codeVersion: 'v2', + }; + + await runner.findCartridges(defaultCartridges, customOptions); + expect(receivedOptions).to.deep.equal(customOptions); + }); + + it('passes options to transformers', async () => { + const runner = new CartridgeProviderRunner(); + let receivedOptions: CartridgeDiscoveryOptions | undefined; + const transformer: CartridgeTransformer = { + name: 'test-transformer', + async transform(cartridges: CartridgeMapping[], opts: CartridgeDiscoveryOptions) { + receivedOptions = opts; + return cartridges; + }, + }; + + runner.addTransformers([transformer]); + const customOptions: CartridgeDiscoveryOptions = { + directory: '/custom', + include: ['cart1'], + }; + + await runner.findCartridges(defaultCartridges, customOptions); + expect(receivedOptions).to.deep.equal(customOptions); + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/config.test.ts b/packages/b2c-tooling-sdk/test/cli/config.test.ts new file mode 100644 index 00000000..edcb923e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/config.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { + loadConfig, + type ResolvedConfig, + type LoadConfigOptions, + type PluginSources, +} from '@salesforce/b2c-tooling-sdk/cli'; +import type {ConfigSource} from '@salesforce/b2c-tooling-sdk/config'; + +/** + * Mock config source for testing. + */ +class MockConfigSource implements ConfigSource { + constructor( + public name: string, + private config: Partial | undefined, + private path?: string, + ) {} + + load() { + return this.config as ResolvedConfig | undefined; + } + + getPath(): string | undefined { + return this.path; + } +} + +describe('cli/config', () => { + describe('loadConfig', () => { + it('loads config from flags only', () => { + const flags: Partial = { + hostname: 'test.demandware.net', + codeVersion: 'v1', + }; + + const config = loadConfig(flags); + expect(config.hostname).to.equal('test.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + }); + + it('merges flags with config file sources', () => { + const flags: Partial = { + hostname: 'flag-hostname.demandware.net', + }; + + // loadConfig uses resolveConfig internally which will try to load from dw.json + // In test environment, this may not exist, so we test with flags only + const config = loadConfig(flags); + expect(config.hostname).to.equal('flag-hostname.demandware.net'); + }); + + it('handles instance option', () => { + const flags: Partial = {}; + const options: LoadConfigOptions = { + instance: 'test-instance', + }; + + const config = loadConfig(flags, options); + // Instance name should be set if found in config + expect(config).to.be.an('object'); + }); + + it('handles configPath option', () => { + const flags: Partial = {}; + const options: LoadConfigOptions = { + configPath: '/custom/path/dw.json', + }; + + const config = loadConfig(flags, options); + expect(config).to.be.an('object'); + }); + + it('handles cloudOrigin option', () => { + const flags: Partial = {}; + const options: LoadConfigOptions = { + cloudOrigin: 'https://cloud-staging.mobify.com', + }; + + const config = loadConfig(flags, options); + expect(config).to.be.an('object'); + }); + + it('merges plugin sources before defaults', () => { + const flags: Partial = { + hostname: 'flag-hostname.demandware.net', + }; + const beforeSource = new MockConfigSource('before', { + hostname: 'before-hostname.demandware.net', + codeVersion: 'v2', + }); + const pluginSources: PluginSources = { + before: [beforeSource], + }; + + const config = loadConfig(flags, {}, pluginSources); + // Plugin source before should contribute codeVersion + expect(config).to.be.an('object'); + }); + + it('merges plugin sources after defaults', () => { + const flags: Partial = { + hostname: 'flag-hostname.demandware.net', + }; + const afterSource = new MockConfigSource('after', { + clientId: 'after-client-id', + }); + const pluginSources: PluginSources = { + after: [afterSource], + }; + + const config = loadConfig(flags, {}, pluginSources); + // Plugin source after should contribute clientId + expect(config).to.be.an('object'); + }); + + it('handles empty flags', () => { + const config = loadConfig({}); + expect(config).to.be.an('object'); + }); + + it('handles empty options', () => { + const flags: Partial = { + hostname: 'test.demandware.net', + }; + const config = loadConfig(flags, {}); + expect(config.hostname).to.equal('test.demandware.net'); + }); + + it('handles empty plugin sources', () => { + const flags: Partial = { + hostname: 'test.demandware.net', + }; + const config = loadConfig(flags, {}, {}); + expect(config.hostname).to.equal('test.demandware.net'); + }); + + it('preserves instanceName from options when not in resolved config', () => { + const flags: Partial = {}; + const options: LoadConfigOptions = { + instance: 'custom-instance', + }; + + const config = loadConfig(flags, options); + expect(config.instanceName).to.equal('custom-instance'); + }); + + it('does not override instanceName if already in resolved config', () => { + const flags: Partial = { + instanceName: 'resolved-instance', + }; + const options: LoadConfigOptions = { + instance: 'option-instance', + }; + + const config = loadConfig(flags, options); + // Flags take precedence + expect(config.instanceName).to.equal('resolved-instance'); + }); + + it('handles multiple plugin sources with priority', () => { + const flags: Partial = { + hostname: 'flag-hostname.demandware.net', + }; + const beforeSource1 = new MockConfigSource('before1', {codeVersion: 'v1'}); + const beforeSource2 = new MockConfigSource('before2', {clientId: 'client1'}); + const afterSource = new MockConfigSource('after', {clientSecret: 'secret1'}); + const pluginSources: PluginSources = { + before: [beforeSource1, beforeSource2], + after: [afterSource], + }; + + const config = loadConfig(flags, {}, pluginSources); + expect(config).to.be.an('object'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts new file mode 100644 index 00000000..bb3b331a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/instance-command.test.ts @@ -0,0 +1,445 @@ +/* + * 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 {Config} from '@oclif/core'; +import {InstanceCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {B2COperationContext, B2COperationResult, B2COperationType} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestInstanceCommand extends InstanceCommand { + static id = 'test:instance'; + static description = 'Test instance command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testHasWebDavCredentials() { + return this.hasWebDavCredentials(); + } + + public testRequireServer() { + return this.requireServer(); + } + + public testRequireCodeVersion() { + return this.requireCodeVersion(); + } + + public testRequireWebDavCredentials() { + return this.requireWebDavCredentials(); + } + + public testInstance() { + return this.instance; + } + + public testCreateContext(operationType: B2COperationType, metadata: Record) { + return this.createContext(operationType, metadata); + } + + public testRunBeforeHooks(context: B2COperationContext) { + return this.runBeforeHooks(context); + } + + public testRunAfterHooks(context: B2COperationContext, result: B2COperationResult) { + return this.runAfterHooks(context, result); + } +} + +// Type for mocking command properties in tests +type MockableInstanceCommand = TestInstanceCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; + error: (message: string) => never; +}; + +describe('cli/instance-command', () => { + let config: Config; + let command: TestInstanceCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestInstanceCommand([], config); + }); + + describe('init', () => { + it('initializes command with instance flags', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags).to.be.an('object'); + expect(cmd.resolvedConfig).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('handles server flag', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.server).to.equal('test.demandware.net'); + + cmd.parse = originalParse; + }); + + it('handles code-version flag', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'code-version': 'v1'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['code-version']).to.equal('v1'); + + cmd.parse = originalParse; + }); + + it('handles username and password flags', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {username: 'test-user', password: 'test-pass'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.username).to.equal('test-user'); + expect(cmd.flags.password).to.equal('test-pass'); + + cmd.parse = originalParse; + }); + }); + + describe('hasWebDavCredentials', () => { + it('returns false when no credentials', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasWebDavCredentials(); + expect(hasCreds).to.be.false; + + cmd.parse = originalParse; + }); + + it('returns true when username and password are set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {username: 'user', password: 'pass'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasWebDavCredentials(); + expect(hasCreds).to.be.true; + + cmd.parse = originalParse; + }); + + it('returns true when clientId is set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasWebDavCredentials(); + expect(hasCreds).to.be.true; + + cmd.parse = originalParse; + }); + }); + + describe('requireServer', () => { + it('throws error when no server', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireServer(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + + it('does not throw when server is set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // Should not throw + command.testRequireServer(); + + cmd.parse = originalParse; + }); + }); + + describe('requireCodeVersion', () => { + it('throws error when no code version', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireCodeVersion(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + + it('does not throw when code version is set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'code-version': 'v1'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // Should not throw + command.testRequireCodeVersion(); + + cmd.parse = originalParse; + }); + }); + + describe('requireWebDavCredentials', () => { + it('throws error when no credentials', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireWebDavCredentials(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + cmd.error = originalError; + cmd.parse = originalParse; + }); + + it('does not throw when credentials are set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {username: 'user', password: 'pass'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // Should not throw + command.testRequireWebDavCredentials(); + + cmd.parse = originalParse; + }); + }); + + describe('instance', () => { + it('throws error when no server', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + command.testInstance(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).to.be.an('error'); + } + + cmd.parse = originalParse; + }); + + it('creates instance when server is set', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const instance = command.testInstance(); + expect(instance).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('creates instance lazily', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const instance1 = command.testInstance(); + const instance2 = command.testInstance(); + // Should return same instance + expect(instance1).to.equal(instance2); + + cmd.parse = originalParse; + }); + }); + + describe('createContext', () => { + it('creates operation context', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const context = command.testCreateContext('job:run', {jobId: 'test-job'}); + expect(context.operationType).to.equal('job:run'); + expect(context.metadata.jobId).to.equal('test-job'); + expect(context.instance).to.be.an('object'); + + cmd.parse = originalParse; + }); + }); + + describe('runBeforeHooks', () => { + it('returns empty result when no lifecycle runner', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const context = command.testCreateContext('job:run', {}); + const result = await command.testRunBeforeHooks(context); + expect(result).to.deep.equal({}); + + cmd.parse = originalParse; + }); + }); + + describe('runAfterHooks', () => { + it('does nothing when no lifecycle runner', async () => { + const cmd = command as MockableInstanceCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const context = command.testCreateContext('job:run', {}); + const result = {success: true, duration: 100}; + // Should not throw + await command.testRunAfterHooks(context, result); + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/job-command.test.ts b/packages/b2c-tooling-sdk/test/cli/job-command.test.ts new file mode 100644 index 00000000..bb210fec --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/job-command.test.ts @@ -0,0 +1,143 @@ +/* + * 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 {Config} from '@oclif/core'; +import {JobCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import type {JobExecution} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; + +// Create a test command class +class TestJobCommand extends JobCommand { + static id = 'test:job'; + static description = 'Test job command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testShowJobLog(execution: JobExecution) { + return this.showJobLog(execution); + } +} + +// Type for mocking command properties in tests +type MockableJobCommand = TestJobCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; +} & Record; + +describe('cli/job-command', () => { + let config: Config; + let command: TestJobCommand; + let mockInstance: B2CInstance; + + beforeEach(async () => { + config = await Config.load(); + command = new TestJobCommand([], config); + mockInstance = new B2CInstance( + { + hostname: 'test.demandware.net', + codeVersion: 'v1', + }, + { + oauth: { + clientId: 'test-client', + clientSecret: 'test-secret', + }, + }, + ); + }); + + describe('showJobLog', () => { + it('handles execution without log file', async () => { + const cmd = command as MockableJobCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + (cmd as Record)._instance = mockInstance; + + const execution: JobExecution = { + id: 'test-job', + execution_status: 'aborted', + is_log_file_existing: false, + } as JobExecution; + + // Should not throw + await command.testShowJobLog(execution); + + cmd.parse = originalParse; + }); + + it('handles execution with log file but fetch fails', async () => { + const cmd = command as MockableJobCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + (cmd as Record)._instance = mockInstance; + + const execution: JobExecution = { + id: 'test-job', + execution_status: 'aborted', + is_log_file_existing: true, + log_file_path: '/path/to/log', + } as JobExecution; + + // Should not throw (handles error gracefully) + try { + await command.testShowJobLog(execution); + } catch { + // Expected if getJobLog fails + } + + cmd.parse = originalParse; + }); + + it('extracts error message from execution', async () => { + const cmd = command as MockableJobCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {server: 'test.demandware.net', 'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + (cmd as Record)._instance = mockInstance; + + const execution: JobExecution = { + id: 'test-job', + execution_status: 'aborted', + is_log_file_existing: false, + step_executions: [ + { + execution_status: 'aborted', + }, + ], + } as JobExecution; + + // Should not throw + await command.testShowJobLog(execution); + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/lifecycle.test.ts b/packages/b2c-tooling-sdk/test/cli/lifecycle.test.ts new file mode 100644 index 00000000..cdaaac36 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/lifecycle.test.ts @@ -0,0 +1,354 @@ +/* + * 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 { + B2CLifecycleRunner, + createB2COperationContext, + type B2COperationType, + type B2COperationResult, + type B2COperationLifecycleProvider, + type B2COperationContext, +} from '@salesforce/b2c-tooling-sdk/cli'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; + +describe('cli/lifecycle', () => { + let mockInstance: B2CInstance; + + beforeEach(() => { + mockInstance = new B2CInstance( + { + hostname: 'test.demandware.net', + codeVersion: 'v1', + }, + { + oauth: { + clientId: 'test-client', + clientSecret: 'test-secret', + }, + }, + ); + }); + + describe('createB2COperationContext', () => { + it('creates a context with required fields', () => { + const context = createB2COperationContext('job:run', {jobId: 'test-job'}, mockInstance); + + expect(context.operationType).to.equal('job:run'); + expect(context.operationId).to.be.a('string'); + expect(context.operationId.length).to.be.greaterThan(0); + expect(context.instance).to.equal(mockInstance); + expect(context.startTime).to.be.a('number'); + expect(context.metadata).to.deep.equal({jobId: 'test-job'}); + }); + + it('generates unique operation IDs', () => { + const context1 = createB2COperationContext('job:run', {}, mockInstance); + const context2 = createB2COperationContext('job:run', {}, mockInstance); + + expect(context1.operationId).to.not.equal(context2.operationId); + }); + + it('sets startTime to current timestamp', () => { + const before = Date.now(); + const context = createB2COperationContext('code:deploy', {}, mockInstance); + const after = Date.now(); + + expect(context.startTime).to.be.at.least(before); + expect(context.startTime).to.be.at.most(after); + }); + + it('handles all operation types', () => { + const types: B2COperationType[] = [ + 'job:run', + 'job:import', + 'job:export', + 'code:deploy', + 'code:activate', + 'site-archive:import', + 'site-archive:export', + ]; + + for (const type of types) { + const context = createB2COperationContext(type, {}, mockInstance); + expect(context.operationType).to.equal(type); + } + }); + }); + + describe('B2CLifecycleRunner', () => { + describe('constructor', () => { + it('creates a runner without logger', () => { + const runner = new B2CLifecycleRunner(); + expect(runner.size).to.equal(0); + }); + + it('creates a runner with logger', () => { + // Logger is optional, so we can create without it + const runner = new B2CLifecycleRunner(); + expect(runner.size).to.equal(0); + }); + }); + + describe('addProviders', () => { + it('adds providers to the runner', () => { + const runner = new B2CLifecycleRunner(); + const provider: B2COperationLifecycleProvider = { + name: 'test-provider', + }; + + runner.addProviders([provider]); + expect(runner.size).to.equal(1); + }); + + it('adds multiple providers', () => { + const runner = new B2CLifecycleRunner(); + const providers: B2COperationLifecycleProvider[] = [ + {name: 'provider1'}, + {name: 'provider2'}, + {name: 'provider3'}, + ]; + + runner.addProviders(providers); + expect(runner.size).to.equal(3); + }); + }); + + describe('size', () => { + it('returns zero for empty runner', () => { + const runner = new B2CLifecycleRunner(); + expect(runner.size).to.equal(0); + }); + + it('returns correct count after adding providers', () => { + const runner = new B2CLifecycleRunner(); + runner.addProviders([{name: 'p1'}, {name: 'p2'}]); + expect(runner.size).to.equal(2); + }); + }); + + describe('runBefore', () => { + it('returns empty result when no providers', async () => { + const runner = new B2CLifecycleRunner(); + const context = createB2COperationContext('job:run', {}, mockInstance); + + const result = await runner.runBefore(context); + expect(result).to.deep.equal({}); + }); + + it('returns empty result when providers have no beforeOperation', async () => { + const runner = new B2CLifecycleRunner(); + runner.addProviders([{name: 'no-op-provider'}]); + const context = createB2COperationContext('job:run', {}, mockInstance); + + const result = await runner.runBefore(context); + expect(result).to.deep.equal({}); + }); + + it('calls beforeOperation on providers', async () => { + const runner = new B2CLifecycleRunner(); + let called = false; + const provider: B2COperationLifecycleProvider = { + name: 'test-provider', + async beforeOperation(context) { + called = true; + expect(context.operationType).to.equal('job:run'); + return {}; + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + + await runner.runBefore(context); + expect(called).to.be.true; + }); + + it('returns skip result when provider requests skip', async () => { + const runner = new B2CLifecycleRunner(); + const provider: B2COperationLifecycleProvider = { + name: 'skip-provider', + async beforeOperation() { + return {skip: true, skipReason: 'Test skip'}; + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + + const result = await runner.runBefore(context); + expect(result.skip).to.be.true; + expect(result.skipReason).to.equal('Test skip'); + }); + + it('stops on first skip request', async () => { + const runner = new B2CLifecycleRunner(); + let secondCalled = false; + const providers: B2COperationLifecycleProvider[] = [ + { + name: 'skip-provider', + async beforeOperation() { + return {skip: true}; + }, + }, + { + name: 'second-provider', + async beforeOperation() { + secondCalled = true; + return {}; + }, + }, + ]; + + runner.addProviders(providers); + const context = createB2COperationContext('job:run', {}, mockInstance); + + await runner.runBefore(context); + expect(secondCalled).to.be.false; + }); + + it('merges context modifications', async () => { + const runner = new B2CLifecycleRunner(); + const provider: B2COperationLifecycleProvider = { + name: 'modify-provider', + async beforeOperation(_context) { + return { + context: {customField: 'value'} as Partial, + }; + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + + await runner.runBefore(context); + expect(context.metadata.customField).to.equal('value'); + }); + + it('handles provider errors gracefully', async () => { + // Test without logger - errors should be handled gracefully + const runner = new B2CLifecycleRunner(); + const provider: B2COperationLifecycleProvider = { + name: 'error-provider', + async beforeOperation() { + throw new Error('Provider error'); + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + + // Should not throw + const result = await runner.runBefore(context); + expect(result).to.deep.equal({}); + }); + }); + + describe('runAfter', () => { + it('does nothing when no providers', async () => { + const runner = new B2CLifecycleRunner(); + const context = createB2COperationContext('job:run', {}, mockInstance); + const result: B2COperationResult = {success: true, duration: 100}; + + // Should not throw + await runner.runAfter(context, result); + }); + + it('does nothing when providers have no afterOperation', async () => { + const runner = new B2CLifecycleRunner(); + runner.addProviders([{name: 'no-op-provider'}]); + const context = createB2COperationContext('job:run', {}, mockInstance); + const result: B2COperationResult = {success: true, duration: 100}; + + // Should not throw + await runner.runAfter(context, result); + }); + + it('calls afterOperation on providers', async () => { + const runner = new B2CLifecycleRunner(); + let called = false; + const provider: B2COperationLifecycleProvider = { + name: 'test-provider', + async afterOperation(context, result) { + called = true; + expect(context.operationType).to.equal('job:run'); + expect(result.success).to.be.true; + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + const result: B2COperationResult = {success: true, duration: 100}; + + await runner.runAfter(context, result); + expect(called).to.be.true; + }); + + it('calls afterOperation with failure result', async () => { + const runner = new B2CLifecycleRunner(); + let receivedError: Error | undefined; + const provider: B2COperationLifecycleProvider = { + name: 'test-provider', + async afterOperation(context, result) { + receivedError = result.error; + expect(result.success).to.be.false; + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + const error = new Error('Operation failed'); + const result: B2COperationResult = {success: false, duration: 50, error}; + + await runner.runAfter(context, result); + expect(receivedError).to.equal(error); + }); + + it('calls all providers', async () => { + const runner = new B2CLifecycleRunner(); + const callOrder: string[] = []; + const providers: B2COperationLifecycleProvider[] = [ + { + name: 'provider1', + async afterOperation() { + callOrder.push('provider1'); + }, + }, + { + name: 'provider2', + async afterOperation() { + callOrder.push('provider2'); + }, + }, + ]; + + runner.addProviders(providers); + const context = createB2COperationContext('job:run', {}, mockInstance); + const result: B2COperationResult = {success: true, duration: 100}; + + await runner.runAfter(context, result); + expect(callOrder).to.deep.equal(['provider1', 'provider2']); + }); + + it('handles provider errors gracefully', async () => { + // Test without logger - errors should be handled gracefully + const runner = new B2CLifecycleRunner(); + const provider: B2COperationLifecycleProvider = { + name: 'error-provider', + async afterOperation() { + throw new Error('Provider error'); + }, + }; + + runner.addProviders([provider]); + const context = createB2COperationContext('job:run', {}, mockInstance); + const result: B2COperationResult = {success: true, duration: 100}; + + // Should not throw + await runner.runAfter(context, result); + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts new file mode 100644 index 00000000..1d6dacf3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/mrt-command.test.ts @@ -0,0 +1,300 @@ +/* + * 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 {Config} from '@oclif/core'; +import {MrtCommand} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestMrtCommand extends MrtCommand { + static id = 'test:mrt'; + static description = 'Test MRT command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testGetMrtAuth() { + return this.getMrtAuth(); + } + + public testHasMrtCredentials() { + return this.hasMrtCredentials(); + } + + public testRequireMrtCredentials() { + return this.requireMrtCredentials(); + } + + public testCreateMrtClient(project: {org: string; project: string; env: string}) { + return this.createMrtClient(project); + } +} + +// Type for mocking command properties in tests +type MockableMrtCommand = TestMrtCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; + error?: (message: string, options?: {exit?: number}) => never; +}; + +describe('cli/mrt-command', () => { + let config: Config; + let command: TestMrtCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestMrtCommand([], config); + }); + + describe('init', () => { + it('initializes command with MRT flags', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags).to.be.an('object'); + expect(cmd.resolvedConfig).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('handles api-key flag', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'api-key': 'test-api-key'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['api-key']).to.equal('test-api-key'); + + cmd.parse = originalParse; + }); + + it('handles project flag', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {project: 'test-project'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.project).to.equal('test-project'); + + cmd.parse = originalParse; + }); + + it('handles environment flag', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {environment: 'staging'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.environment).to.equal('staging'); + + cmd.parse = originalParse; + }); + + it('handles cloud-origin flag', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'cloud-origin': 'https://cloud-staging.mobify.com'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['cloud-origin']).to.equal('https://cloud-staging.mobify.com'); + + cmd.parse = originalParse; + }); + }); + + describe('getMrtAuth', () => { + it('throws error when no API key', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + command.testGetMrtAuth(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).to.be.an('error'); + // Error message may be translated, so just check it's an error about MRT/API key + const message = (error as Error).message.toLowerCase(); + expect(message).to.satisfy( + (msg: string) => msg.includes('mrt') || msg.includes('api') || msg.includes('schlüssel'), + ); + } + + cmd.parse = originalParse; + }); + + it('returns ApiKeyStrategy when API key is set', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'api-key': 'test-api-key'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const auth = command.testGetMrtAuth(); + expect(auth).to.be.an('object'); + + cmd.parse = originalParse; + }); + }); + + describe('hasMrtCredentials', () => { + it('returns false when no API key', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasMrtCredentials(); + expect(hasCreds).to.be.false; + + cmd.parse = originalParse; + }); + + it('returns true when API key is set', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'api-key': 'test-api-key'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasMrtCredentials(); + expect(hasCreds).to.be.true; + + cmd.parse = originalParse; + }); + }); + + describe('requireMrtCredentials', () => { + it('throws error when no credentials', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error?.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireMrtCredentials(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + if (originalError) { + cmd.error = originalError; + } + cmd.parse = originalParse; + }); + + it('does not throw when API key is set', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'api-key': 'test-api-key'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // Should not throw + command.testRequireMrtCredentials(); + + cmd.parse = originalParse; + }); + }); + + describe('createMrtClient', () => { + it('throws error when no credentials', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + command.testCreateMrtClient({org: 'test-org', project: 'test-project', env: 'staging'}); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).to.be.an('error'); + } + + cmd.parse = originalParse; + }); + + it('creates MRT client when credentials available', async () => { + const cmd = command as MockableMrtCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'api-key': 'test-api-key'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const client = command.testCreateMrtClient({org: 'test-org', project: 'test-project', env: 'staging'}); + expect(client).to.be.an('object'); + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts new file mode 100644 index 00000000..7ce68d94 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/oauth-command.test.ts @@ -0,0 +1,402 @@ +/* + * 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 {Config} from '@oclif/core'; +import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestOAuthCommand extends OAuthCommand { + static id = 'test:oauth'; + static description = 'Test OAuth command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testParseAuthMethods() { + return this.parseAuthMethods(); + } + + public testAccountManagerHost() { + return this.accountManagerHost; + } + + public testGetOAuthStrategy() { + return this.getOAuthStrategy(); + } + + public testHasOAuthCredentials() { + return this.hasOAuthCredentials(); + } + + public testHasFullOAuthCredentials() { + return this.hasFullOAuthCredentials(); + } + + public testRequireOAuthCredentials() { + return this.requireOAuthCredentials(); + } +} + +// Type for mocking command properties in tests +type MockableOAuthCommand = TestOAuthCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; + error?: (message: string, options?: {exit?: number}) => never; +}; + +describe('cli/oauth-command', () => { + let config: Config; + let command: TestOAuthCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestOAuthCommand([], config); + }); + + describe('init', () => { + it('initializes command with OAuth flags', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags).to.be.an('object'); + expect(cmd.resolvedConfig).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('handles client-id flag', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client-id'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['client-id']).to.equal('test-client-id'); + + cmd.parse = originalParse; + }); + + it('handles client-secret flag', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-secret': 'test-secret'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['client-secret']).to.equal('test-secret'); + + cmd.parse = originalParse; + }); + + it('handles scope flag', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {scope: ['mail', 'roles']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags.scope).to.be.an('array'); + + cmd.parse = originalParse; + }); + + it('handles account-manager-host flag', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'account-manager-host': 'custom.example.com'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['account-manager-host']).to.equal('custom.example.com'); + + cmd.parse = originalParse; + }); + }); + + describe('parseAuthMethods', () => { + it('returns undefined when no auth methods specified', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const methods = command.testParseAuthMethods(); + expect(methods).to.be.undefined; + + cmd.parse = originalParse; + }); + + it('parses valid auth methods', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'auth-methods': ['client-credentials', 'implicit']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const methods = command.testParseAuthMethods(); + expect(methods).to.include('client-credentials'); + expect(methods).to.include('implicit'); + + cmd.parse = originalParse; + }); + + it('filters out invalid auth methods', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'auth-methods': ['client-credentials', 'invalid', 'basic']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const methods = command.testParseAuthMethods(); + expect(methods).to.include('client-credentials'); + expect(methods).to.not.include('invalid'); + + cmd.parse = originalParse; + }); + }); + + describe('accountManagerHost', () => { + it('returns default account manager host', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const host = command.testAccountManagerHost(); + expect(host).to.be.a('string'); + expect(host.length).to.be.greaterThan(0); + + cmd.parse = originalParse; + }); + + it('returns custom account manager host from flag', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'account-manager-host': 'custom.example.com'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const host = command.testAccountManagerHost(); + expect(host).to.equal('custom.example.com'); + + cmd.parse = originalParse; + }); + }); + + describe('getOAuthStrategy', () => { + it('throws error when no credentials available', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + command.testGetOAuthStrategy(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).to.be.an('error'); + } + + cmd.parse = originalParse; + }); + + it('returns OAuthStrategy when client credentials available', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const strategy = command.testGetOAuthStrategy(); + expect(strategy).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('returns ImplicitOAuthStrategy when only clientId available', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client', 'auth-methods': ['implicit']}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const strategy = command.testGetOAuthStrategy(); + expect(strategy).to.be.an('object'); + + cmd.parse = originalParse; + }); + }); + + describe('hasOAuthCredentials', () => { + it('returns false when no clientId', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasOAuthCredentials(); + expect(hasCreds).to.be.false; + + cmd.parse = originalParse; + }); + + it('returns true when clientId is set', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasCreds = command.testHasOAuthCredentials(); + expect(hasCreds).to.be.true; + + cmd.parse = originalParse; + }); + }); + + describe('hasFullOAuthCredentials', () => { + it('returns false when only clientId', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasFull = command.testHasFullOAuthCredentials(); + expect(hasFull).to.be.false; + + cmd.parse = originalParse; + }); + + it('returns true when both clientId and clientSecret', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const hasFull = command.testHasFullOAuthCredentials(); + expect(hasFull).to.be.true; + + cmd.parse = originalParse; + }); + }); + + describe('requireOAuthCredentials', () => { + it('throws error when no credentials', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + let errorCalled = false; + const originalError = cmd.error.bind(command); + cmd.error = () => { + errorCalled = true; + throw new Error('Expected error'); + }; + + try { + command.testRequireOAuthCredentials(); + } catch { + // Expected + } + + expect(errorCalled).to.be.true; + + if (originalError) { + cmd.error = originalError; + } + cmd.parse = originalParse; + }); + + it('does not throw when clientId is set', async () => { + const cmd = command as MockableOAuthCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + // Should not throw + command.testRequireOAuthCredentials(); + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts new file mode 100644 index 00000000..8f633f2f --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/ods-command.test.ts @@ -0,0 +1,190 @@ +/* + * 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 {Config} from '@oclif/core'; +import {OdsCommand} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestOdsCommand extends OdsCommand { + static id = 'test:ods'; + static description = 'Test ODS command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testOdsHost() { + return this.odsHost; + } + + public testOdsClient() { + return this.odsClient; + } +} + +// Type for mocking command properties in tests +type MockableOdsCommand = TestOdsCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; +}; + +describe('cli/ods-command', () => { + let config: Config; + let command: TestOdsCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestOdsCommand([], config); + }); + + describe('init', () => { + it('initializes command with ODS flags', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags).to.be.an('object'); + expect(cmd.resolvedConfig).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('handles sandbox-api-host flag', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'sandbox-api-host': 'custom.example.com'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + expect(cmd.flags['sandbox-api-host']).to.equal('custom.example.com'); + + cmd.parse = originalParse; + }); + + it('uses default sandbox-api-host when not specified', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const host = command.testOdsHost(); + expect(host).to.be.a('string'); + expect(host.length).to.be.greaterThan(0); + + cmd.parse = originalParse; + }); + }); + + describe('odsHost', () => { + it('returns default host when not specified', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const host = command.testOdsHost(); + expect(host).to.be.a('string'); + + cmd.parse = originalParse; + }); + + it('returns custom host from flag', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'sandbox-api-host': 'custom.example.com'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const host = command.testOdsHost(); + expect(host).to.equal('custom.example.com'); + + cmd.parse = originalParse; + }); + }); + + describe('odsClient', () => { + it('throws error when no OAuth credentials', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + try { + // Accessing odsClient getter will try to create client + command.testOdsClient(); + expect.fail('Should have thrown'); + } catch (error) { + expect(error).to.be.an('error'); + } + + cmd.parse = originalParse; + }); + + it('creates ODS client when OAuth credentials available', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const client = command.testOdsClient(); + expect(client).to.be.an('object'); + + cmd.parse = originalParse; + }); + + it('creates ODS client lazily', async () => { + const cmd = command as MockableOdsCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {'client-id': 'test-client', 'client-secret': 'test-secret'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const client1 = command.testOdsClient(); + const client2 = command.testOdsClient(); + // Should return same instance + expect(client1).to.equal(client2); + + cmd.parse = originalParse; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/table.test.ts b/packages/b2c-tooling-sdk/test/cli/table.test.ts new file mode 100644 index 00000000..7862815c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/table.test.ts @@ -0,0 +1,258 @@ +/* + * 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 {TableRenderer, createTable, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; + +interface TestItem { + id: string; + name: string; + status: string; + description?: string; +} + +describe('cli/table', () => { + describe('TableRenderer', () => { + const columns: Record> = { + id: {header: 'ID', get: (item) => item.id}, + name: {header: 'Name', get: (item) => item.name}, + status: {header: 'Status', get: (item) => item.status}, + description: {header: 'Description', get: (item) => item.description ?? '', extended: true}, + }; + + const testData: TestItem[] = [ + {id: '1', name: 'Item One', status: 'active', description: 'First item'}, + {id: '2', name: 'Item Two', status: 'inactive', description: 'Second item'}, + {id: '3', name: 'Item Three', status: 'pending'}, + ]; + + describe('constructor', () => { + it('creates a renderer with columns', () => { + const renderer = new TableRenderer(columns); + expect(renderer).to.be.instanceOf(TableRenderer); + }); + }); + + describe('render', () => { + it('renders table with all columns', () => { + const renderer = new TableRenderer(columns); + // Capture stdout + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render(testData, ['id', 'name', 'status']); + expect(output).to.include('ID'); + expect(output).to.include('Name'); + expect(output).to.include('Status'); + expect(output).to.include('Item One'); + expect(output).to.include('Item Two'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('renders table with subset of columns', () => { + const renderer = new TableRenderer(columns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render(testData, ['id', 'name']); + expect(output).to.include('ID'); + expect(output).to.include('Name'); + expect(output).to.not.include('Status'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('handles empty data array', () => { + const renderer = new TableRenderer(columns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render([], ['id', 'name']); + expect(output).to.include('ID'); + expect(output).to.include('Name'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('respects custom term width', () => { + const renderer = new TableRenderer(columns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render(testData, ['id', 'name'], {termWidth: 200}); + // Should not throw + expect(output).to.be.a('string'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('respects custom padding', () => { + const renderer = new TableRenderer(columns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render(testData, ['id', 'name'], {padding: 4}); + // Should not throw + expect(output).to.be.a('string'); + } finally { + process.stdout.write = originalWrite; + } + }); + }); + + describe('getColumnKeys', () => { + it('returns all column keys', () => { + const renderer = new TableRenderer(columns); + const keys = renderer.getColumnKeys(); + expect(keys).to.have.members(['id', 'name', 'status', 'description']); + }); + }); + + describe('getDefaultColumnKeys', () => { + it('returns only non-extended column keys', () => { + const renderer = new TableRenderer(columns); + const keys = renderer.getDefaultColumnKeys(); + expect(keys).to.have.members(['id', 'name', 'status']); + expect(keys).to.not.include('description'); + }); + + it('returns all keys when no extended columns', () => { + const simpleColumns: Record> = { + id: {header: 'ID', get: (item) => item.id}, + name: {header: 'Name', get: (item) => item.name}, + }; + const renderer = new TableRenderer(simpleColumns); + const keys = renderer.getDefaultColumnKeys(); + expect(keys).to.have.members(['id', 'name']); + }); + }); + + describe('validateColumnKeys', () => { + it('returns valid column keys', () => { + const renderer = new TableRenderer(columns); + const valid = renderer.validateColumnKeys(['id', 'name', 'status']); + expect(valid).to.have.members(['id', 'name', 'status']); + }); + + it('filters out invalid column keys', () => { + const renderer = new TableRenderer(columns); + const valid = renderer.validateColumnKeys(['id', 'invalid', 'name', 'also-invalid']); + expect(valid).to.have.members(['id', 'name']); + }); + + it('returns empty array when all keys are invalid', () => { + const renderer = new TableRenderer(columns); + const valid = renderer.validateColumnKeys(['invalid1', 'invalid2']); + expect(valid).to.be.empty; + }); + }); + + describe('column width calculation', () => { + it('calculates width based on header length', () => { + const wideHeaderColumns: Record> = { + id: {header: 'Very Long Header Name', get: (item) => item.id}, + }; + const renderer = new TableRenderer(wideHeaderColumns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render([{id: '1', name: 'test', status: 'active'}], ['id']); + expect(output).to.include('Very Long Header Name'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('calculates width based on content length', () => { + const columns: Record> = { + name: {header: 'Name', get: (item) => item.name}, + }; + const renderer = new TableRenderer(columns); + const longData: TestItem[] = [{id: '1', name: 'Very Long Item Name That Exceeds Header', status: 'active'}]; + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render(longData, ['name']); + expect(output).to.include('Very Long Item Name That Exceeds Header'); + } finally { + process.stdout.write = originalWrite; + } + }); + + it('respects minWidth option', () => { + const columns: Record> = { + id: {header: 'ID', get: (item) => item.id, minWidth: 20}, + }; + const renderer = new TableRenderer(columns); + const originalWrite = process.stdout.write; + let output = ''; + process.stdout.write = ((chunk: string) => { + output += chunk; + return true; + }) as typeof process.stdout.write; + + try { + renderer.render([{id: '1', name: 'test', status: 'active'}], ['id']); + // Should render with at least minWidth + expect(output).to.be.a('string'); + } finally { + process.stdout.write = originalWrite; + } + }); + }); + }); + + describe('createTable', () => { + it('creates a TableRenderer instance', () => { + const columns: Record> = { + id: {header: 'ID', get: (item) => item.id}, + name: {header: 'Name', get: (item) => item.name}, + }; + const renderer = createTable(columns); + expect(renderer).to.be.instanceOf(TableRenderer); + expect(renderer.getColumnKeys()).to.have.members(['id', 'name']); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/cli/webdav-command.test.ts b/packages/b2c-tooling-sdk/test/cli/webdav-command.test.ts new file mode 100644 index 00000000..a2b0fcef --- /dev/null +++ b/packages/b2c-tooling-sdk/test/cli/webdav-command.test.ts @@ -0,0 +1,218 @@ +/* + * 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 {Config} from '@oclif/core'; +import {WebDavCommand, WEBDAV_ROOTS, VALID_ROOTS, type WebDavRootKey} from '@salesforce/b2c-tooling-sdk/cli'; + +// Create a test command class +class TestWebDavCommand extends WebDavCommand { + static id = 'test:webdav'; + static description = 'Test WebDAV command'; + + async run(): Promise { + // Test implementation + } + + // Expose protected methods for testing + public testBuildPath(relativePath: string) { + return this.buildPath(relativePath); + } + + public testRootPath() { + return this.rootPath; + } +} + +// Type for mocking command properties in tests +type MockableWebDavCommand = TestWebDavCommand & { + parse: () => Promise<{ + args: Record; + flags: Record; + metadata: Record; + }>; + flags: Record; + args: Record; + resolvedConfig: Record; +}; + +describe('cli/webdav-command', () => { + describe('WEBDAV_ROOTS', () => { + it('contains all expected root keys', () => { + expect(WEBDAV_ROOTS.IMPEX).to.equal('Impex'); + expect(WEBDAV_ROOTS.TEMP).to.equal('Temp'); + expect(WEBDAV_ROOTS.CARTRIDGES).to.equal('Cartridges'); + expect(WEBDAV_ROOTS.REALMDATA).to.equal('Realmdata'); + expect(WEBDAV_ROOTS.CATALOGS).to.equal('Catalogs'); + expect(WEBDAV_ROOTS.LIBRARIES).to.equal('Libraries'); + expect(WEBDAV_ROOTS.STATIC).to.equal('Static'); + expect(WEBDAV_ROOTS.LOGS).to.equal('Logs'); + expect(WEBDAV_ROOTS.SECURITYLOGS).to.equal('Securitylogs'); + }); + }); + + describe('VALID_ROOTS', () => { + it('contains all root keys', () => { + expect(VALID_ROOTS).to.include('IMPEX'); + expect(VALID_ROOTS).to.include('CARTRIDGES'); + expect(VALID_ROOTS.length).to.equal(Object.keys(WEBDAV_ROOTS).length); + }); + }); + + describe('WebDavCommand', () => { + let config: Config; + let command: TestWebDavCommand; + + beforeEach(async () => { + config = await Config.load(); + command = new TestWebDavCommand([], config); + }); + + describe('buildPath', () => { + it('builds path with default root (IMPEX)', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'impex'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath('src/data/file.xml'); + expect(path).to.equal('Impex/src/data/file.xml'); + + cmd.parse = originalParse; + }); + + it('builds path with explicit root', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'cartridges'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath('v1/test.zip'); + expect(path).to.equal('Cartridges/v1/test.zip'); + + cmd.parse = originalParse; + }); + + it('handles path with leading slash', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'impex'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath('/src/data/file.xml'); + expect(path).to.equal('Impex/src/data/file.xml'); + + cmd.parse = originalParse; + }); + + it('returns root path for empty string', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'impex'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath(''); + expect(path).to.equal('Impex'); + + cmd.parse = originalParse; + }); + + it('returns root path for single slash', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'impex'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath('/'); + expect(path).to.equal('Impex'); + + cmd.parse = originalParse; + }); + + it('handles all root types', async () => { + const roots: WebDavRootKey[] = [ + 'IMPEX', + 'TEMP', + 'CARTRIDGES', + 'REALMDATA', + 'CATALOGS', + 'LIBRARIES', + 'STATIC', + 'LOGS', + 'SECURITYLOGS', + ]; + for (const root of roots) { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: root.toLowerCase()}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const path = command.testBuildPath('test'); + expect(path).to.include(WEBDAV_ROOTS[root]); + + cmd.parse = originalParse; + } + }); + }); + + describe('rootPath', () => { + it('returns default root path', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'impex'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const rootPath = command.testRootPath(); + expect(rootPath).to.equal('Impex'); + + cmd.parse = originalParse; + }); + + it('returns correct root path for specified root', async () => { + const cmd = command as MockableWebDavCommand; + const originalParse = cmd.parse.bind(command); + cmd.parse = (async () => ({ + args: {}, + flags: {root: 'cartridges'}, + metadata: {}, + })) as typeof cmd.parse; + + await cmd.init(); + const rootPath = command.testRootPath(); + expect(rootPath).to.equal('Cartridges'); + + cmd.parse = originalParse; + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/config/dw-json.test.ts b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts new file mode 100644 index 00000000..0c5e8151 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/dw-json.test.ts @@ -0,0 +1,180 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {findDwJson, loadDwJson, type DwJsonConfig} from '@salesforce/b2c-tooling-sdk/config'; + +describe('config/dw-json', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + // Create a temporary directory for tests + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dw-json-test-')); + originalCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + // Clean up + process.chdir(originalCwd); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + describe('findDwJson', () => { + it('returns undefined when no dw.json exists', () => { + const result = findDwJson(); + expect(result).to.be.undefined; + }); + + it('finds dw.json in current directory', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync(dwJsonPath, JSON.stringify({hostname: 'test.demandware.net'})); + + const result = findDwJson(tempDir); + expect(result).to.equal(dwJsonPath); + }); + + it('finds dw.json in parent directory', () => { + const subDir = path.join(tempDir, 'subdir'); + fs.mkdirSync(subDir); + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync(dwJsonPath, JSON.stringify({hostname: 'test.demandware.net'})); + + const result = findDwJson(subDir); + expect(result).to.equal(dwJsonPath); + }); + + it('stops at filesystem root', () => { + const root = path.parse(tempDir).root; + const result = findDwJson(root); + expect(result).to.be.undefined; + }); + }); + + describe('loadDwJson', () => { + it('returns undefined when no dw.json exists', () => { + const result = loadDwJson(); + expect(result).to.be.undefined; + }); + + it('loads basic dw.json config', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const config: DwJsonConfig = { + hostname: 'test.demandware.net', + 'code-version': 'v1', + username: 'test-user', + password: 'test-pass', + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(config)); + + const result = loadDwJson(); + expect(result).to.deep.equal(config); + }); + + it('loads config from explicit path', () => { + const customPath = path.join(tempDir, 'custom-dw.json'); + const config: DwJsonConfig = { + hostname: 'custom.demandware.net', + }; + fs.writeFileSync(customPath, JSON.stringify(config)); + + const result = loadDwJson({path: customPath}); + expect(result).to.deep.equal(config); + }); + + it('selects named instance from multi-config', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const multiConfig = { + hostname: 'root.demandware.net', + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); + + const result = loadDwJson({instance: 'staging'}); + expect(result?.hostname).to.equal('staging.demandware.net'); + expect(result?.name).to.equal('staging'); + }); + + it('selects active config when no instance specified', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const multiConfig = { + active: false, + hostname: 'root.demandware.net', + configs: [ + {name: 'staging', hostname: 'staging.demandware.net', active: false}, + {name: 'production', hostname: 'prod.demandware.net', active: true}, + ], + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); + + const result = loadDwJson(); + expect(result?.hostname).to.equal('prod.demandware.net'); + expect(result?.name).to.equal('production'); + }); + + it('returns root config when no active config found', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const multiConfig = { + active: true, + hostname: 'root.demandware.net', + configs: [{name: 'staging', hostname: 'staging.demandware.net', active: false}], + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(multiConfig)); + + const result = loadDwJson(); + expect(result?.hostname).to.equal('root.demandware.net'); + }); + + it('returns undefined for invalid JSON', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync(dwJsonPath, 'invalid json'); + + const result = loadDwJson(); + expect(result).to.be.undefined; + }); + + it('returns undefined for non-existent explicit path', () => { + const result = loadDwJson({path: '/nonexistent/dw.json'}); + expect(result).to.be.undefined; + }); + + it('handles OAuth credentials', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const config: DwJsonConfig = { + hostname: 'test.demandware.net', + 'client-id': 'test-client', + 'client-secret': 'test-secret', + 'oauth-scopes': ['mail', 'roles'], + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(config)); + + const result = loadDwJson(); + expect(result?.['client-id']).to.equal('test-client'); + expect(result?.['client-secret']).to.equal('test-secret'); + expect(result?.['oauth-scopes']).to.deep.equal(['mail', 'roles']); + }); + + it('handles webdav-hostname', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + const config: DwJsonConfig = { + hostname: 'test.demandware.net', + 'webdav-hostname': 'webdav.test.com', + }; + fs.writeFileSync(dwJsonPath, JSON.stringify(config)); + + const result = loadDwJson(); + expect(result?.['webdav-hostname']).to.equal('webdav.test.com'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts new file mode 100644 index 00000000..acfe2c18 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/resolved-config.test.ts @@ -0,0 +1,200 @@ +/* + * 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 {resolveConfig} from '@salesforce/b2c-tooling-sdk/config'; +import {B2CInstance} from '@salesforce/b2c-tooling-sdk/instance'; + +describe('config/resolved-config', () => { + describe('ResolvedB2CConfig', () => { + describe('validation methods', () => { + it('hasB2CInstanceConfig returns true when hostname is present', () => { + const config = resolveConfig({hostname: 'test.demandware.net'}); + expect(config.hasB2CInstanceConfig()).to.be.true; + }); + + it('hasB2CInstanceConfig returns false when hostname is missing', () => { + const config = resolveConfig({}); + expect(config.hasB2CInstanceConfig()).to.be.false; + }); + + it('hasMrtConfig returns true when mrtApiKey is present', () => { + const config = resolveConfig({mrtApiKey: 'test-api-key'}); + expect(config.hasMrtConfig()).to.be.true; + }); + + it('hasMrtConfig returns false when mrtApiKey is missing', () => { + const config = resolveConfig({}); + expect(config.hasMrtConfig()).to.be.false; + }); + + it('hasOAuthConfig returns true when clientId is present', () => { + const config = resolveConfig({clientId: 'test-client'}); + expect(config.hasOAuthConfig()).to.be.true; + }); + + it('hasOAuthConfig returns false when clientId is missing', () => { + const config = resolveConfig({}); + expect(config.hasOAuthConfig()).to.be.false; + }); + + it('hasBasicAuthConfig returns true when username and password are present', () => { + const config = resolveConfig({username: 'user', password: 'pass'}); + expect(config.hasBasicAuthConfig()).to.be.true; + }); + + it('hasBasicAuthConfig returns false when username is missing', () => { + const config = resolveConfig({password: 'pass'}); + expect(config.hasBasicAuthConfig()).to.be.false; + }); + + it('hasBasicAuthConfig returns false when password is missing', () => { + const config = resolveConfig({username: 'user'}); + expect(config.hasBasicAuthConfig()).to.be.false; + }); + }); + + describe('createB2CInstance', () => { + it('creates B2CInstance when hostname is present', () => { + const config = resolveConfig({ + hostname: 'test.demandware.net', + clientId: 'test-client', + }); + const instance = config.createB2CInstance(); + expect(instance).to.be.instanceOf(B2CInstance); + }); + + it('throws error when hostname is missing', () => { + const config = resolveConfig({}); + expect(() => config.createB2CInstance()).to.throw('B2C instance requires hostname'); + }); + }); + + describe('createBasicAuth', () => { + it('creates BasicAuthStrategy when credentials are present', () => { + const config = resolveConfig({username: 'user', password: 'pass'}); + const auth = config.createBasicAuth(); + expect(auth).to.be.an('object'); + expect(auth).to.have.property('fetch'); + expect(auth.fetch).to.be.a('function'); + }); + + it('throws error when username is missing', () => { + const config = resolveConfig({password: 'pass'}); + expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); + }); + + it('throws error when password is missing', () => { + const config = resolveConfig({username: 'user'}); + expect(() => config.createBasicAuth()).to.throw('Basic auth requires username and password'); + }); + }); + + describe('createOAuth', () => { + it('creates OAuth strategy when clientId is present', () => { + const config = resolveConfig({clientId: 'test-client'}); + const auth = config.createOAuth(); + expect(auth).to.be.an('object'); + expect(auth).to.have.property('fetch'); + expect(auth.fetch).to.be.a('function'); + }); + + it('throws error when clientId is missing', () => { + const config = resolveConfig({}); + expect(() => config.createOAuth()).to.throw('OAuth requires clientId'); + }); + + it('accepts allowedMethods option', () => { + const config = resolveConfig({clientId: 'test-client', clientSecret: 'test-secret'}); + const auth = config.createOAuth({allowedMethods: ['client-credentials']}); + expect(auth).to.be.an('object'); + }); + }); + + describe('createMrtAuth', () => { + it('creates ApiKeyStrategy when mrtApiKey is present', () => { + const config = resolveConfig({mrtApiKey: 'test-api-key'}); + const auth = config.createMrtAuth(); + expect(auth).to.be.an('object'); + expect(auth).to.have.property('fetch'); + expect(auth.fetch).to.be.a('function'); + }); + + it('throws error when mrtApiKey is missing', () => { + const config = resolveConfig({}); + expect(() => config.createMrtAuth()).to.throw('MRT auth requires mrtApiKey'); + }); + }); + + describe('createWebDavAuth', () => { + it('creates BasicAuthStrategy when basic auth is available', () => { + const config = resolveConfig({username: 'user', password: 'pass'}); + const auth = config.createWebDavAuth(); + expect(auth).to.be.an('object'); + expect(auth).to.have.property('fetch'); + expect(auth.fetch).to.be.a('function'); + }); + + it('creates OAuth strategy when OAuth is available and basic auth is not', () => { + const config = resolveConfig({clientId: 'test-client'}); + const auth = config.createWebDavAuth(); + expect(auth).to.be.an('object'); + expect(auth).to.have.property('fetch'); + expect(auth.fetch).to.be.a('function'); + }); + + it('throws error when no auth is available', () => { + const config = resolveConfig({}); + expect(() => config.createWebDavAuth()).to.throw( + 'WebDAV auth requires basic auth (username/password) or OAuth (clientId)', + ); + }); + }); + + describe('createMrtClient', () => { + it('creates MrtClient when mrtApiKey is present', () => { + const config = resolveConfig({mrtApiKey: 'test-api-key'}); + const client = config.createMrtClient({org: 'test-org', project: 'test-project', env: 'staging'}); + expect(client).to.be.an('object'); + }); + + it('uses config values when options not provided', () => { + const config = resolveConfig({ + mrtApiKey: 'test-api-key', + mrtProject: 'config-project', + mrtEnvironment: 'production', + }); + const client = config.createMrtClient({org: 'test-org'}); + expect(client).to.be.an('object'); + }); + + it('throws error when mrtApiKey is missing', () => { + const config = resolveConfig({}); + expect(() => config.createMrtClient({org: 'test-org', project: 'test-project'})).to.throw( + 'MRT auth requires mrtApiKey', + ); + }); + }); + + describe('warnings and sources', () => { + it('exposes warnings from resolution', () => { + const config = resolveConfig({hostname: 'override.demandware.net'}); + expect(config.warnings).to.be.an('array'); + }); + + it('exposes sources from resolution', () => { + const config = resolveConfig({hostname: 'test.demandware.net'}); + expect(config.sources).to.be.an('array'); + }); + + it('exposes values from resolution', () => { + const config = resolveConfig({hostname: 'test.demandware.net', codeVersion: 'v1'}); + expect(config.values).to.be.an('object'); + expect(config.values.hostname).to.equal('test.demandware.net'); + expect(config.values.codeVersion).to.equal('v1'); + }); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts new file mode 100644 index 00000000..85df2d9c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -0,0 +1,363 @@ +/* + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import {ConfigResolver} from '@salesforce/b2c-tooling-sdk/config'; + +describe('config/sources', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + // Create a temporary directory for tests + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-sources-test-')); + originalCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + // Clean up + process.chdir(originalCwd); + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + describe('DwJsonSource', () => { + it('loads config from dw.json in current directory', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'test.demandware.net', + 'code-version': 'v1', + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.hostname).to.equal('test.demandware.net'); + expect(config.codeVersion).to.equal('v1'); + }); + + it('loads config from dw.json in parent directory', () => { + const subDir = path.join(tempDir, 'subdir'); + fs.mkdirSync(subDir); + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'parent.demandware.net', + }), + ); + + process.chdir(subDir); + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.hostname).to.equal('parent.demandware.net'); + }); + + it('handles OAuth credentials from dw.json', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'test.demandware.net', + 'client-id': 'test-client', + 'client-secret': 'test-secret', + 'oauth-scopes': ['mail', 'roles'], + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.clientId).to.equal('test-client'); + expect(config.clientSecret).to.equal('test-secret'); + expect(config.scopes).to.deep.equal(['mail', 'roles']); + }); + + it('returns undefined when dw.json does not exist', () => { + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + // Should not have hostname from dw.json + expect(config.hostname).to.be.undefined; + }); + + it('handles named instance from multi-config', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'root.demandware.net', + configs: [ + {name: 'staging', hostname: 'staging.demandware.net'}, + {name: 'production', hostname: 'prod.demandware.net'}, + ], + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve({}, {instance: 'staging'}); + + expect(config.hostname).to.equal('staging.demandware.net'); + }); + + it('provides path via getPath', () => { + const dwJsonPath = path.join(tempDir, 'dw.json'); + fs.writeFileSync( + dwJsonPath, + JSON.stringify({ + hostname: 'test.demandware.net', + }), + ); + + const resolver = new ConfigResolver(); + resolver.resolve(); + const {sources} = resolver.resolve(); + + const dwJsonSource = sources.find((s) => s.name === 'dw.json'); + // Normalize paths to handle macOS symlinks (/var -> /private/var) + const expectedPath = fs.realpathSync(dwJsonPath); + const actualPath = dwJsonSource?.path ? fs.realpathSync(dwJsonSource.path) : undefined; + expect(actualPath).to.equal(expectedPath); + }); + }); + + describe('MobifySource', () => { + it('loads mrtApiKey from ~/.mobify', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + username: 'user@example.com', + api_key: 'test-api-key', + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.mrtApiKey).to.equal('test-api-key'); + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + it('returns undefined when ~/.mobify does not exist', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.mrtApiKey).to.be.undefined; + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + it('returns undefined when api_key is missing from ~/.mobify', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + username: 'user@example.com', + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.mrtApiKey).to.be.undefined; + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + it('handles cloudOrigin for custom mobify file', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const mobifyPath = path.join(tempDir, '.mobify--example.com'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + api_key: 'cloud-api-key', + }), + ); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve({}, {cloudOrigin: 'https://example.com'}); + + expect(config.mrtApiKey).to.equal('cloud-api-key'); + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + it('returns undefined for invalid JSON in ~/.mobify', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync(mobifyPath, 'invalid json'); + + const resolver = new ConfigResolver(); + const {config} = resolver.resolve(); + + expect(config.mrtApiKey).to.be.undefined; + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + + it('provides path via getPath', function () { + const originalHomedir = os.homedir; + let canMock = false; + try { + Object.defineProperty(os, 'homedir', { + value: () => tempDir, + writable: true, + enumerable: true, + configurable: true, + }); + canMock = true; + } catch { + this.skip(); + } + + if (canMock) { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + api_key: 'test-api-key', + }), + ); + + const resolver = new ConfigResolver(); + resolver.resolve(); + const {sources} = resolver.resolve(); + + const mobifySource = sources.find((s) => s.name === 'mobify'); + // Normalize paths to handle macOS symlinks + const expectedPath = fs.realpathSync(mobifyPath); + const actualPath = mobifySource?.path ? fs.realpathSync(mobifySource.path) : undefined; + expect(actualPath).to.equal(expectedPath); + + // Restore + Object.defineProperty(os, 'homedir', { + value: originalHomedir, + writable: true, + enumerable: true, + configurable: true, + }); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/logging/logger.test.ts b/packages/b2c-tooling-sdk/test/logging/logger.test.ts new file mode 100644 index 00000000..977de807 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/logging/logger.test.ts @@ -0,0 +1,373 @@ +/* + * 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 { + createLogger, + configureLogger, + getLogger, + resetLogger, + createSilentLogger, + type Logger, +} from '@salesforce/b2c-tooling-sdk/logging'; + +describe('logging/logger', () => { + beforeEach(() => { + // Reset global logger state before each test + resetLogger(); + }); + + afterEach(() => { + // Clean up after each test + resetLogger(); + }); + + describe('createLogger', () => { + it('creates logger with default options', () => { + const logger = createLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + expect(logger.debug).to.be.a('function'); + expect(logger.warn).to.be.a('function'); + expect(logger.error).to.be.a('function'); + }); + + it('creates logger with custom level', () => { + const logger = createLogger({level: 'debug'}); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with json output', () => { + const logger = createLogger({json: true}); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with colorize disabled', () => { + const logger = createLogger({colorize: false}); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with baseContext', () => { + const logger = createLogger({baseContext: {app: 'test'}}); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with custom file descriptor', () => { + const logger = createLogger({fd: 1}); // stdout + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with redaction disabled', () => { + const logger = createLogger({redact: false}); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('creates logger with all options', () => { + const logger = createLogger({ + level: 'warn', + fd: 1, + json: true, + colorize: false, + redact: true, + baseContext: {env: 'test'}, + }); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + }); + + describe('configureLogger', () => { + it('configures global logger options', () => { + configureLogger({level: 'debug'}); + const logger = getLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('merges with existing global options', () => { + configureLogger({level: 'info'}); + configureLogger({json: true}); + const logger = getLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('updates global logger instance', () => { + configureLogger({level: 'debug'}); + const logger1 = getLogger(); + configureLogger({level: 'warn'}); + const logger2 = getLogger(); + expect(logger1).to.exist; + expect(logger2).to.exist; + expect(logger1.info).to.be.a('function'); + expect(logger2.info).to.be.a('function'); + }); + }); + + describe('getLogger', () => { + it('returns logger with default options when not configured', () => { + resetLogger(); + const logger = getLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('returns same logger instance after configuration', () => { + configureLogger({level: 'info'}); + const logger1 = getLogger(); + const logger2 = getLogger(); + expect(logger1).to.equal(logger2); + }); + + it('creates new logger after reset', () => { + configureLogger({level: 'info'}); + const logger1 = getLogger(); + resetLogger(); + const logger2 = getLogger(); + expect(logger1).to.exist; + expect(logger2).to.exist; + expect(logger1.info).to.be.a('function'); + expect(logger2.info).to.be.a('function'); + }); + }); + + describe('resetLogger', () => { + it('resets global logger to null', () => { + configureLogger({level: 'debug'}); + getLogger(); // Create logger + resetLogger(); + const logger = getLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('resets global options to defaults', () => { + configureLogger({level: 'debug', json: true}); + resetLogger(); + const logger = getLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + }); + + describe('createSilentLogger', () => { + it('creates logger with silent level', () => { + const logger = createSilentLogger(); + expect(logger).to.exist; + expect(logger.info).to.be.a('function'); + }); + + it('does not output logs', () => { + const logger = createSilentLogger(); + // Should not throw + logger.info('test message'); + logger.debug('debug message'); + logger.warn('warn message'); + logger.error('error message'); + }); + }); + + describe('Logger methods', () => { + let logger: Logger; + + beforeEach(() => { + logger = createLogger({level: 'trace'}); + }); + + it('supports trace method with message first', () => { + logger.trace('trace message'); + }); + + it('supports trace method with context first', () => { + logger.trace({key: 'value'}, 'trace message'); + }); + + it('supports debug method with message first', () => { + logger.debug('debug message'); + }); + + it('supports debug method with context first', () => { + logger.debug({key: 'value'}, 'debug message'); + }); + + it('supports info method with message first', () => { + logger.info('info message'); + }); + + it('supports info method with context first', () => { + logger.info({key: 'value'}, 'info message'); + }); + + it('supports warn method with message first', () => { + logger.warn('warn message'); + }); + + it('supports warn method with context first', () => { + logger.warn({key: 'value'}, 'warn message'); + }); + + it('supports error method with message first', () => { + logger.error('error message'); + }); + + it('supports error method with context first', () => { + logger.error({key: 'value'}, 'error message'); + }); + + it('supports fatal method with message first', () => { + logger.fatal('fatal message'); + }); + + it('supports fatal method with context first', () => { + logger.fatal({key: 'value'}, 'fatal message'); + }); + }); + + describe('child logger', () => { + it('creates child logger with context', () => { + const parent = createLogger({level: 'info'}); + const child = parent.child({operation: 'deploy'}); + expect(child).to.exist; + expect(child.info).to.be.a('function'); + }); + + it('child logger inherits parent configuration', () => { + const parent = createLogger({level: 'debug'}); + const child = parent.child({operation: 'deploy'}); + expect(child).to.exist; + expect(child.info).to.be.a('function'); + }); + + it('child logger can create nested children', () => { + const parent = createLogger({level: 'info'}); + const child1 = parent.child({operation: 'deploy'}); + const child2 = child1.child({file: 'app.zip'}); + expect(child2).to.exist; + expect(child2.info).to.be.a('function'); + }); + }); + + describe('log levels', () => { + it('respects trace level', () => { + const logger = createLogger({level: 'trace'}); + logger.trace('trace message'); + logger.debug('debug message'); + logger.info('info message'); + }); + + it('respects debug level', () => { + const logger = createLogger({level: 'debug'}); + logger.debug('debug message'); + logger.info('info message'); + }); + + it('respects info level', () => { + const logger = createLogger({level: 'info'}); + logger.info('info message'); + logger.warn('warn message'); + }); + + it('respects warn level', () => { + const logger = createLogger({level: 'warn'}); + logger.warn('warn message'); + logger.error('error message'); + }); + + it('respects error level', () => { + const logger = createLogger({level: 'error'}); + logger.error('error message'); + logger.fatal('fatal message'); + }); + + it('respects fatal level', () => { + const logger = createLogger({level: 'fatal'}); + logger.fatal('fatal message'); + }); + + it('respects silent level', () => { + const logger = createLogger({level: 'silent'}); + logger.info('should not appear'); + logger.error('should not appear'); + }); + }); + + describe('secret redaction', () => { + it('redacts password field', () => { + const logger = createLogger({level: 'info', json: true}); + // Capture output to verify redaction + logger.info({username: 'user', password: 'secret123'}, 'Auth attempt'); + }); + + it('redacts clientSecret field', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({clientId: 'client', clientSecret: 'secret123'}, 'OAuth config'); + }); + + it('redacts apiKey field', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({apiKey: 'key123456789'}, 'API key config'); + }); + + it('redacts token field', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({token: 'token123456789'}, 'Token config'); + }); + + it('redacts nested fields', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({config: {auth: {password: 'secret123'}}}, 'Nested config'); + }); + + it('redacts authorization header with Basic auth', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({authorization: 'Basic dXNlcjpwYXNz'}, 'Auth header'); + }); + + it('redacts authorization header with Bearer token', () => { + const logger = createLogger({level: 'info', json: true}); + logger.info({authorization: 'Bearer token123456789'}, 'Auth header'); + }); + + it('does not redact when redact is disabled', () => { + const logger = createLogger({level: 'info', json: true, redact: false}); + logger.info({password: 'secret123'}, 'No redaction'); + }); + }); + + describe('JSON output mode', () => { + it('outputs JSON when json option is true', () => { + const logger = createLogger({json: true, level: 'info'}); + logger.info('test message'); + }); + + it('outputs pretty print when json option is false', () => { + const logger = createLogger({json: false, level: 'info'}); + logger.info('test message'); + }); + + it('outputs pretty print by default', () => { + const logger = createLogger({level: 'info'}); + logger.info('test message'); + }); + }); + + describe('baseContext', () => { + it('includes baseContext in all log entries', () => { + const logger = createLogger({baseContext: {app: 'test-app', version: '1.0.0'}}); + logger.info('test message'); + }); + + it('merges baseContext with inline context', () => { + const logger = createLogger({baseContext: {app: 'test-app'}}); + logger.info({operation: 'deploy'}, 'test message'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/platform/mrt.test.ts b/packages/b2c-tooling-sdk/test/platform/mrt.test.ts new file mode 100644 index 00000000..2ac244ac --- /dev/null +++ b/packages/b2c-tooling-sdk/test/platform/mrt.test.ts @@ -0,0 +1,179 @@ +/* + * 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 {MrtClient, type MrtProject} from '@salesforce/b2c-tooling-sdk/platform'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const BASE_URL = 'https://api.commercecloud.salesforce.com/mrt'; + +describe('platform/mrt', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('MrtClient', () => { + const project: MrtProject = { + org: 'test-org', + project: 'test-project', + env: 'production', + }; + + it('creates client with project and auth', () => { + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + expect(client).to.exist; + expect(client.project).to.deep.equal(project); + expect(client.auth).to.equal(auth); + }); + + it('request normalizes path with leading slash', async () => { + server.use( + http.get(`${BASE_URL}/api/projects`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({results: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/projects'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles path without leading slash', async () => { + server.use( + http.get(`${BASE_URL}/api/projects`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({results: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('api/projects'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request passes through RequestInit options', async () => { + let receivedMethod: string | null = null; + let receivedBody: string | null = null; + + server.use( + http.post(`${BASE_URL}/api/builds`, async ({request}) => { + receivedMethod = request.method; + receivedBody = await request.text(); + return HttpResponse.json({id: 'build-123'}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/builds', { + method: 'POST', + body: JSON.stringify({message: 'test'}), + headers: {'Content-Type': 'application/json'}, + }); + + expect(response).to.exist; + expect(response.ok).to.be.true; + expect(receivedMethod).to.equal('POST'); + expect(receivedBody).to.equal('{"message":"test"}'); + }); + + it('request handles GET requests', async () => { + server.use( + http.get(`${BASE_URL}/api/projects/test-project`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({slug: 'test-project'}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/projects/test-project'); + const data = await response.json(); + expect(response.ok).to.be.true; + expect(data).to.deep.equal({slug: 'test-project'}); + }); + + it('request handles error responses', async () => { + server.use( + http.get(`${BASE_URL}/api/projects/nonexistent`, () => { + return HttpResponse.json({detail: 'Not found'}, {status: 404}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/projects/nonexistent'); + expect(response.ok).to.be.false; + expect(response.status).to.equal(404); + }); + + it('request uses auth strategy fetch method', async () => { + let requestUrl: string | null = null; + + server.use( + http.get(`${BASE_URL}/api/test`, ({request}) => { + requestUrl = request.url; + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({success: true}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/test'); + expect(response).to.exist; + expect(requestUrl).to.equal(`${BASE_URL}/api/test`); + }); + + it('request handles empty path', async () => { + server.use( + http.get(`${BASE_URL}`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({status: 'ok'}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request(''); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles path with query parameters', async () => { + server.use( + http.get(`${BASE_URL}/api/projects`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('env')).to.equal('production'); + return HttpResponse.json({results: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = new MrtClient(project, auth); + const response = await client.request('/api/projects?env=production'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/platform/ods.test.ts b/packages/b2c-tooling-sdk/test/platform/ods.test.ts new file mode 100644 index 00000000..87ef619a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/platform/ods.test.ts @@ -0,0 +1,238 @@ +/* + * 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 {OdsClient, type OdsConfig} from '@salesforce/b2c-tooling-sdk/platform'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const BASE_URL = 'https://api.commercecloud.salesforce.com/ods'; + +describe('platform/ods', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('OdsClient', () => { + it('creates client with config and auth', () => { + const config: OdsConfig = {region: 'us'}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + expect(client).to.exist; + expect(client.config).to.deep.equal(config); + expect(client.auth).to.equal(auth); + }); + + it('creates client with empty config', () => { + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + expect(client).to.exist; + expect(client.config).to.deep.equal(config); + }); + + it('creates client with region in config', () => { + const config: OdsConfig = {region: 'eu'}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + expect(client).to.exist; + expect(client.config.region).to.equal('eu'); + }); + + it('request normalizes path with leading slash', async () => { + server.use( + http.get(`${BASE_URL}/api/sandboxes`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({data: []}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles path without leading slash', async () => { + server.use( + http.get(`${BASE_URL}/api/sandboxes`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({data: []}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('api/sandboxes'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request passes through RequestInit options', async () => { + let receivedMethod: string | null = null; + let receivedBody: string | null = null; + + server.use( + http.post(`${BASE_URL}/api/sandboxes`, async ({request}) => { + receivedMethod = request.method; + receivedBody = await request.text(); + return HttpResponse.json({data: {id: 'sb-123'}}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes', { + method: 'POST', + body: JSON.stringify({realm: 'zzzv'}), + headers: {'Content-Type': 'application/json'}, + }); + + expect(response).to.exist; + expect(response.ok).to.be.true; + expect(receivedMethod).to.equal('POST'); + expect(receivedBody).to.equal('{"realm":"zzzv"}'); + }); + + it('request handles GET requests', async () => { + server.use( + http.get(`${BASE_URL}/api/sandboxes/sb-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({data: {id: 'sb-123', state: 'started'}}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes/sb-123'); + const data = await response.json(); + expect(response.ok).to.be.true; + expect(data).to.deep.equal({data: {id: 'sb-123', state: 'started'}}); + }); + + it('request handles error responses', async () => { + server.use( + http.get(`${BASE_URL}/api/sandboxes/nonexistent`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes/nonexistent'); + expect(response.ok).to.be.false; + expect(response.status).to.equal(404); + }); + + it('request uses auth strategy fetch method', async () => { + let requestUrl: string | null = null; + + server.use( + http.get(`${BASE_URL}/api/test`, ({request}) => { + requestUrl = request.url; + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({success: true}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/test'); + expect(response).to.exist; + expect(requestUrl).to.equal(`${BASE_URL}/api/test`); + }); + + it('request handles empty path', async () => { + server.use( + http.get(`${BASE_URL}`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({status: 'ok'}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request(''); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles path with query parameters', async () => { + server.use( + http.get(`${BASE_URL}/api/sandboxes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('include_deleted')).to.equal('true'); + return HttpResponse.json({data: []}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes?include_deleted=true'); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles DELETE requests', async () => { + server.use( + http.delete(`${BASE_URL}/api/sandboxes/sb-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({data: {id: 'op-123', status: 'deleting'}}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes/sb-123', {method: 'DELETE'}); + expect(response).to.exist; + expect(response.ok).to.be.true; + }); + + it('request handles PUT requests', async () => { + let receivedBody: string | null = null; + + server.use( + http.put(`${BASE_URL}/api/sandboxes/sb-123`, async ({request}) => { + receivedBody = await request.text(); + return HttpResponse.json({data: {id: 'sb-123', updated: true}}); + }), + ); + + const config: OdsConfig = {}; + const auth = new MockAuthStrategy(); + const client = new OdsClient(config, auth); + const response = await client.request('/api/sandboxes/sb-123', { + method: 'PUT', + body: JSON.stringify({ttl: 48}), + headers: {'Content-Type': 'application/json'}, + }); + + expect(response).to.exist; + expect(response.ok).to.be.true; + expect(receivedBody).to.equal('{"ttl":48}'); + }); + }); +});