diff --git a/packages/b2c-cli/eslint.config.mjs b/packages/b2c-cli/eslint.config.mjs index 05a68b2..1a0ce5a 100644 --- a/packages/b2c-cli/eslint.config.mjs +++ b/packages/b2c-cli/eslint.config.mjs @@ -9,7 +9,7 @@ import headerPlugin from 'eslint-plugin-header'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {copyrightHeader, sharedRules, oclifRules, prettierPlugin} from '../../eslint.config.mjs'; +import {copyrightHeader, sharedRules, oclifRules, chaiTestRules, prettierPlugin} from '../../eslint.config.mjs'; const gitignorePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '.gitignore'); headerPlugin.rules.header.meta.schema = false; @@ -19,7 +19,7 @@ export default [ // node_modules must be explicitly ignored because the .gitignore pattern only covers // packages/b2c-cli/node_modules, not the monorepo root node_modules { - ignores: ['**/node_modules/**', 'test/functional/fixtures/**/*.js'], + ignores: ['**/node_modules/**', 'test/functional/fixtures/**/*.js', '**/node_modules/marked-terminal/**'], }, includeIgnoreFile(gitignorePath), ...oclif, @@ -39,4 +39,29 @@ export default [ ...oclifRules, }, }, + { + files: ['test/**/*.ts'], + rules: { + ...chaiTestRules, + // Tests use stubbing patterns that intentionally return undefined + 'unicorn/no-useless-undefined': 'off', + // Some tests use void 0 to satisfy TS stub typings; allow it in tests + 'no-void': 'off', + // Command tests frequently use `any` to avoid over-typing oclif command internals + '@typescript-eslint/no-explicit-any': 'off', + // Helper functions in tests are commonly declared within suites for clarity + 'unicorn/consistent-function-scoping': 'off', + // Sinon default import is intentional and idiomatic in tests + 'import/no-named-as-default-member': 'off', + // import/namespace behaves inconsistently across environments when parsing CJS modules like marked-terminal + 'import/namespace': 'off', + }, + }, + { + files: ['src/commands/docs/**/*.ts'], + rules: { + // marked-terminal is CJS and breaks import/namespace static analysis + 'import/namespace': 'off', + }, + }, ]; diff --git a/packages/b2c-cli/package.json b/packages/b2c-cli/package.json index 6146e3f..1b12754 100644 --- a/packages/b2c-cli/package.json +++ b/packages/b2c-cli/package.json @@ -52,6 +52,7 @@ "oclif": "^4", "prettier": "^3.6.2", "shx": "^0.3.3", + "sinon": "^21.0.1", "tsx": "^4.20.6", "typescript": "^5" }, diff --git a/packages/b2c-cli/src/commands/code/delete.ts b/packages/b2c-cli/src/commands/code/delete.ts index bc2870a..8382ee2 100644 --- a/packages/b2c-cli/src/commands/code/delete.ts +++ b/packages/b2c-cli/src/commands/code/delete.ts @@ -51,6 +51,10 @@ export default class CodeDelete extends InstanceCommand { }), }; + protected async confirm(message: string): Promise { + return confirm(message); + } + async run(): Promise { this.requireOAuthCredentials(); @@ -59,7 +63,7 @@ export default class CodeDelete extends InstanceCommand { // Confirm deletion unless --force is used if (!this.flags.force) { - const confirmed = await confirm( + const confirmed = await this.confirm( t( 'commands.code.delete.confirm', 'Are you sure you want to delete code version "{{codeVersion}}" on {{hostname}}? (y/n)', diff --git a/packages/b2c-cli/src/commands/code/deploy.ts b/packages/b2c-cli/src/commands/code/deploy.ts index 65fa2d7..c3972b8 100644 --- a/packages/b2c-cli/src/commands/code/deploy.ts +++ b/packages/b2c-cli/src/commands/code/deploy.ts @@ -47,6 +47,18 @@ export default class CodeDeploy extends CartridgeCommand { }), }; + protected async deleteCartridges(cartridges: Parameters[1]) { + return deleteCartridges(this.instance, cartridges); + } + + protected async getActiveCodeVersion() { + return getActiveCodeVersion(this.instance); + } + + protected async reloadCodeVersion(codeVersion: string) { + return reloadCodeVersion(this.instance, codeVersion); + } + async run(): Promise { this.requireWebDavCredentials(); this.requireOAuthCredentials(); @@ -59,7 +71,7 @@ export default class CodeDeploy extends CartridgeCommand { this.warn( t('commands.code.deploy.noCodeVersion', 'No code version specified, discovering active code version...'), ); - const activeVersion = await getActiveCodeVersion(this.instance); + const activeVersion = await this.getActiveCodeVersion(); if (!activeVersion?.id) { this.error( t('commands.code.deploy.noActiveVersion', 'No active code version found. Specify one with --code-version.'), @@ -119,17 +131,17 @@ export default class CodeDeploy extends CartridgeCommand { try { // Optionally delete existing cartridges first if (this.flags.delete) { - await deleteCartridges(this.instance, cartridges); + await this.deleteCartridges(cartridges); } // Upload cartridges - await uploadCartridges(this.instance, cartridges); + await this.uploadCartridges(cartridges); // Optionally reload code version let reloaded = false; if (this.flags.reload) { try { - await reloadCodeVersion(this.instance, version); + await this.reloadCodeVersion(version); reloaded = true; } catch (error) { this.logger?.debug(`Could not reload code version: ${error instanceof Error ? error.message : error}`); @@ -176,4 +188,8 @@ export default class CodeDeploy extends CartridgeCommand { throw error; } } + + protected async uploadCartridges(cartridges: Parameters[1]) { + return uploadCartridges(this.instance, cartridges); + } } diff --git a/packages/b2c-cli/src/commands/code/watch.ts b/packages/b2c-cli/src/commands/code/watch.ts index 849fe0a..f6f4b42 100644 --- a/packages/b2c-cli/src/commands/code/watch.ts +++ b/packages/b2c-cli/src/commands/code/watch.ts @@ -41,18 +41,7 @@ export default class CodeWatch extends CartridgeCommand { } try { - const result = await watchCartridges(this.instance, this.cartridgePath, { - ...this.cartridgeOptions, - onUpload: (files) => { - this.log(t('commands.code.watch.uploaded', '[UPLOAD] {{count}} file(s)', {count: files.length})); - }, - onDelete: (files) => { - this.log(t('commands.code.watch.deleted', '[DELETE] {{count}} file(s)', {count: files.length})); - }, - onError: (error) => { - this.warn(t('commands.code.watch.error', 'Error: {{message}}', {message: error.message})); - }, - }); + const result = await this.watchCartridges(); this.log( t('commands.code.watch.watching', 'Watching {{count}} cartridge(s)...', {count: result.cartridges.length}), @@ -78,4 +67,19 @@ export default class CodeWatch extends CartridgeCommand { throw error; } } + + protected async watchCartridges() { + return watchCartridges(this.instance, this.cartridgePath, { + ...this.cartridgeOptions, + onUpload: (files) => { + this.log(t('commands.code.watch.uploaded', '[UPLOAD] {{count}} file(s)', {count: files.length})); + }, + onDelete: (files) => { + this.log(t('commands.code.watch.deleted', '[DELETE] {{count}} file(s)', {count: files.length})); + }, + onError: (error) => { + this.warn(t('commands.code.watch.error', 'Error: {{message}}', {message: error.message})); + }, + }); + } } diff --git a/packages/b2c-cli/src/commands/docs/download.ts b/packages/b2c-cli/src/commands/docs/download.ts index 20927d6..66eed64 100644 --- a/packages/b2c-cli/src/commands/docs/download.ts +++ b/packages/b2c-cli/src/commands/docs/download.ts @@ -37,6 +37,10 @@ export default class DocsDownload extends InstanceCommand { }), }; + protected async downloadDocs(input: Parameters[1]) { + return downloadDocs(this.instance, input); + } + async run(): Promise { this.requireServer(); this.requireWebDavCredentials(); @@ -50,7 +54,7 @@ export default class DocsDownload extends InstanceCommand { }), ); - const result = await downloadDocs(this.instance, { + const result = await this.downloadDocs({ outputDir, keepArchive, }); diff --git a/packages/b2c-cli/src/commands/docs/read.ts b/packages/b2c-cli/src/commands/docs/read.ts index 82aa3fa..71115d7 100644 --- a/packages/b2c-cli/src/commands/docs/read.ts +++ b/packages/b2c-cli/src/commands/docs/read.ts @@ -5,7 +5,7 @@ */ import {Args, Flags} from '@oclif/core'; import {marked} from 'marked'; -// eslint-disable-next-line import/namespace + import {markedTerminal} from 'marked-terminal'; import {BaseCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {readDocByQuery, type DocEntry} from '@salesforce/b2c-tooling-sdk/operations/docs'; @@ -57,11 +57,15 @@ export default class DocsRead extends BaseCommand { }), }; + protected readDocByQuery(query: string) { + return readDocByQuery(query); + } + async run(): Promise { const {query} = this.args; const {raw} = this.flags; - const result = readDocByQuery(query); + const result = this.readDocByQuery(query); if (!result) { this.error(t('commands.docs.read.notFound', 'No documentation found matching: {{query}}', {query}), { diff --git a/packages/b2c-cli/src/commands/docs/schema.ts b/packages/b2c-cli/src/commands/docs/schema.ts index 84ee6eb..0052d70 100644 --- a/packages/b2c-cli/src/commands/docs/schema.ts +++ b/packages/b2c-cli/src/commands/docs/schema.ts @@ -45,13 +45,21 @@ export default class DocsSchema extends BaseCommand { }), }; + protected listSchemas() { + return listSchemas(); + } + + protected readSchemaByQuery(query: string) { + return readSchemaByQuery(query); + } + async run(): Promise { const {query} = this.args; const {list} = this.flags; // List mode if (list) { - const entries = listSchemas(); + const entries = this.listSchemas(); if (this.jsonEnabled()) { return {entries}; @@ -72,7 +80,7 @@ export default class DocsSchema extends BaseCommand { this.error(t('commands.docs.schema.queryRequired', 'Schema name is required. Use --list to see all schemas.')); } - const result = readSchemaByQuery(query); + const result = this.readSchemaByQuery(query); if (!result) { this.error(t('commands.docs.schema.notFound', 'No schema found matching: {{query}}', {query}), { diff --git a/packages/b2c-cli/src/commands/docs/search.ts b/packages/b2c-cli/src/commands/docs/search.ts index 740d53b..9c99a4f 100644 --- a/packages/b2c-cli/src/commands/docs/search.ts +++ b/packages/b2c-cli/src/commands/docs/search.ts @@ -70,13 +70,17 @@ export default class DocsSearch extends BaseCommand { }), }; + protected listDocs() { + return listDocs(); + } + async run(): Promise { const {query} = this.args; const {limit, list} = this.flags; // List mode if (list) { - const entries = listDocs(); + const entries = this.listDocs(); if (this.jsonEnabled()) { return {entries}; @@ -109,7 +113,7 @@ export default class DocsSearch extends BaseCommand { ); } - const results = searchDocs(query, limit); + const results = this.searchDocs(query, limit); const response: SearchDocsResponse = { query, @@ -136,4 +140,8 @@ export default class DocsSearch extends BaseCommand { return response; } + + protected searchDocs(query: string, limit: number) { + return searchDocs(query, limit); + } } diff --git a/packages/b2c-cli/src/commands/job/export.ts b/packages/b2c-cli/src/commands/job/export.ts index 2188845..4243dd7 100644 --- a/packages/b2c-cli/src/commands/job/export.ts +++ b/packages/b2c-cli/src/commands/job/export.ts @@ -114,7 +114,7 @@ export default class JobExport extends JobCommand { 'no-download': noDownload, 'zip-only': zipOnly, timeout, - 'show-log': showLog, + 'show-log': showLog = true, } = this.flags; const hostname = this.resolvedConfig.values.hostname!; @@ -173,7 +173,7 @@ export default class JobExport extends JobCommand { this.log(t('commands.job.export.dataUnits', 'Data units: {{dataUnits}}', {dataUnits: JSON.stringify(dataUnits)})); try { - const result = await siteArchiveExportToPath(this.instance, dataUnits, output, { + const result = await this.siteArchiveExportToPath(dataUnits, output, { keepArchive: keepArchive || noDownload, extractZip: !zipOnly, waitOptions: { @@ -254,6 +254,14 @@ export default class JobExport extends JobCommand { } } + protected async siteArchiveExportToPath( + dataUnits: Parameters[1], + output: Parameters[2], + options: Parameters[3], + ) { + return siteArchiveExportToPath(this.instance, dataUnits, output, options); + } + private buildDataUnits(params: { dataUnitsJson?: string; site?: string[]; diff --git a/packages/b2c-cli/src/commands/job/import.ts b/packages/b2c-cli/src/commands/job/import.ts index eaca9f4..73b0cff 100644 --- a/packages/b2c-cli/src/commands/job/import.ts +++ b/packages/b2c-cli/src/commands/job/import.ts @@ -61,7 +61,7 @@ export default class JobImport extends JobCommand { this.requireWebDavCredentials(); const {target} = this.args; - const {'keep-archive': keepArchive, remote, timeout, 'show-log': showLog} = this.flags; + const {'keep-archive': keepArchive, remote, timeout, 'show-log': showLog = true} = this.flags; const hostname = this.resolvedConfig.values.hostname!; @@ -107,7 +107,7 @@ export default class JobImport extends JobCommand { try { const importTarget = remote ? {remoteFilename: target} : target; - const result = await siteArchiveImport(this.instance, importTarget, { + const result = await this.siteArchiveImport(importTarget, { keepArchive, waitOptions: { timeout: timeout ? timeout * 1000 : undefined, @@ -178,4 +178,11 @@ export default class JobImport extends JobCommand { throw error; } } + + protected async siteArchiveImport( + target: Parameters[1], + options: Parameters[2], + ) { + return siteArchiveImport(this.instance, target, options); + } } diff --git a/packages/b2c-cli/src/commands/job/run.ts b/packages/b2c-cli/src/commands/job/run.ts index 1284187..f11d7bc 100644 --- a/packages/b2c-cli/src/commands/job/run.ts +++ b/packages/b2c-cli/src/commands/job/run.ts @@ -66,6 +66,10 @@ export default class JobRun extends JobCommand { }), }; + protected async executeJob(jobId: string, options: Parameters[2]) { + return executeJob(this.instance, jobId, options); + } + async run(): Promise { this.requireOAuthCredentials(); @@ -106,7 +110,7 @@ export default class JobRun extends JobCommand { let execution: JobExecution; try { - execution = await executeJob(this.instance, jobId, { + execution = await this.executeJob(jobId, { parameters: rawBody ? undefined : parameters, body: rawBody, waitForRunning: !noWaitRunning, @@ -143,6 +147,10 @@ export default class JobRun extends JobCommand { return execution; } + protected async waitForJob(jobId: string, executionId: string, options: Parameters[3]) { + return waitForJob(this.instance, jobId, executionId, options); + } + private handleExecutionError(error: unknown, context: B2COperationContext): never { // Run afterOperation hooks with failure (fire-and-forget, errors ignored) this.runAfterHooks(context, { @@ -213,7 +221,7 @@ export default class JobRun extends JobCommand { this.log(t('commands.job.run.waiting', 'Waiting for job to complete...')); try { - const execution = await waitForJob(this.instance, jobId, executionId, { + const execution = await this.waitForJob(jobId, executionId, { timeout: timeout ? timeout * 1000 : undefined, onProgress: (exec, elapsed) => { if (!this.jsonEnabled()) { diff --git a/packages/b2c-cli/src/commands/job/search.ts b/packages/b2c-cli/src/commands/job/search.ts index 021bfe1..4ef2c36 100644 --- a/packages/b2c-cli/src/commands/job/search.ts +++ b/packages/b2c-cli/src/commands/job/search.ts @@ -90,7 +90,7 @@ export default class JobSearch extends InstanceCommand { }), ); - const results = await searchJobExecutions(this.instance, { + const results = await this.searchJobExecutions({ jobId, status, count, @@ -121,4 +121,8 @@ export default class JobSearch extends InstanceCommand { return results; } + + protected async searchJobExecutions(options: Parameters[1]) { + return searchJobExecutions(this.instance, options); + } } diff --git a/packages/b2c-cli/src/commands/job/wait.ts b/packages/b2c-cli/src/commands/job/wait.ts index 01b224b..4d5a877 100644 --- a/packages/b2c-cli/src/commands/job/wait.ts +++ b/packages/b2c-cli/src/commands/job/wait.ts @@ -60,7 +60,7 @@ export default class JobWait extends JobCommand { ); try { - const execution = await waitForJob(this.instance, jobId, executionId, { + const execution = await this.waitForJob(jobId, executionId, { timeout: timeout ? timeout * 1000 : undefined, pollInterval: pollInterval * 1000, onProgress: (exec, elapsed) => { @@ -99,4 +99,8 @@ export default class JobWait extends JobCommand { throw error; } } + + protected async waitForJob(jobId: string, executionId: string, options: Parameters[3]) { + return waitForJob(this.instance, jobId, executionId, options); + } } diff --git a/packages/b2c-cli/src/commands/mrt/env/create.ts b/packages/b2c-cli/src/commands/mrt/env/create.ts index 012f23d..3186402 100644 --- a/packages/b2c-cli/src/commands/mrt/env/create.ts +++ b/packages/b2c-cli/src/commands/mrt/env/create.ts @@ -193,6 +193,10 @@ export default class MrtEnvCreate extends MrtCommand { }), }; + protected async createEnv(input: Parameters[0], auth: Parameters[1]) { + return createEnv(input, auth); + } + async run(): Promise { this.requireMrtCredentials(); @@ -229,7 +233,7 @@ export default class MrtEnvCreate extends MrtCommand { ); try { - let result = await createEnv( + let result = await this.createEnv( { projectSlug: project, slug, @@ -252,7 +256,7 @@ export default class MrtEnvCreate extends MrtCommand { this.log(t('commands.mrt.env.create.waiting', 'Waiting for environment "{{slug}}" to be ready...', {slug})); const waitStartTime = Date.now(); - result = await waitForEnv( + result = await this.waitForEnv( { projectSlug: project, slug, @@ -292,4 +296,8 @@ export default class MrtEnvCreate extends MrtCommand { throw error; } } + + protected async waitForEnv(input: Parameters[0], auth: Parameters[1]) { + return waitForEnv(input, auth); + } } diff --git a/packages/b2c-cli/src/commands/mrt/env/delete.ts b/packages/b2c-cli/src/commands/mrt/env/delete.ts index 140276a..2ded487 100644 --- a/packages/b2c-cli/src/commands/mrt/env/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/delete.ts @@ -55,6 +55,10 @@ export default class MrtEnvDelete extends MrtCommand { }), }; + protected async deleteEnv(input: Parameters[0], auth: Parameters[1]) { + return deleteEnv(input, auth); + } + async run(): Promise<{slug: string; project: string}> { this.requireMrtCredentials(); @@ -95,7 +99,7 @@ export default class MrtEnvDelete extends MrtCommand { } try { - await deleteEnv( + await this.deleteEnv( { projectSlug: project, slug, diff --git a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts index 6359275..7b72d88 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/delete.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/delete.ts @@ -35,6 +35,10 @@ export default class MrtEnvVarDelete extends MrtCommand ...MrtCommand.baseFlags, }; + protected async deleteEnvVar(input: Parameters[0], auth: Parameters[1]) { + return deleteEnvVar(input, auth); + } + async run(): Promise<{key: string; project: string; environment: string}> { this.requireMrtCredentials(); @@ -52,7 +56,7 @@ export default class MrtEnvVarDelete extends MrtCommand ); } - await deleteEnvVar( + await this.deleteEnvVar( { projectSlug: project, environment, diff --git a/packages/b2c-cli/src/commands/mrt/env/var/list.ts b/packages/b2c-cli/src/commands/mrt/env/var/list.ts index ef90797..3478fd1 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/list.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/list.ts @@ -53,6 +53,14 @@ export default class MrtEnvVarList extends MrtCommand { ...MrtCommand.baseFlags, }; + protected async listEnvVars(input: Parameters[0], auth: Parameters[1]) { + return listEnvVars(input, auth); + } + + protected renderTable(variables: EnvironmentVariable[]): void { + createTable(COLUMNS).render(variables, DEFAULT_COLUMNS); + } + async run(): Promise { this.requireMrtCredentials(); @@ -76,7 +84,7 @@ export default class MrtEnvVarList extends MrtCommand { }), ); - const result = await listEnvVars( + const result = await this.listEnvVars( { projectSlug: project, environment, @@ -89,7 +97,7 @@ export default class MrtEnvVarList extends MrtCommand { if (result.variables.length === 0) { this.log(t('commands.mrt.env.var.list.empty', 'No environment variables found.')); } else { - createTable(COLUMNS).render(result.variables, DEFAULT_COLUMNS); + this.renderTable(result.variables); } } diff --git a/packages/b2c-cli/src/commands/mrt/env/var/set.ts b/packages/b2c-cli/src/commands/mrt/env/var/set.ts index 6744ebd..41f0ded 100644 --- a/packages/b2c-cli/src/commands/mrt/env/var/set.ts +++ b/packages/b2c-cli/src/commands/mrt/env/var/set.ts @@ -83,7 +83,7 @@ export default class MrtEnvVarSet extends MrtCommand { this.error(t('commands.mrt.env.var.set.noVariables', 'No environment variables provided. Use KEY=value format.')); } - await setEnvVars( + await this.setEnvVars( { projectSlug: project, environment, @@ -113,4 +113,8 @@ export default class MrtEnvVarSet extends MrtCommand { return {variables, project, environment}; } + + protected async setEnvVars(input: Parameters[0], auth: Parameters[1]) { + return setEnvVars(input, auth); + } } diff --git a/packages/b2c-cli/src/commands/mrt/push.ts b/packages/b2c-cli/src/commands/mrt/push.ts index a0f22de..1268b75 100644 --- a/packages/b2c-cli/src/commands/mrt/push.ts +++ b/packages/b2c-cli/src/commands/mrt/push.ts @@ -76,6 +76,10 @@ export default class MrtPush extends MrtCommand { }), }; + protected async pushBundle(input: Parameters[0], auth: Parameters[1]) { + return pushBundle(input, auth); + } + async run(): Promise { this.requireMrtCredentials(); @@ -107,7 +111,7 @@ export default class MrtPush extends MrtCommand { } try { - const result = await pushBundle( + const result = await this.pushBundle( { projectSlug: project, target, diff --git a/packages/b2c-cli/src/commands/webdav/get.ts b/packages/b2c-cli/src/commands/webdav/get.ts index 90edc33..136b5b2 100644 --- a/packages/b2c-cli/src/commands/webdav/get.ts +++ b/packages/b2c-cli/src/commands/webdav/get.ts @@ -3,7 +3,7 @@ * 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 * as fs from 'node:fs'; +import fs from 'node:fs'; import {basename, resolve} from 'node:path'; import {Args, Flags} from '@oclif/core'; import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; diff --git a/packages/b2c-cli/src/commands/webdav/put.ts b/packages/b2c-cli/src/commands/webdav/put.ts index 488c6ef..cba3327 100644 --- a/packages/b2c-cli/src/commands/webdav/put.ts +++ b/packages/b2c-cli/src/commands/webdav/put.ts @@ -3,7 +3,7 @@ * 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 * as fs from 'node:fs'; +import fs from 'node:fs'; import {basename, extname, resolve} from 'node:path'; import {Args} from '@oclif/core'; import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; diff --git a/packages/b2c-cli/src/commands/webdav/rm.ts b/packages/b2c-cli/src/commands/webdav/rm.ts index 0709ce7..d665066 100644 --- a/packages/b2c-cli/src/commands/webdav/rm.ts +++ b/packages/b2c-cli/src/commands/webdav/rm.ts @@ -3,7 +3,7 @@ * 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 * as readline from 'node:readline'; +import readline from 'node:readline'; import {Args, Flags} from '@oclif/core'; import {WebDavCommand} from '@salesforce/b2c-tooling-sdk/cli'; import {t} from '../../i18n/index.js'; diff --git a/packages/b2c-cli/test/commands/auth/token.test.ts b/packages/b2c-cli/test/commands/auth/token.test.ts new file mode 100644 index 0000000..7f73bb4 --- /dev/null +++ b/packages/b2c-cli/test/commands/auth/token.test.ts @@ -0,0 +1,74 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import {ux} from '@oclif/core'; +import AuthToken from '../../../src/commands/auth/token.js'; +import {createIsolatedConfigHooks} from '../../helpers/test-setup.js'; + +describe('auth token', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + function createCommand(): any { + return new AuthToken([], hooks.getConfig()); + } + + it('returns structured JSON in JSON mode', async () => { + const command = createCommand(); + + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + + const strategy = { + getTokenResponse: sinon.stub().resolves({ + accessToken: 'token123', + expires: new Date('2025-01-01T00:00:00.000Z'), + scopes: ['scope1'], + }), + }; + + sinon.stub(command, 'getOAuthStrategy').returns(strategy); + + const result = await command.run(); + + expect(strategy.getTokenResponse.calledOnce).to.equal(true); + expect(result.accessToken).to.equal('token123'); + expect(result.scopes).to.have.lengthOf(1); + }); + + it('prints raw token in non-JSON mode', async () => { + const command = createCommand(); + + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + const strategy = { + getTokenResponse: sinon.stub().resolves({ + accessToken: 'token123', + expires: new Date('2025-01-01T00:00:00.000Z'), + scopes: [], + }), + }; + + sinon.stub(command, 'getOAuthStrategy').returns(strategy); + + await command.run(); + + expect(stdoutStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/code/activate.test.ts b/packages/b2c-cli/test/commands/code/activate.test.ts new file mode 100644 index 0000000..aebb9be --- /dev/null +++ b/packages/b2c-cli/test/commands/code/activate.test.ts @@ -0,0 +1,132 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeActivate from '../../../src/commands/code/activate.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code activate', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(CodeActivate, hooks.getConfig(), flags, args); + } + + it('activates when --reload is not set', async () => { + const command: any = await createCommand({}, {codeVersion: 'v1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const patchStub = sinon.stub().resolves({data: {}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + PATCH: patchStub, + GET: sinon.stub().rejects(new Error('Unexpected ocapi.GET')), + }, + })); + + await command.run(); + + expect(patchStub.calledOnce).to.equal(true); + expect(patchStub.getCall(0).args[0]).to.equal('/code_versions/{code_version_id}'); + }); + + it('errors when no code version is provided for activate mode', async () => { + const command: any = await createCommand({}, {}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('reloads the active code version when --reload is set and no arg is provided', async () => { + const command: any = await createCommand({reload: true}, {}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const getStub = sinon.stub().resolves({ + data: { + data: [ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ], + }, + error: undefined, + }); + + const patchStub = sinon.stub().resolves({data: {}, error: undefined}); + + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + GET: getStub, + PATCH: patchStub, + }, + })); + + await command.run(); + + expect(getStub.calledOnce).to.equal(true); + expect(patchStub.callCount).to.equal(2); + }); + + it('calls command.error when reload fails with an error message', async () => { + const command: any = await createCommand({reload: true}, {codeVersion: 'v1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const getStub = sinon.stub().resolves({ + data: { + data: [{id: 'v1', active: true}], + }, + error: undefined, + }); + + const patchStub = sinon.stub().resolves({data: {}, error: {message: 'boom'}}); + + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + GET: getStub, + PATCH: patchStub, + }, + })); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/code/delete.test.ts b/packages/b2c-cli/test/commands/code/delete.test.ts new file mode 100644 index 0000000..62d1404 --- /dev/null +++ b/packages/b2c-cli/test/commands/code/delete.test.ts @@ -0,0 +1,85 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeDelete from '../../../src/commands/code/delete.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code delete', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(CodeDelete, hooks.getConfig(), flags, args); + } + + it('deletes without prompting when --force is set', async () => { + const command: any = await createCommand({force: true}, {codeVersion: 'v1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const deleteStub = sinon.stub().resolves({data: {}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + DELETE: deleteStub, + }, + })); + + await command.run(); + expect(deleteStub.calledOnce).to.equal(true); + }); + + it('does not delete when prompt is declined', async () => { + const command: any = await createCommand({}, {codeVersion: 'v1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const deleteStub = sinon.stub().rejects(new Error('Unexpected delete')); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + DELETE: deleteStub, + }, + })); + + const confirmStub = sinon.stub(command, 'confirm').resolves(false); + + await command.run(); + + expect(confirmStub.calledOnce).to.equal(true); + expect(deleteStub.called).to.equal(false); + }); + + it('deletes when prompt is accepted', async () => { + const command: any = await createCommand({}, {codeVersion: 'v1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const deleteStub = sinon.stub().resolves({data: {}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + DELETE: deleteStub, + }, + })); + + const confirmStub = sinon.stub(command, 'confirm').resolves(true); + + await command.run(); + + expect(confirmStub.calledOnce).to.equal(true); + expect(deleteStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/code/deploy.test.ts b/packages/b2c-cli/test/commands/code/deploy.test.ts new file mode 100644 index 0000000..02fe3b0 --- /dev/null +++ b/packages/b2c-cli/test/commands/code/deploy.test.ts @@ -0,0 +1,134 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeDeploy from '../../../src/commands/code/deploy.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code deploy', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(CodeDeploy, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: 'v1'}})); + sinon.stub(command, 'instance').get(() => ({config: {hostname: 'example.com', codeVersion: 'v1'}})); + } + + it('runs before hooks and returns early when skipped', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: true, skipReason: 'by plugin'}); + sinon.stub(command, 'runAfterHooks').rejects(new Error('Unexpected after hooks')); + sinon.stub(command, 'findCartridgesWithProviders').rejects(new Error('Unexpected cartridge discovery')); + + const result = await command.run(); + + expect(result).to.deep.equal({cartridges: [], codeVersion: 'v1', reloaded: false}); + }); + + it('errors when no cartridges are found', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'findCartridgesWithProviders').resolves([]); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('calls delete + upload and reload when flags are set', async () => { + const command: any = await createCommand({delete: true, reload: true}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + const afterHooksStub = sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const cartridges = [{name: 'c1', src: '/tmp/c1', dest: 'c1'}]; + sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); + + const deleteStub = sinon.stub(command, 'deleteCartridges').resolves(void 0); + const uploadStub = sinon.stub(command, 'uploadCartridges').resolves(void 0); + const reloadStub = sinon.stub(command, 'reloadCodeVersion').resolves(void 0); + + const result = await command.run(); + + expect(deleteStub.calledOnceWithExactly(cartridges)).to.equal(true); + expect(uploadStub.calledOnceWithExactly(cartridges)).to.equal(true); + expect(reloadStub.calledOnceWithExactly('v1')).to.equal(true); + + expect(result).to.deep.include({codeVersion: 'v1', reloaded: true}); + expect(afterHooksStub.calledOnce).to.equal(true); + }); + + it('swallows reload errors and still succeeds', async () => { + const command: any = await createCommand({reload: true}, {cartridgePath: '.'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const cartridges = [{name: 'c1', src: '/tmp/c1', dest: 'c1'}]; + sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); + + sinon.stub(command, 'uploadCartridges').resolves(void 0); + sinon.stub(command, 'reloadCodeVersion').rejects(new Error('reload failed')); + + const result = await command.run(); + + expect(result.reloaded).to.equal(false); + }); + + it('uses active code version when resolvedConfig is missing codeVersion', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'warn').returns(void 0); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: undefined}})); + + const instanceConfig: any = {hostname: 'example.com', codeVersion: undefined}; + sinon.stub(command, 'instance').get(() => ({config: instanceConfig})); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + sinon.stub(command, 'getActiveCodeVersion').resolves({id: 'active', active: true}); + + const cartridges = [{name: 'c1', src: '/tmp/c1', dest: 'c1'}]; + sinon.stub(command, 'findCartridgesWithProviders').resolves(cartridges); + sinon.stub(command, 'uploadCartridges').resolves(void 0); + + const result = await command.run(); + + expect(instanceConfig.codeVersion).to.equal('active'); + expect(result.codeVersion).to.equal('active'); + }); +}); diff --git a/packages/b2c-cli/test/commands/code/list.test.ts b/packages/b2c-cli/test/commands/code/list.test.ts new file mode 100644 index 0000000..2fa4c8c --- /dev/null +++ b/packages/b2c-cli/test/commands/code/list.test.ts @@ -0,0 +1,70 @@ +/* + * 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 {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeList from '../../../src/commands/code/list.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code list', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record) { + return createTestCommand(CodeList, hooks.getConfig(), flags, {}); + } + + it('returns data in json mode', async () => { + const command: any = await createCommand({json: true}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(true); + + const getStub = sinon.stub().resolves({data: {data: [{id: 'v1', active: true}]}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + GET: getStub, + }, + })); + + const uxStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(result.total).to.equal(1); + expect(uxStub.called).to.equal(false); + }); + + it('prints a message when no code versions are returned in non-json mode', async () => { + const command: any = await createCommand({}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(false); + + const getStub = sinon.stub().resolves({data: {data: []}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ + ocapi: { + GET: getStub, + }, + })); + + const uxStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(result.total).to.equal(0); + expect(uxStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/code/watch.test.ts b/packages/b2c-cli/test/commands/code/watch.test.ts new file mode 100644 index 0000000..781e1eb --- /dev/null +++ b/packages/b2c-cli/test/commands/code/watch.test.ts @@ -0,0 +1,75 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import CodeWatch from '../../../src/commands/code/watch.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('code watch', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(CodeWatch, hooks.getConfig(), flags, args); + } + + it('stops watcher on SIGINT', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: 'v1'}})); + + const stopStub = sinon.stub().resolves(void 0); + sinon.stub(command, 'watchCartridges').resolves({cartridges: [{name: 'c1'}], stop: stopStub}); + + const logStub = sinon.stub(command, 'log').returns(void 0); + + const handlers: Record void> = {}; + sinon.stub(process, 'on').callsFake(((event: string, handler: () => void) => { + handlers[event] = handler; + return process; + }) as any); + + const runPromise = command.run(); + + await Promise.resolve(); + + expect(handlers.SIGINT).to.be.a('function'); + handlers.SIGINT(); + + await runPromise; + + expect(stopStub.calledOnce).to.equal(true); + expect(logStub.called).to.equal(true); + }); + + it('calls command.error when watcher setup fails', async () => { + const command: any = await createCommand({}, {cartridgePath: '.'}); + + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com', codeVersion: 'v1'}})); + + sinon.stub(command, 'watchCartridges').rejects(new Error('boom')); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/docs/download.test.ts b/packages/b2c-cli/test/commands/docs/download.test.ts new file mode 100644 index 0000000..4eb68c5 --- /dev/null +++ b/packages/b2c-cli/test/commands/docs/download.test.ts @@ -0,0 +1,57 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import DocsDownload from '../../../src/commands/docs/download.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('docs download', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(DocsDownload, hooks.getConfig(), flags, args); + } + + it('calls downloadDocs with outputDir and keepArchive', async () => { + const command: any = await createCommand({'keep-archive': true}, {output: './docs'}); + + sinon.stub(command, 'requireServer').returns(void 0); + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const downloadStub = sinon + .stub(command, 'downloadDocs') + .resolves({outputPath: './docs', fileCount: 1, archivePath: './docs/a.zip'}); + + const result = await command.run(); + + expect(downloadStub.calledOnce).to.equal(true); + expect(downloadStub.getCall(0).args[0]).to.deep.equal({outputDir: './docs', keepArchive: true}); + expect(result.fileCount).to.equal(1); + }); + + it('returns result directly in json mode', async () => { + const command: any = await createCommand({json: true}, {output: './docs'}); + + sinon.stub(command, 'requireServer').returns(void 0); + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(command, 'downloadDocs').resolves({outputPath: './docs', fileCount: 2}); + + const result = await command.run(); + + expect(result.fileCount).to.equal(2); + }); +}); diff --git a/packages/b2c-cli/test/commands/docs/read.test.ts b/packages/b2c-cli/test/commands/docs/read.test.ts new file mode 100644 index 0000000..d22e8f5 --- /dev/null +++ b/packages/b2c-cli/test/commands/docs/read.test.ts @@ -0,0 +1,68 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import DocsRead from '../../../src/commands/docs/read.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('docs read', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(DocsRead, hooks.getConfig(), flags, args); + } + + it('errors when no match is found', async () => { + const command: any = await createCommand({}, {query: 'Nope'}); + + sinon.stub(command, 'readDocByQuery').returns(null); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('writes raw markdown when --raw is set', async () => { + const command: any = await createCommand({raw: true}, {query: 'ProductMgr'}); + + sinon.stub(command, 'readDocByQuery').returns({entry: {id: 'x', title: 't', filePath: 'x.md'}, content: '# Hello'}); + sinon.stub(command, 'jsonEnabled').returns(false); + + const writeStub = sinon.stub(process.stdout, 'write'); + + const result = await command.run(); + + expect(writeStub.calledOnceWithExactly('# Hello')).to.equal(true); + expect(result.entry.id).to.equal('x'); + }); + + it('returns data without writing to stdout in json mode', async () => { + const command: any = await createCommand({json: true}, {query: 'ProductMgr'}); + + const readStub = sinon + .stub(command, 'readDocByQuery') + .returns({entry: {id: 'x', title: 't', filePath: 'x.md'}, content: '# Hello'}); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(readStub.calledOnceWithExactly('ProductMgr')).to.equal(true); + expect(result.entry.id).to.equal('x'); + }); +}); diff --git a/packages/b2c-cli/test/commands/docs/schema.test.ts b/packages/b2c-cli/test/commands/docs/schema.test.ts new file mode 100644 index 0000000..22af96d --- /dev/null +++ b/packages/b2c-cli/test/commands/docs/schema.test.ts @@ -0,0 +1,63 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import DocsSchema from '../../../src/commands/docs/schema.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('docs schema', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(DocsSchema, hooks.getConfig(), flags, args); + } + + it('lists schemas in json mode', async () => { + const command: any = await createCommand({list: true, json: true}, {}); + + sinon.stub(command, 'listSchemas').returns([{id: 'a', title: 'a', filePath: 'a.xsd'}]); + + const result = await command.run(); + + expect(result.entries).to.have.length(1); + }); + + it('errors when query is missing in read mode', async () => { + const command: any = await createCommand({}, {}); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('writes schema content in non-json mode', async () => { + const command: any = await createCommand({}, {query: 'catalog'}); + + sinon.stub(command, 'jsonEnabled').returns(false); + sinon + .stub(command, 'readSchemaByQuery') + .returns({entry: {id: 'catalog', title: 't', filePath: 'c.xsd'}, content: ''}); + + const writeStub = sinon.stub(process.stdout, 'write'); + + await command.run(); + + expect(writeStub.calledOnceWithExactly('')).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/docs/search.test.ts b/packages/b2c-cli/test/commands/docs/search.test.ts new file mode 100644 index 0000000..ce483f6 --- /dev/null +++ b/packages/b2c-cli/test/commands/docs/search.test.ts @@ -0,0 +1,73 @@ +/* + * 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 {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import DocsSearch from '../../../src/commands/docs/search.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('docs search', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(DocsSearch, hooks.getConfig(), flags, args); + } + + it('errors when query is missing in search mode', async () => { + const command: any = await createCommand({}, {}); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('lists docs in json mode', async () => { + const command: any = await createCommand({list: true, json: true}, {}); + + sinon.stub(command, 'listDocs').returns([{id: 'a', title: 'A', filePath: 'a.md'}]); + + const result = await command.run(); + + expect(result.entries).to.have.length(1); + }); + + it('prints no results message when search returns empty in non-json mode', async () => { + const command: any = await createCommand({limit: 5}, {query: 'x'}); + + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'searchDocs').returns([]); + + const stdoutStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(result.results).to.have.length(0); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + it('returns results in json mode', async () => { + const command: any = await createCommand({json: true, limit: 5}, {query: 'x'}); + + sinon.stub(command, 'searchDocs').returns([{entry: {id: 'a', title: 'A', filePath: 'a.md'}, score: 0.1}]); + + const result = await command.run(); + + expect(result.results).to.have.length(1); + }); +}); diff --git a/packages/b2c-cli/test/commands/job/export.test.ts b/packages/b2c-cli/test/commands/job/export.test.ts new file mode 100644 index 0000000..7946e20 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/export.test.ts @@ -0,0 +1,158 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobExport from '../../../src/commands/job/export.js'; +import {JobExecutionError} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('job export', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record) { + return createTestCommand(JobExport, hooks.getConfig(), flags, {}); + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ + operationType, + metadata, + startTime: Date.now(), + })); + } + + it('errors when no data units are provided', async () => { + const command: any = await createCommand({output: './export'}); + stubCommon(command); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('errors on invalid --data-units json', async () => { + const command: any = await createCommand({'data-units': '{not json'}); + stubCommon(command); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('calls export operation and passes derived dataUnits', async () => { + const command: any = await createCommand({ + output: './export', + 'global-data': 'meta_data', + timeout: 1, + json: true, + }); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const exportStub = sinon.stub(command, 'siteArchiveExportToPath').resolves({ + execution: {execution_status: 'finished', exit_status: {code: 'OK'}, duration: 1000} as any, + archiveFilename: 'a.zip', + archiveKept: false, + localPath: './export/a.zip', + }); + + const result = await command.run(); + + expect(exportStub.calledOnce).to.equal(true); + const args = exportStub.getCall(0).args; + expect(args[1]).to.equal('./export'); + expect(result.archiveFilename).to.equal('a.zip'); + }); + + it('returns early when before hooks skip', async () => { + const command: any = await createCommand({'global-data': 'meta_data'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: true, skipReason: 'by plugin'}); + const exportStub = sinon.stub(command, 'siteArchiveExportToPath').rejects(new Error('Unexpected export')); + + const result = await command.run(); + + expect(exportStub.called).to.equal(false); + expect(result.execution.exit_status.code).to.equal('skipped'); + }); + + it('passes keepArchive when --no-download is set', async () => { + const command: any = await createCommand({ + output: './export', + 'global-data': 'meta_data', + 'no-download': true, + 'zip-only': true, + json: true, + }); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const exportStub = sinon.stub(command, 'siteArchiveExportToPath').resolves({ + execution: {execution_status: 'finished', exit_status: {code: 'OK'}} as any, + archiveFilename: 'a.zip', + archiveKept: true, + }); + + await command.run(); + + const options = exportStub.getCall(0).args[2]; + expect(options.keepArchive).to.equal(true); + expect(options.extractZip).to.equal(false); + }); + + it('shows job log and errors on JobExecutionError when show-log is true', async () => { + const command: any = await createCommand({'global-data': 'meta_data', json: true}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + sinon.stub(command, 'showJobLog').resolves(void 0); + + const exec: any = {execution_status: 'finished', exit_status: {code: 'ERROR'}}; + const error = new JobExecutionError('failed', exec); + sinon.stub(command, 'siteArchiveExportToPath').rejects(error); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.called).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/job/import.test.ts b/packages/b2c-cli/test/commands/job/import.test.ts new file mode 100644 index 0000000..d4b9906 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/import.test.ts @@ -0,0 +1,111 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobImport from '../../../src/commands/job/import.js'; +import {JobExecutionError} from '@salesforce/b2c-tooling-sdk/operations/jobs'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('job import', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobImport, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'requireWebDavCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ + operationType, + metadata, + startTime: Date.now(), + })); + } + + it('imports remote filename when --remote is set', async () => { + const command: any = await createCommand({remote: true, json: true}, {target: 'a.zip'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const importStub = sinon.stub(command, 'siteArchiveImport').resolves({ + execution: {execution_status: 'finished', exit_status: {code: 'OK'}} as any, + archiveFilename: 'a.zip', + archiveKept: false, + }); + + await command.run(); + + expect(importStub.calledOnce).to.equal(true); + expect(importStub.getCall(0).args[0]).to.deep.equal({remoteFilename: 'a.zip'}); + }); + + it('imports local target when --remote is not set', async () => { + const command: any = await createCommand({json: true}, {target: './dir'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const importStub = sinon.stub(command, 'siteArchiveImport').resolves({ + execution: {execution_status: 'finished', exit_status: {code: 'OK'}} as any, + archiveFilename: 'a.zip', + archiveKept: false, + }); + + await command.run(); + + expect(importStub.calledOnce).to.equal(true); + expect(importStub.getCall(0).args[0]).to.equal('./dir'); + }); + + it('returns early when before hooks skip', async () => { + const command: any = await createCommand({json: true}, {target: './dir'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: true, skipReason: 'by plugin'}); + const importStub = sinon.stub(command, 'siteArchiveImport').rejects(new Error('Unexpected import')); + + const result = await command.run(); + + expect(importStub.called).to.equal(false); + expect(result.execution.exit_status.code).to.equal('skipped'); + }); + + it('shows job log and errors on JobExecutionError when show-log is true', async () => { + const command: any = await createCommand({json: true}, {target: './dir'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + sinon.stub(command, 'showJobLog').resolves(void 0); + + const exec: any = {execution_status: 'finished', exit_status: {code: 'ERROR'}}; + const error = new JobExecutionError('failed', exec); + sinon.stub(command, 'siteArchiveImport').rejects(error); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.called).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts new file mode 100644 index 0000000..ab5a8b8 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -0,0 +1,139 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobRun from '../../../src/commands/job/run.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('job run', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobRun, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'createContext').callsFake((operationType: any, metadata: any) => ({ + operationType, + metadata, + startTime: Date.now(), + })); + } + + it('errors on invalid -P param format', async () => { + const command: any = await createCommand({param: ['bad'], json: true}, {jobId: 'my-job'}); + stubCommon(command); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('executes without waiting when --wait is false', async () => { + const command: any = await createCommand({param: ['A=1'], json: true}, {jobId: 'my-job'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + const execStub = sinon.stub(command, 'executeJob').resolves({id: 'e1', execution_status: 'running'}); + const waitStub = sinon.stub(command, 'waitForJob').rejects(new Error('Unexpected wait')); + + const result = await command.run(); + + expect(execStub.calledOnce).to.equal(true); + expect(waitStub.called).to.equal(false); + expect(result.id).to.equal('e1'); + }); + + it('waits when --wait is true', async () => { + const command: any = await createCommand({wait: true, timeout: 1, json: true}, {jobId: 'my-job'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + + sinon.stub(command, 'executeJob').resolves({id: 'e1', execution_status: 'running'}); + const waitStub = sinon.stub(command, 'waitForJob').resolves({id: 'e1', execution_status: 'finished'}); + + const result = await command.run(); + + expect(waitStub.calledOnce).to.equal(true); + expect(result.execution_status).to.equal('finished'); + }); + + it('returns early when before hooks skip', async () => { + const command: any = await createCommand({json: true}, {jobId: 'my-job'}); + stubCommon(command); + + sinon.stub(command, 'runBeforeHooks').resolves({skip: true, skipReason: 'by plugin'}); + + const result = await command.run(); + + expect(result.exit_status.code).to.equal('skipped'); + }); + + it('errors on invalid --body JSON', async () => { + const command: any = await createCommand({body: '{bad', json: true}, {jobId: 'my-job'}); + stubCommon(command); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.calledOnce).to.equal(true); + }); + + it('shows job log and errors on JobExecutionError when waiting and show-log is true', async () => { + const command: any = await createCommand({wait: true, json: true, 'show-log': true}, {jobId: 'my-job'}); + stubCommon(command); + + command.flags = {...command.flags, wait: true, json: true, 'show-log': true}; + + sinon.stub(command, 'runBeforeHooks').resolves({skip: false}); + sinon.stub(command, 'runAfterHooks').resolves(void 0); + sinon.stub(command, 'executeJob').resolves({id: 'e1', execution_status: 'running'}); + sinon.stub(command, 'showJobLog').resolves(void 0); + + const exec: any = {execution_status: 'finished', exit_status: {code: 'ERROR'}}; + const {JobExecutionError} = await import('@salesforce/b2c-tooling-sdk/operations/jobs'); + const jobError = new JobExecutionError('failed', exec); + expect(jobError).to.be.instanceOf(JobExecutionError); + sinon.stub(command, 'waitForJob').rejects(jobError); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + // expected + } + + expect(errorStub.called).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/job/search.test.ts b/packages/b2c-cli/test/commands/job/search.test.ts new file mode 100644 index 0000000..402a4c5 --- /dev/null +++ b/packages/b2c-cli/test/commands/job/search.test.ts @@ -0,0 +1,59 @@ +/* + * 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 {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobSearch from '../../../src/commands/job/search.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('job search', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobSearch, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'log').returns(void 0); + } + + it('returns results in json mode', async () => { + const command: any = await createCommand({json: true}, {}); + stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(true); + + const searchStub = sinon.stub(command, 'searchJobExecutions').resolves({total: 1, hits: [{id: 'e1'}]}); + const uxStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(searchStub.calledOnce).to.equal(true); + expect(uxStub.called).to.equal(false); + expect(result.total).to.equal(1); + }); + + it('prints no results in non-json mode', async () => { + const command: any = await createCommand({}, {}); + stubCommon(command); + sinon.stub(command, 'jsonEnabled').returns(false); + + sinon.stub(command, 'searchJobExecutions').resolves({total: 0, hits: []}); + const uxStub = sinon.stub(ux, 'stdout'); + + const result = await command.run(); + + expect(result.total).to.equal(0); + expect(uxStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/job/wait.test.ts b/packages/b2c-cli/test/commands/job/wait.test.ts new file mode 100644 index 0000000..b5c172c --- /dev/null +++ b/packages/b2c-cli/test/commands/job/wait.test.ts @@ -0,0 +1,38 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import JobWait from '../../../src/commands/job/wait.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('job wait', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(JobWait, hooks.getConfig(), flags, args); + } + + it('waits using wrapper without real polling', async () => { + const command: any = await createCommand({'poll-interval': 1, json: true}, {jobId: 'my-job', executionId: 'e1'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + + const waitStub = sinon.stub(command, 'waitForJob').resolves({id: 'e1', execution_status: 'finished'}); + + const result = await command.run(); + + expect(waitStub.calledOnce).to.equal(true); + expect(result.id).to.equal('e1'); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/create.test.ts b/packages/b2c-cli/test/commands/mrt/env/create.test.ts new file mode 100644 index 0000000..4ef958f --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/create.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtEnvCreate from '../../../../src/commands/mrt/env/create.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('mrt env create', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtEnvCreate([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('calls createEnv with parsed proxy flags and returns JSON result', async () => { + const command = createCommand(); + + stubParse( + command, + { + project: 'my-project', + name: 'My Env', + region: 'eu-west-1', + production: true, + hostname: 'foo', + 'external-hostname': 'www.example.com', + 'external-domain': 'example.com', + 'allow-cookies': true, + 'enable-source-maps': true, + proxy: ['api=api.example.com', 'ocapi=ocapi.example.com'], + wait: false, + }, + {slug: 'staging'}, + ); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtOrigin: 'https://example.com'}})); + + const createStub = sinon.stub(command, 'createEnv').resolves({ + slug: 'staging', + name: 'My Env', + state: 'creating', + is_production: true, + } as any); + + const result = await command.run(); + + expect(createStub.calledOnce).to.equal(true); + const [input] = createStub.firstCall.args; + expect(input.projectSlug).to.equal('my-project'); + expect(input.slug).to.equal('staging'); + expect(input.name).to.equal('My Env'); + expect(input.isProduction).to.equal(true); + expect(input.proxyConfigs).to.have.lengthOf(2); + expect(result.slug).to.equal('staging'); + }); + + it('when --wait is set, calls waitForEnv (no onPoll simulation)', async () => { + const command = createCommand(); + + stubParse(command, {project: 'my-project', wait: true}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtOrigin: 'https://example.com'}})); + + sinon.stub(command, 'createEnv').resolves({slug: 'staging', name: 'staging', is_production: false} as any); + + const waitStub = sinon.stub(command, 'waitForEnv').resolves({ + slug: 'staging', + name: 'staging', + state: 'ready', + is_production: false, + } as any); + + await command.run(); + + expect(waitStub.calledOnce).to.equal(true); + }); + + it('calls command.error when proxy flag has invalid format', async () => { + const command = createCommand(); + + stubParse(command, {project: 'my-project', proxy: ['INVALID']}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project'}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/delete.test.ts b/packages/b2c-cli/test/commands/mrt/env/delete.test.ts new file mode 100644 index 0000000..651dac6 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/delete.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtEnvDelete from '../../../../src/commands/mrt/env/delete.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('mrt env delete', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtEnvDelete([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {force: true}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('deletes without prompt when --force is set', async () => { + const command = createCommand(); + + stubParse(command, {force: true}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtOrigin: 'https://example.com'}})); + + const deleteStub = sinon.stub(command, 'deleteEnv').resolves(void 0); + + const result = await command.run(); + + expect(deleteStub.calledOnce).to.equal(true); + expect(result.slug).to.equal('staging'); + }); + + it('skips confirmation prompt in JSON mode when --force is not set', async () => { + const command = createCommand(); + + stubParse(command, {force: false}, {slug: 'staging'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project'}})); + + const deleteStub = sinon.stub(command, 'deleteEnv').resolves(void 0); + + await command.run(); + + expect(deleteStub.calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/var/delete.test.ts b/packages/b2c-cli/test/commands/mrt/env/var/delete.test.ts new file mode 100644 index 0000000..1effe8d --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/var/delete.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtEnvVarDelete from '../../../../../src/commands/mrt/env/var/delete.js'; +import {isolateConfig, restoreConfig} from '../../../../helpers/config-isolation.js'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +describe('mrt env var delete', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtEnvVarDelete([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {key: 'MY_VAR'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('calls command.error when environment is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {key: 'MY_VAR'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: undefined}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('deletes env var via SDK wrapper', async () => { + const command = createCommand(); + + stubParse(command, {}, {key: 'MY_VAR'}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: { + mrtProject: 'my-project', + mrtEnvironment: 'staging', + mrtOrigin: 'https://example.com', + }, + })); + + const delStub = sinon.stub(command, 'deleteEnvVar').resolves(void 0); + + const result = await command.run(); + + expect(delStub.calledOnce).to.equal(true); + const [input] = delStub.firstCall.args; + expect(input.projectSlug).to.equal('my-project'); + expect(input.environment).to.equal('staging'); + expect(input.key).to.equal('MY_VAR'); + expect(result.key).to.equal('MY_VAR'); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/var/list.test.ts b/packages/b2c-cli/test/commands/mrt/env/var/list.test.ts new file mode 100644 index 0000000..7be82c3 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/var/list.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtEnvVarList from '../../../../../src/commands/mrt/env/var/list.js'; +import {isolateConfig, restoreConfig} from '../../../../helpers/config-isolation.js'; +import {stubParse} from '../../../../helpers/stub-parse.js'; + +describe('mrt env var list', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtEnvVarList([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + stubParse(command, {}, {}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('returns SDK result in JSON mode', async () => { + const command = createCommand(); + + stubParse(command, {}, {}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: { + mrtProject: 'my-project', + mrtEnvironment: 'staging', + mrtOrigin: 'https://example.com', + }, + })); + + const listStub = sinon.stub(command, 'listEnvVars').resolves({variables: [{name: 'A', value: '1'}]} as any); + + const result = await command.run(); + + expect(listStub.calledOnce).to.equal(true); + expect(result.variables).to.have.lengthOf(1); + }); + + it('does not block in non-JSON mode (renderTable is stubbed)', async () => { + const command = createCommand(); + + stubParse(command, {}, {}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging'}})); + + sinon.stub(command, 'renderTable').returns(void 0); + sinon.stub(command, 'listEnvVars').resolves({variables: [{name: 'A', value: '1'}]} as any); + + await command.run(); + + expect((command.renderTable as sinon.SinonStub).calledOnce).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/env/var/set.test.ts b/packages/b2c-cli/test/commands/mrt/env/var/set.test.ts new file mode 100644 index 0000000..3d8f147 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/env/var/set.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import MrtEnvVarSet from '../../../../../src/commands/mrt/env/var/set.js'; +import {isolateConfig, restoreConfig} from '../../../../helpers/config-isolation.js'; + +describe('mrt env var set', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + function createCommand(): any { + return new MrtEnvVarSet([], config); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = createCommand(); + + sinon + .stub(command, 'parse') + .resolves({argv: ['A=1'], args: {}, flags: {}, metadata: {}, raw: [], nonExistentFlags: {}}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined, mrtEnvironment: 'staging'}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('sets a space-containing KEY=value token as a single variable', async () => { + const command = createCommand(); + + sinon + .stub(command, 'parse') + .resolves({argv: ['MESSAGE=hello world'], args: {}, flags: {}, metadata: {}, raw: [], nonExistentFlags: {}}); + await command.init(); + + stubCommonAuth(command); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({ + values: { + mrtProject: 'my-project', + mrtEnvironment: 'production', + mrtOrigin: 'https://example.com', + }, + })); + + const setStub = sinon.stub(command, 'setEnvVars').resolves(void 0); + + const result = await command.run(); + + expect(setStub.calledOnce).to.equal(true); + const [input] = setStub.firstCall.args; + expect(input.variables.MESSAGE).to.equal('hello world'); + expect(result.variables.MESSAGE).to.equal('hello world'); + }); +}); diff --git a/packages/b2c-cli/test/commands/mrt/push.test.ts b/packages/b2c-cli/test/commands/mrt/push.test.ts new file mode 100644 index 0000000..d1b0d81 --- /dev/null +++ b/packages/b2c-cli/test/commands/mrt/push.test.ts @@ -0,0 +1,103 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import MrtPush from '../../../src/commands/mrt/push.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('mrt push', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}): Promise { + return createTestCommand(MrtPush, hooks.getConfig(), flags, args); + } + + function stubErrorToThrow(command: any): sinon.SinonStub { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + function stubCommonAuth(command: any): void { + sinon.stub(command, 'requireMrtCredentials').returns(void 0); + sinon.stub(command, 'getMrtAuth').returns({} as any); + } + + it('calls command.error when project is missing', async () => { + const command = await createCommand(); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: undefined}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('parses --ssr-param and --node-version and calls SDK wrapper', async () => { + const command = await createCommand( + { + project: 'my-project', + environment: 'staging', + 'build-dir': 'dist', + 'ssr-only': 'ssr.js', + 'ssr-shared': 'static/**/*', + 'node-version': '20.x', + 'ssr-param': ['SSRProxyPath=/api', 'Foo=bar'], + }, + {}, + ); + + stubCommonAuth(command); + sinon + .stub(command, 'resolvedConfig') + .get(() => ({values: {mrtProject: 'my-project', mrtEnvironment: 'staging', mrtOrigin: 'https://example.com'}})); + sinon.stub(command, 'log').returns(void 0); + + const pushStub = sinon.stub(command, 'pushBundle').resolves({ + bundleId: 1, + deployed: true, + message: 'ok', + projectSlug: 'my-project', + target: 'staging', + } as any); + + const result = await command.run(); + + expect(pushStub.calledOnce).to.equal(true); + const [input] = pushStub.firstCall.args; + expect(input.projectSlug).to.equal('my-project'); + expect(input.target).to.equal('staging'); + expect(input.buildDirectory).to.equal('dist'); + expect(input.ssrParameters.SSRProxyPath).to.equal('/api'); + expect(input.ssrParameters.Foo).to.equal('bar'); + expect(input.ssrParameters.SSRFunctionNodeVersion).to.equal('20.x'); + expect(result.bundleId).to.equal(1); + }); + + it('calls command.error when ssr-param is invalid', async () => { + const command = await createCommand({project: 'my-project', 'ssr-param': ['INVALID']}, {}); + + stubCommonAuth(command); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {mrtProject: 'my-project'}})); + + try { + await command.run(); + expect.fail('Expected error'); + } catch (error) { + expect(error).to.be.instanceOf(Error); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/ods/create.test.ts b/packages/b2c-cli/test/commands/ods/create.test.ts index 40c2e5d..26f6152 100644 --- a/packages/b2c-cli/test/commands/ods/create.test.ts +++ b/packages/b2c-cli/test/commands/ods/create.test.ts @@ -4,15 +4,46 @@ * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -/* eslint-disable @typescript-eslint/no-explicit-any, unicorn/consistent-function-scoping */ import {expect} from 'chai'; +import sinon from 'sinon'; import OdsCreate from '../../../src/commands/ods/create.js'; -import { - makeCommandThrowOnError, - stubCommandConfigAndLogger, - stubOdsClient, - stubResolvedConfig, -} from '../../helpers/ods.js'; +import {isolateConfig, restoreConfig} from '../../helpers/config-isolation.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function stubResolvedConfig(command: any, resolvedConfig: Record): void { + Object.defineProperty(command, 'resolvedConfig', { + get: () => ({values: resolvedConfig}), + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS create command CLI logic. @@ -20,6 +51,15 @@ import { * SDK tests cover the actual API calls. */ describe('ods create', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + describe('buildSettings', () => { it('should return undefined when set-permissions is false', () => { const command = new OdsCreate([], {} as any); @@ -284,9 +324,13 @@ describe('ods create', () => { it('should timeout if sandbox never reaches terminal state', async () => { const command = setupCreateCommand(); + sinon.stub(command as any, 'sleep').resolves(undefined); + sinon.stub(Date, 'now').onFirstCall().returns(0).returns(1001); + stubOdsClient(command, { GET: async () => ({ - data: {data: {state: 'creating'}}, + data: {data: {id: 'sb-1', state: 'creating'}}, + response: new Response(), }), }); diff --git a/packages/b2c-cli/test/commands/ods/delete.test.ts b/packages/b2c-cli/test/commands/ods/delete.test.ts index 96e5424..fbfde7d 100644 --- a/packages/b2c-cli/test/commands/ods/delete.test.ts +++ b/packages/b2c-cli/test/commands/ods/delete.test.ts @@ -5,14 +5,43 @@ */ import {expect} from 'chai'; -/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; + import OdsDelete from '../../../src/commands/ods/delete.js'; -import { - makeCommandThrowOnError, - stubCommandConfigAndLogger, - stubJsonEnabled, - stubOdsClient, -} from '../../helpers/ods.js'; +import {isolateConfig, restoreConfig} from '../../helpers/config-isolation.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS delete command CLI logic. @@ -20,6 +49,15 @@ import { * SDK tests cover the actual API calls. */ describe('ods delete', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + describe('command structure', () => { it('should require sandboxId as argument', () => { expect(OdsDelete.args).to.have.property('sandboxId'); diff --git a/packages/b2c-cli/test/commands/ods/get.test.ts b/packages/b2c-cli/test/commands/ods/get.test.ts index b0743fb..0589b03 100644 --- a/packages/b2c-cli/test/commands/ods/get.test.ts +++ b/packages/b2c-cli/test/commands/ods/get.test.ts @@ -5,14 +5,43 @@ */ import {expect} from 'chai'; -/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; + import OdsGet from '../../../src/commands/ods/get.js'; -import { - makeCommandThrowOnError, - stubJsonEnabled, - stubOdsClient, - stubCommandConfigAndLogger, -} from '../../helpers/ods.js'; +import {isolateConfig, restoreConfig} from '../../helpers/config-isolation.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS get command CLI logic. @@ -20,6 +49,15 @@ import { * SDK tests cover the actual API calls. */ describe('ods get', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + describe('command structure', () => { it('should require sandboxId as argument', () => { expect(OdsGet.args).to.have.property('sandboxId'); diff --git a/packages/b2c-cli/test/commands/ods/info.test.ts b/packages/b2c-cli/test/commands/ods/info.test.ts index 042bec4..39db474 100644 --- a/packages/b2c-cli/test/commands/ods/info.test.ts +++ b/packages/b2c-cli/test/commands/ods/info.test.ts @@ -5,14 +5,45 @@ */ import {expect} from 'chai'; -/* eslint-disable @typescript-eslint/no-explicit-any */ +import sinon from 'sinon'; + import OdsInfo from '../../../src/commands/ods/info.js'; -import { - makeCommandThrowOnError, - stubCommandConfigAndLogger, - stubJsonEnabled, - stubOdsClientGet, -} from '../../helpers/ods.js'; +import {isolateConfig, restoreConfig} from '../../helpers/config-isolation.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClientGet(command: any, handler: (path: string) => Promise): void { + Object.defineProperty(command, 'odsClient', { + value: { + GET: handler, + }, + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS info command CLI logic. @@ -20,6 +51,15 @@ import { * SDK tests cover the actual API calls. */ describe('ods info', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + describe('command structure', () => { it('should have correct description', () => { expect(OdsInfo.description).to.be.a('string'); diff --git a/packages/b2c-cli/test/commands/ods/list.test.ts b/packages/b2c-cli/test/commands/ods/list.test.ts index 599a67b..623d489 100644 --- a/packages/b2c-cli/test/commands/ods/list.test.ts +++ b/packages/b2c-cli/test/commands/ods/list.test.ts @@ -3,15 +3,44 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ -/* eslint-disable @typescript-eslint/no-explicit-any */ + import {expect} from 'chai'; +import sinon from 'sinon'; import OdsList from '../../../src/commands/ods/list.js'; -import { - makeCommandThrowOnError, - stubCommandConfigAndLogger, - stubJsonEnabled, - stubOdsClient, -} from '../../helpers/ods.js'; +import {isolateConfig, restoreConfig} from '../../helpers/config-isolation.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS list command CLI logic. @@ -19,6 +48,15 @@ import { * SDK tests cover the actual API calls. */ describe('ods list', () => { + beforeEach(() => { + isolateConfig(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + describe('getSelectedColumns', () => { it('should return default columns when no flags provided', () => { const command = new OdsList([], {} as any); diff --git a/packages/b2c-cli/test/commands/ods/operations.test.ts b/packages/b2c-cli/test/commands/ods/operations.test.ts index 61d0aa8..2c545b1 100644 --- a/packages/b2c-cli/test/commands/ods/operations.test.ts +++ b/packages/b2c-cli/test/commands/ods/operations.test.ts @@ -9,14 +9,41 @@ import {expect} from 'chai'; import OdsStart from '../../../src/commands/ods/start.js'; import OdsStop from '../../../src/commands/ods/stop.js'; -/* eslint-disable @typescript-eslint/no-explicit-any */ + import OdsRestart from '../../../src/commands/ods/restart.js'; -import { - makeCommandThrowOnError, - stubCommandConfigAndLogger, - stubJsonEnabled, - stubOdsClient, -} from '../../helpers/ods.js'; + +function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { + Object.defineProperty(command, 'config', { + value: { + findConfigFile: () => ({ + read: () => ({'sandbox-api-host': sandboxApiHost}), + }), + }, + configurable: true, + }); + + Object.defineProperty(command, 'logger', { + value: {info() {}, debug() {}, warn() {}, error() {}}, + configurable: true, + }); +} + +function stubJsonEnabled(command: any, enabled: boolean): void { + command.jsonEnabled = () => enabled; +} + +function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { + Object.defineProperty(command, 'odsClient', { + value: client, + configurable: true, + }); +} + +function makeCommandThrowOnError(command: any): void { + command.error = (msg: string) => { + throw new Error(msg); + }; +} /** * Unit tests for ODS operation commands CLI logic. diff --git a/packages/b2c-cli/test/commands/scapi/custom/status.test.ts b/packages/b2c-cli/test/commands/scapi/custom/status.test.ts index a528d21..699bad4 100644 --- a/packages/b2c-cli/test/commands/scapi/custom/status.test.ts +++ b/packages/b2c-cli/test/commands/scapi/custom/status.test.ts @@ -3,18 +3,143 @@ * 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 {runCommand} from '@oclif/test'; + import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ScapiCustomStatus from '../../../../src/commands/scapi/custom/status.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; describe('scapi custom status', () => { - it('shows help without errors', async () => { - const {error} = await runCommand('scapi custom status --help'); - expect(error).to.be.undefined; + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('calls command.error when shortCode is missing from resolved config', async () => { + const command: any = new ScapiCustomStatus([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: undefined}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } }); - it('requires tenant-id flag', async () => { - const {error} = await runCommand('scapi custom status'); - expect(error).to.not.be.undefined; - expect(error?.message).to.include('tenant-id'); + it('returns API response in JSON mode', async () => { + const command: any = new ScapiCustomStatus([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78'}})); + + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + total: 1, + activeCodeVersion: 'version1', + data: [ + { + apiName: 'MyApi', + apiVersion: 'v1', + cartridgeName: 'app_custom', + endpointPath: '/test', + httpMethod: 'get', + status: 'active', + securityScheme: 'AmOAuth2', + siteId: 'RefArch', + }, + ], + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + + expect(fetchStub.called).to.equal(true); + expect(result.total).to.equal(1); + expect(result.activeCodeVersion).to.equal('version1'); + expect(result.data).to.have.lengthOf(1); + }); + + it('passes status filter through to the request', async () => { + const command: any = new ScapiCustomStatus([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd', status: 'active'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78'}})); + + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').callsFake(async (url: Request | string | URL) => { + const requestUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url; + expect(requestUrl).to.include('status=active'); + return new Response(JSON.stringify({total: 0, data: []}), { + status: 200, + headers: {'content-type': 'application/json'}, + }); + }); + + await command.run(); + expect(fetchStub.called).to.equal(true); + }); + + it('does not block in non-JSON mode (renderEndpoints is stubbed)', async () => { + const command: any = new ScapiCustomStatus([], config); + + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78'}})); + + sinon.stub(command, 'renderEndpoints').returns(void 0); + + sinon.stub(command, 'getOAuthStrategy').returns({ + getAuthorizationHeader: async () => 'Bearer test', + }); + + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({total: 1, data: []}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(fetchStub.called).to.equal(true); + expect(result.total).to.equal(1); }); }); diff --git a/packages/b2c-cli/test/commands/sites/list.test.ts b/packages/b2c-cli/test/commands/sites/list.test.ts new file mode 100644 index 0000000..4570b3a --- /dev/null +++ b/packages/b2c-cli/test/commands/sites/list.test.ts @@ -0,0 +1,82 @@ +/* + * 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 {ux} from '@oclif/core'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import SitesList from '../../../src/commands/sites/list.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('sites list', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record = {}, args: Record = {}) { + return createTestCommand(SitesList, hooks.getConfig(), flags, args); + } + + function stubCommon(command: any, {jsonEnabled}: {jsonEnabled: boolean}) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + sinon.stub(command, 'jsonEnabled').returns(jsonEnabled); + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + it('returns data in JSON mode', async () => { + const command: any = await createCommand(); + + stubCommon(command, {jsonEnabled: true}); + + const ocapiGet = sinon.stub().resolves({data: {count: 1, data: [{id: 'site1'}]}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const result = await command.run(); + expect(result.count).to.equal(1); + expect(ocapiGet.calledOnce).to.equal(true); + }); + + it('prints "no sites" message when count is 0 in non-JSON mode', async () => { + const command: any = await createCommand(); + + stubCommon(command, {jsonEnabled: false}); + sinon.stub(command, 'log').returns(void 0); + + const ocapiGet = sinon.stub().resolves({data: {count: 0, data: []}, error: undefined}); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const stdoutStub = sinon.stub(ux, 'stdout').returns(void 0 as any); + + const result = await command.run(); + expect(result.count).to.equal(0); + expect(stdoutStub.calledOnce).to.equal(true); + }); + + it('calls command.error when ocapi returns error', async () => { + const command: any = await createCommand(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {hostname: 'example.com'}})); + + const ocapiGet = sinon.stub().resolves({data: undefined, error: {message: 'boom'}}); + sinon.stub(command, 'instance').get(() => ({ocapi: {GET: ocapiGet}})); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/create.test.ts b/packages/b2c-cli/test/commands/slas/client/create.test.ts new file mode 100644 index 0000000..11b3d3c --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/create.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientCreate from '../../../../src/commands/slas/client/create.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client create', () => { + let config: Config; + + async function createCommand(flags: Record, args: Record) { + const command: any = new SlasClientCreate([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('errors when neither --scopes nor --default-scopes is provided', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + channels: ['RefArch'], + 'redirect-uri': ['http://localhost/callback'], + 'default-scopes': false, + }, + {clientId: 'my-client'}, + ); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('creates private client and includes secret when --public is false', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + name: 'My Client', + channels: ['RefArch'], + scopes: ['sfcc.shopper-products'], + 'default-scopes': false, + 'redirect-uri': ['http://localhost/callback'], + public: false, + 'create-tenant': false, + secret: 'my-secret', + }, + {clientId: 'my-client'}, + ); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'My Client', + scopes: 'sfcc.shopper-products', + channels: ['RefArch'], + redirectUri: ['http://localhost/callback'], + isPrivateClient: true, + secret: 'my-secret', + }, + error: undefined, + response: {status: 201}, + }); + + sinon.stub(command, 'getSlasClient').returns({ + PUT: putStub, + } as any); + + sinon.stub(command, 'ensureTenantExists').resolves(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(putStub.calledOnce).to.equal(true); + const [, options] = putStub.firstCall.args as [string, any]; + expect(options.params.path.tenantId).to.equal('abcd_123'); + expect(options.params.path.clientId).to.equal('my-client'); + expect(options.body.isPrivateClient).to.equal(true); + expect(options.body.secret).to.equal('my-secret'); + + expect(result.clientId).to.equal('my-client'); + expect(result.isPrivateClient).to.equal(true); + }); + + it('creates public client and omits secret when --public is true', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + name: 'My Client', + channels: ['RefArch'], + scopes: ['sfcc.shopper-products'], + 'default-scopes': false, + 'redirect-uri': ['http://localhost/callback'], + public: true, + 'create-tenant': false, + }, + {clientId: 'my-client'}, + ); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'My Client', + scopes: 'sfcc.shopper-products', + channels: ['RefArch'], + redirectUri: ['http://localhost/callback'], + isPrivateClient: false, + }, + error: undefined, + response: {status: 201}, + }); + + sinon.stub(command, 'getSlasClient').returns({ + PUT: putStub, + } as any); + + sinon.stub(command, 'ensureTenantExists').resolves(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + const [, options] = putStub.firstCall.args as [string, any]; + expect(options.body.isPrivateClient).to.equal(false); + expect('secret' in options.body).to.equal(false); + + expect(result.clientId).to.equal('my-client'); + expect(result.isPrivateClient).to.equal(false); + }); + + it('calls ensureTenantExists when --create-tenant is true', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + name: 'My Client', + channels: ['RefArch'], + scopes: ['sfcc.shopper-products'], + 'default-scopes': false, + 'redirect-uri': ['http://localhost/callback'], + public: true, + 'create-tenant': true, + }, + {clientId: 'my-client'}, + ); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'My Client', + isPrivateClient: false, + }, + error: undefined, + response: {status: 200}, + }); + + const slasClient = {PUT: putStub} as any; + sinon.stub(command, 'getSlasClient').returns(slasClient); + + const ensureStub = sinon.stub(command, 'ensureTenantExists').resolves(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + + await command.run(); + + expect(ensureStub.calledOnce).to.equal(true); + expect(ensureStub.firstCall.args[0]).to.equal(slasClient); + expect(ensureStub.firstCall.args[1]).to.equal('abcd_123'); + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/delete.test.ts b/packages/b2c-cli/test/commands/slas/client/delete.test.ts new file mode 100644 index 0000000..c5f1baa --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/delete.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientDelete from '../../../../src/commands/slas/client/delete.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client delete', () => { + let config: Config; + + async function createCommand(flags: Record, args: Record) { + const command: any = new SlasClientDelete([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('deletes a client via SLAS API', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const delStub = sinon.stub().resolves({error: undefined}); + sinon.stub(command, 'getSlasClient').returns({DELETE: delStub} as any); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(delStub.calledOnce).to.equal(true); + const [, options] = delStub.firstCall.args as [string, any]; + expect(options.params.path.tenantId).to.equal('abcd_123'); + expect(options.params.path.clientId).to.equal('my-client'); + + expect(result.clientId).to.equal('my-client'); + expect(result.deleted).to.equal(true); + }); + + it('calls command.error on API error', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const delStub = sinon.stub().resolves({error: {message: 'boom'}}); + sinon.stub(command, 'getSlasClient').returns({DELETE: delStub} as any); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/get.test.ts b/packages/b2c-cli/test/commands/slas/client/get.test.ts new file mode 100644 index 0000000..1ed554c --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/get.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientGet from '../../../../src/commands/slas/client/get.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client get', () => { + let config: Config; + + async function createCommand(flags: Record, args: Record) { + const command: any = new SlasClientGet([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('fetches a client via SLAS API and returns normalized output in JSON mode', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const getStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'My Client', + scopes: 'a b', + channels: ['RefArch'], + redirectUri: ['http://localhost/callback'], + isPrivateClient: true, + }, + error: undefined, + }); + + sinon.stub(command, 'getSlasClient').returns({GET: getStub} as any); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(getStub.calledOnce).to.equal(true); + expect(result.clientId).to.equal('my-client'); + expect(result.scopes).to.deep.equal(['a', 'b']); + expect(result.channels).to.deep.equal(['RefArch']); + }); + + it('calls command.error on API error', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const getStub = sinon.stub().resolves({data: undefined, error: {message: 'boom'}}); + sinon.stub(command, 'getSlasClient').returns({GET: getStub} as any); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/list.test.ts b/packages/b2c-cli/test/commands/slas/client/list.test.ts new file mode 100644 index 0000000..f798294 --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/list.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientList from '../../../../src/commands/slas/client/list.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client list', () => { + let config: Config; + + async function createCommand(flags: Record, args: Record) { + const command: any = new SlasClientList([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('returns empty clients list when API returns no data array (JSON mode)', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const getStub = sinon.stub().resolves({data: {}, error: undefined}); + sinon.stub(command, 'getSlasClient').returns({GET: getStub} as any); + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.clients).to.deep.equal([]); + }); + + it('calls command.error on API error', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123'}, {}); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + + const getStub = sinon.stub().resolves({data: undefined, error: {message: 'boom'}}); + sinon.stub(command, 'getSlasClient').returns({GET: getStub} as any); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/open.test.ts b/packages/b2c-cli/test/commands/slas/client/open.test.ts new file mode 100644 index 0000000..0d92abd --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/open.test.ts @@ -0,0 +1,84 @@ +/* + * 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 {createRequire} from 'node:module'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientOpen from '../../../../src/commands/slas/client/open.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client open', () => { + let config: Config; + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + + // Prevent any attempt to actually open a browser by stubbing child_process + const require = createRequire(import.meta.url); + const childProcess = require('node:child_process') as typeof import('node:child_process'); + + sinon.stub(childProcess, 'spawn').throws(new Error('blocked')); + sinon.stub(childProcess, 'execFile').throws(new Error('blocked')); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('errors when short code is missing from both flag and resolved config', async () => { + const command: any = new SlasClientOpen([], config); + + stubParse(command, {'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: undefined}})); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('builds URL using short code from resolved config and returns it', async () => { + const command: any = new SlasClientOpen([], config); + + stubParse(command, {'tenant-id': 'abcd_123'}, {clientId: 'my-client'}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78'}})); + + const logStub = sinon.stub(command, 'log').returns(void 0); + + const result = await command.run(); + + expect(result.url).to.include('kv7kzm78.api.commercecloud.salesforce.com'); + expect(result.url).to.include('clientId=my-client'); + expect(result.url).to.include('tenantId=abcd_123'); + expect(logStub.called).to.equal(true); + }); + + it('prefers --short-code flag over resolved config', async () => { + const command: any = new SlasClientOpen([], config); + + stubParse(command, {'tenant-id': 'abcd_123', 'short-code': 'flagcode'}, {clientId: 'my-client'}); + await command.init(); + + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78'}})); + + sinon.stub(command, 'log').returns(void 0); + + const result = await command.run(); + expect(result.url).to.include('flagcode.api.commercecloud.salesforce.com'); + }); +}); diff --git a/packages/b2c-cli/test/commands/slas/client/update.test.ts b/packages/b2c-cli/test/commands/slas/client/update.test.ts new file mode 100644 index 0000000..2f5dc89 --- /dev/null +++ b/packages/b2c-cli/test/commands/slas/client/update.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import SlasClientUpdate from '../../../../src/commands/slas/client/update.js'; +import {isolateConfig, restoreConfig} from '../../../helpers/config-isolation.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; + +describe('slas client update', () => { + let config: Config; + + async function createCommand(flags: Record, args: Record) { + const command: any = new SlasClientUpdate([], config); + stubParse(command, flags, args); + await command.init(); + return command; + } + + function stubAuthAndJson(command: any) { + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + } + + beforeEach(async () => { + isolateConfig(); + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + restoreConfig(); + }); + + it('replaces list values when --replace is true', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + channels: ['NewSite'], + scopes: ['new.scope'], + 'redirect-uri': ['http://new/cb'], + replace: true, + }, + {clientId: 'my-client'}, + ); + + stubAuthAndJson(command); + + const getStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['OldSite'], + scopes: 'old.scope', + redirectUri: 'http://old/cb', + callbackUri: 'cb1, cb2', + isPrivateClient: true, + }, + error: undefined, + }); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['NewSite'], + scopes: 'new.scope', + redirectUri: ['http://new/cb'], + callbackUri: 'cb1, cb2', + isPrivateClient: true, + }, + error: undefined, + }); + + sinon.stub(command, 'getSlasClient').returns({GET: getStub, PUT: putStub} as any); + + const result = await command.run(); + + expect(putStub.calledOnce).to.equal(true); + const [, options] = putStub.firstCall.args as [string, any]; + expect(options.body.channels).to.deep.equal(['NewSite']); + expect(options.body.scopes).to.deep.equal(['new.scope']); + expect(options.body.redirectUri).to.deep.equal(['http://new/cb']); + + expect(result.clientId).to.equal('my-client'); + }); + + it('appends list values with dedupe when --replace is false', async () => { + const command: any = await createCommand( + { + 'tenant-id': 'abcd_123', + channels: ['Site2'], + scopes: ['b', 'a'], + 'redirect-uri': ['http://r2'], + replace: false, + }, + {clientId: 'my-client'}, + ); + + stubAuthAndJson(command); + + const getStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['Site1'], + scopes: 'a b', + redirectUri: ['http://r1'], + isPrivateClient: true, + }, + error: undefined, + }); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['Site1', 'Site2'], + scopes: 'a b', + redirectUri: ['http://r1', 'http://r2'], + isPrivateClient: true, + }, + error: undefined, + }); + + sinon.stub(command, 'getSlasClient').returns({GET: getStub, PUT: putStub} as any); + + await command.run(); + + const [, options] = putStub.firstCall.args as [string, any]; + expect(options.body.channels).to.deep.equal(['Site1', 'Site2']); + expect(options.body.scopes).to.deep.equal(['a', 'b']); + expect(options.body.redirectUri).to.deep.equal(['http://r1', 'http://r2']); + }); + + it('includes secret in update body when provided', async () => { + const command: any = await createCommand({'tenant-id': 'abcd_123', secret: 'new-secret'}, {clientId: 'my-client'}); + + stubAuthAndJson(command); + + const getStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['Site1'], + scopes: 'a', + redirectUri: ['http://r1'], + isPrivateClient: true, + }, + error: undefined, + }); + + const putStub = sinon.stub().resolves({ + data: { + clientId: 'my-client', + name: 'Existing', + channels: ['Site1'], + scopes: 'a', + redirectUri: ['http://r1'], + isPrivateClient: true, + secret: 'new-secret', + }, + error: undefined, + }); + + sinon.stub(command, 'getSlasClient').returns({GET: getStub, PUT: putStub} as any); + + await command.run(); + + const [, options] = putStub.firstCall.args as [string, any]; + expect(options.body.secret).to.equal('new-secret'); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/get.test.ts b/packages/b2c-cli/test/commands/webdav/get.test.ts new file mode 100644 index 0000000..dadaecd --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/get.test.ts @@ -0,0 +1,82 @@ +/* + * 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 fs from 'node:fs'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavGet from '../../../src/commands/webdav/get.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav get', () => { + let writeFileSyncStub: sinon.SinonStub; + + const hooks = createIsolatedConfigHooks(); + + beforeEach(async () => { + await hooks.beforeEach(); + + // Guard against any accidental real file writes. + writeFileSyncStub = sinon.stub(fs, 'writeFileSync').throws(new Error('Unexpected fs.writeFileSync')); + }); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavGet, hooks.getConfig(), flags, args); + } + + it('downloads to a file when output is omitted (defaults to basename(remote))', async () => { + const command: any = await createCommand({root: 'impex'}, {remote: 'src/instance/export.zip'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/export.zip'); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + get: sinon.stub().resolves('abc'), + }, + })); + + // This test expects a write, but it must be fully stubbed. + writeFileSyncStub.resetBehavior(); + writeFileSyncStub.returns(void 0 as any); + const stdoutStub = sinon.stub(process.stdout, 'write'); + + const result = await command.run(); + + expect(writeFileSyncStub.calledOnce).to.equal(true); + expect(stdoutStub.called).to.equal(false); + expect(result.remotePath).to.equal('Impex/src/instance/export.zip'); + expect(result.localPath).to.include('export.zip'); + expect(result.size).to.equal(3); + }); + + it('writes to stdout when --output is -', async () => { + const command: any = await createCommand({root: 'impex', output: '-'}, {remote: 'src/instance/export.zip'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/export.zip'); + sinon.stub(command, 'log').returns(void 0); + + const webdavGet = sinon.stub().resolves('abc'); + sinon.stub(command, 'instance').get(() => ({ + webdav: { + get: webdavGet, + }, + })); + const stdoutStub = sinon.stub(process.stdout, 'write'); + + const result = await command.run(); + + expect(writeFileSyncStub.called).to.equal(false); + expect(stdoutStub.calledOnce).to.equal(true); + expect(webdavGet.calledOnceWithExactly('Impex/src/instance/export.zip')).to.equal(true); + expect(result.localPath).to.equal('-'); + expect(result.size).to.equal(3); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/ls.test.ts b/packages/b2c-cli/test/commands/webdav/ls.test.ts new file mode 100644 index 0000000..c6b0730 --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/ls.test.ts @@ -0,0 +1,84 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavLs from '../../../src/commands/webdav/ls.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav ls', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavLs, hooks.getConfig(), flags, args); + } + + it('filters out the queried directory entry and returns result in JSON mode', async () => { + const command: any = await createCommand({root: 'impex'}, {path: 'src/instance'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'buildPath').returns('Impex/src/instance'); + sinon.stub(command, 'jsonEnabled').returns(true); + + const entries = [ + { + href: 'https://example.com/Impex/src/instance/', + isCollection: true, + }, + { + href: 'https://example.com/Impex/src/instance/file.txt', + isCollection: false, + contentLength: 5, + }, + ]; + + const propfind = sinon.stub().resolves(entries); + sinon.stub(command, 'instance').get(() => ({ + webdav: { + propfind, + }, + })); + + const result = await command.run(); + + expect(propfind.calledOnceWithExactly('Impex/src/instance', '1')).to.equal(true); + expect(result.path).to.equal('Impex/src/instance'); + expect(result.count).to.equal(1); + expect(result.entries).to.have.lengthOf(1); + expect(result.entries[0].href).to.include('file.txt'); + }); + + it('returns empty entries when only the queried directory exists', async () => { + const command: any = await createCommand({root: 'impex'}, {path: 'src/instance'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'buildPath').returns('Impex/src/instance'); + sinon.stub(command, 'jsonEnabled').returns(true); + + const propfind = sinon.stub().resolves([ + { + href: 'https://example.com/Impex/src/instance', + isCollection: true, + }, + ]); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + propfind, + }, + })); + + const result = await command.run(); + + expect(result.count).to.equal(0); + expect(result.entries).to.deep.equal([]); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/mkdir.test.ts b/packages/b2c-cli/test/commands/webdav/mkdir.test.ts new file mode 100644 index 0000000..08d44bd --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/mkdir.test.ts @@ -0,0 +1,51 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavMkdir from '../../../src/commands/webdav/mkdir.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav mkdir', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavMkdir, hooks.getConfig(), flags, args); + } + + it('creates all directories in the path (mkdir -p behavior)', async () => { + const command: any = await createCommand({root: 'impex'}, {path: 'src/instance/my-folder'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + + const buildPathStub = sinon.stub(command, 'buildPath').returns('Impex/src/instance/my-folder'); + + const mkcolStub = sinon.stub().resolves(void 0); + sinon.stub(command, 'instance').get(() => ({ + webdav: { + mkcol: mkcolStub, + }, + })); + + const result = await command.run(); + + expect(buildPathStub.calledOnceWithExactly('src/instance/my-folder')).to.equal(true); + + expect(mkcolStub.callCount).to.equal(4); + expect(mkcolStub.getCall(0).args[0]).to.equal('Impex'); + expect(mkcolStub.getCall(1).args[0]).to.equal('Impex/src'); + expect(mkcolStub.getCall(2).args[0]).to.equal('Impex/src/instance'); + expect(mkcolStub.getCall(3).args[0]).to.equal('Impex/src/instance/my-folder'); + + expect(result.path).to.equal('Impex/src/instance/my-folder'); + expect(result.created).to.equal(true); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/put.test.ts b/packages/b2c-cli/test/commands/webdav/put.test.ts new file mode 100644 index 0000000..e2c6e75 --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/put.test.ts @@ -0,0 +1,122 @@ +/* + * 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 fs from 'node:fs'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavPut from '../../../src/commands/webdav/put.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav put', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavPut, hooks.getConfig(), flags, args); + } + + function stubErrorToThrow(command: any) { + return sinon.stub(command, 'error').throws(new Error('Expected error')); + } + + it('errors when local file does not exist', async () => { + const command: any = await createCommand({root: 'impex'}, {local: './missing.zip', remote: '/'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(fs, 'existsSync').returns(false); + + const errorStub = stubErrorToThrow(command); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('appends local filename when remote is a directory', async () => { + const command: any = await createCommand({root: 'impex'}, {local: './export.zip', remote: 'src/instance/'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns(Buffer.from('abc')); + + const buildPathStub = sinon.stub(command, 'buildPath').callsFake((p: unknown) => { + const path = String(p); + return `Impex/${path.startsWith('/') ? path.slice(1) : path}`; + }); + + const mkcolStub = sinon.stub().resolves(void 0); + const putStub = sinon.stub().resolves(void 0); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + mkcol: mkcolStub, + put: putStub, + }, + })); + + const result = await command.run(); + + expect(buildPathStub.calledOnceWithExactly('src/instance/export.zip')).to.equal(true); + + // Parent dirs: Impex, Impex/src, Impex/src/instance + expect(mkcolStub.callCount).to.equal(3); + expect(mkcolStub.getCall(0).args[0]).to.equal('Impex'); + expect(mkcolStub.getCall(1).args[0]).to.equal('Impex/src'); + expect(mkcolStub.getCall(2).args[0]).to.equal('Impex/src/instance'); + + expect(putStub.calledOnce).to.equal(true); + expect(putStub.getCall(0).args[0]).to.equal('Impex/src/instance/export.zip'); + expect(result.remotePath).to.equal('Impex/src/instance/export.zip'); + expect(result.size).to.equal(3); + expect(result.contentType).to.equal('application/zip'); + }); + + it('uses remote path as-is when remote is a file path', async () => { + const command: any = await createCommand( + {root: 'impex'}, + {local: './data.xml', remote: 'src/instance/renamed.xml'}, + ); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'log').returns(void 0); + + sinon.stub(fs, 'existsSync').returns(true); + sinon.stub(fs, 'readFileSync').returns(Buffer.from('abc')); + + const buildPathStub = sinon.stub(command, 'buildPath').callsFake((p: unknown) => { + const path = String(p); + return `Impex/${path.startsWith('/') ? path.slice(1) : path}`; + }); + + const mkcolStub = sinon.stub().resolves(void 0); + const putStub = sinon.stub().resolves(void 0); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + mkcol: mkcolStub, + put: putStub, + }, + })); + + const result = await command.run(); + + expect(buildPathStub.calledOnceWithExactly('src/instance/renamed.xml')).to.equal(true); + expect(putStub.getCall(0).args[0]).to.equal('Impex/src/instance/renamed.xml'); + expect(result.remotePath).to.equal('Impex/src/instance/renamed.xml'); + expect(result.contentType).to.equal('application/xml'); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/rm.test.ts b/packages/b2c-cli/test/commands/webdav/rm.test.ts new file mode 100644 index 0000000..fe71715 --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/rm.test.ts @@ -0,0 +1,72 @@ +/* + * 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 readline from 'node:readline'; +import {expect} from 'chai'; +import {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavRm from '../../../src/commands/webdav/rm.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav rm', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavRm, hooks.getConfig(), flags, args); + } + + it('deletes when --force is set (no prompt)', async () => { + const command: any = await createCommand({root: 'impex', force: true}, {path: 'src/instance/old.zip'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(undefined); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/old.zip'); + + sinon.stub(command, 'log').returns(undefined); + + const deleteStub = sinon.stub().resolves(undefined); + sinon.stub(command, 'instance').get(() => ({ + webdav: { + delete: deleteStub, + }, + })); + + const result = await command.run(); + + expect(deleteStub.calledOnceWithExactly('Impex/src/instance/old.zip')).to.equal(true); + expect(result.deleted).to.equal(true); + }); + + it('does not delete when user declines confirmation', async () => { + const command: any = await createCommand({root: 'impex', force: false}, {path: 'src/instance/old.zip'}); + + sinon.stub(command, 'ensureWebDavAuth').returns(void 0); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/old.zip'); + + const deleteStub = sinon.stub().resolves(void 0); + sinon.stub(command, 'instance').get(() => ({ + webdav: { + delete: deleteStub, + }, + })); + + const rl = { + question(_prompt: string, cb: (answer: string) => void) { + cb('n'); + }, + close() {}, + }; + sinon.stub(readline, 'createInterface').returns(rl as any); + + const result = await command.run(); + + expect(deleteStub.called).to.equal(false); + expect(result.deleted).to.equal(false); + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/unzip.test.ts b/packages/b2c-cli/test/commands/webdav/unzip.test.ts new file mode 100644 index 0000000..84f8abf --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/unzip.test.ts @@ -0,0 +1,100 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavUnzip from '../../../src/commands/webdav/unzip.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav unzip', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavUnzip, hooks.getConfig(), flags, args); + } + + it('posts UNZIP request and returns archive/extract paths', async () => { + const command = (await createCommand({root: 'impex'}, {path: 'src/instance/export.zip'})) as unknown as { + ensureWebDavAuth: () => void; + buildPath: (p: string) => string; + instance: unknown; + run: () => Promise<{archivePath: string; extractPath: string}>; + }; + + sinon.stub(command, 'ensureWebDavAuth').returns(); + const buildPathStub = sinon.stub(command, 'buildPath').callsFake((p: unknown) => { + const path = String(p); + return `Impex/${path.startsWith('/') ? path.slice(1) : path}`; + }); + + const requestStub = sinon.stub().resolves({ + ok: true, + status: 200, + async text() { + return ''; + }, + }); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + request: requestStub, + }, + })); + + const result = await command.run(); + + expect(buildPathStub.calledOnceWithExactly('src/instance/export.zip')).to.equal(true); + expect(requestStub.calledOnce).to.equal(true); + + const [, init] = requestStub.getCall(0).args as [string, {body?: unknown; method?: string}]; + expect(init.method).to.equal('POST'); + expect(String(init.body)).to.include('method=UNZIP'); + + expect(result.archivePath).to.equal('Impex/src/instance/export.zip'); + expect(result.extractPath).to.equal('Impex/src/instance/export'); + }); + + it('calls command.error when response is not ok', async () => { + const command = (await createCommand({root: 'impex'}, {path: 'src/instance/export.zip'})) as unknown as { + ensureWebDavAuth: () => void; + buildPath: (p: string) => string; + error: (message: string) => never; + instance: unknown; + run: () => Promise; + }; + + sinon.stub(command, 'ensureWebDavAuth').returns(); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/export.zip'); + + const requestStub = sinon.stub().resolves({ + ok: false, + status: 500, + async text() { + return 'boom'; + }, + }); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + request: requestStub, + }, + })); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/commands/webdav/zip.test.ts b/packages/b2c-cli/test/commands/webdav/zip.test.ts new file mode 100644 index 0000000..5f8a6d6 --- /dev/null +++ b/packages/b2c-cli/test/commands/webdav/zip.test.ts @@ -0,0 +1,98 @@ +/* + * 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 {afterEach, beforeEach} from 'mocha'; +import sinon from 'sinon'; +import WebDavZip from '../../../src/commands/webdav/zip.js'; +import {createIsolatedConfigHooks, createTestCommand} from '../../helpers/test-setup.js'; + +describe('webdav zip', () => { + const hooks = createIsolatedConfigHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + async function createCommand(flags: Record, args: Record) { + return createTestCommand(WebDavZip, hooks.getConfig(), flags, args); + } + + it('posts ZIP request and returns source/archive paths', async () => { + const command = (await createCommand({root: 'impex'}, {path: 'src/instance/data'})) as unknown as { + ensureWebDavAuth: () => void; + buildPath: (p: string) => string; + instance: unknown; + run: () => Promise<{archivePath: string; sourcePath: string}>; + }; + + sinon.stub(command, 'ensureWebDavAuth').returns(); + const buildPathStub = sinon.stub(command, 'buildPath').callsFake((p: unknown) => { + const path = String(p); + return `Impex/${path.startsWith('/') ? path.slice(1) : path}`; + }); + + const requestStub = sinon.stub().resolves({ + ok: true, + status: 200, + async text() { + return ''; + }, + }); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + request: requestStub, + }, + })); + + const result = await command.run(); + + expect(buildPathStub.calledOnceWithExactly('src/instance/data')).to.equal(true); + expect(requestStub.calledOnce).to.equal(true); + + const [, init] = requestStub.getCall(0).args as [string, {body?: unknown; method?: string}]; + expect(init.method).to.equal('POST'); + expect(String(init.body)).to.include('method=ZIP'); + + expect(result.sourcePath).to.equal('Impex/src/instance/data'); + expect(result.archivePath).to.equal('Impex/src/instance/data.zip'); + }); + + it('calls command.error when response is not ok', async () => { + const command = (await createCommand({root: 'impex'}, {path: 'src/instance/data'})) as unknown as { + ensureWebDavAuth: () => void; + buildPath: (p: string) => string; + error: (message: string) => never; + instance: unknown; + run: () => Promise; + }; + + sinon.stub(command, 'ensureWebDavAuth').returns(); + sinon.stub(command, 'buildPath').returns('Impex/src/instance/data'); + + const requestStub = sinon.stub().resolves({ + ok: false, + status: 500, + text: async () => 'boom', + }); + + sinon.stub(command, 'instance').get(() => ({ + webdav: { + request: requestStub, + }, + })); + + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Expected error'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); +}); diff --git a/packages/b2c-cli/test/helpers/config-isolation.ts b/packages/b2c-cli/test/helpers/config-isolation.ts new file mode 100644 index 0000000..5500458 --- /dev/null +++ b/packages/b2c-cli/test/helpers/config-isolation.ts @@ -0,0 +1,53 @@ +/* + * 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 + */ + +const ADDITIONAL_ENV_VARS = ['LANGUAGE', 'NO_COLOR']; + +interface IsolationState { + savedEnvVars: Record; +} + +let state: IsolationState | null = null; + +export function isolateConfig(): void { + if (state) throw new Error('isolateConfig() called without cleanup - call restoreConfig() first'); + + const savedEnvVars: Record = {}; + + for (const key of Object.keys(process.env)) { + if (key.startsWith('SFCC_') || key.startsWith('MRT_')) { + savedEnvVars[key] = process.env[key]; + delete process.env[key]; + } + } + + for (const key of ADDITIONAL_ENV_VARS) { + savedEnvVars[key] = process.env[key]; + delete process.env[key]; + } + + process.env.SFCC_CONFIG = '/dev/null'; + process.env.MRT_CREDENTIALS_FILE = '/dev/null'; + + state = {savedEnvVars}; +} + +export function restoreConfig(): void { + if (!state) return; + + delete process.env.SFCC_CONFIG; + delete process.env.MRT_CREDENTIALS_FILE; + + for (const [key, value] of Object.entries(state.savedEnvVars)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + state = null; +} diff --git a/packages/b2c-cli/test/helpers/ods.ts b/packages/b2c-cli/test/helpers/ods.ts deleted file mode 100644 index ba84bdc..0000000 --- a/packages/b2c-cli/test/helpers/ods.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * SPDX-License-Identifier: Apache-2 - * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export function stubCommandConfigAndLogger(command: any, sandboxApiHost = 'admin.dx.test.com'): void { - Object.defineProperty(command, 'config', { - value: { - findConfigFile: () => ({ - read: () => ({'sandbox-api-host': sandboxApiHost}), - }), - }, - configurable: true, - }); - - Object.defineProperty(command, 'logger', { - value: {info() {}, debug() {}, warn() {}, error() {}}, - configurable: true, - }); -} - -export function stubJsonEnabled(command: any, enabled: boolean): void { - command.jsonEnabled = () => enabled; -} - -export function stubOdsClientGet(command: any, handler: (path: string) => Promise): void { - Object.defineProperty(command, 'odsClient', { - value: { - GET: handler, - }, - configurable: true, - }); -} - -export function stubOdsClient(command: any, client: Partial<{GET: any; POST: any; PUT: any; DELETE: any}>): void { - Object.defineProperty(command, 'odsClient', { - value: client, - configurable: true, - }); -} - -export function stubResolvedConfig(command: any, values: Record): void { - Object.defineProperty(command, 'resolvedConfig', { - get: () => ({ - values, - warnings: [], - sources: [], - }), - configurable: true, - }); -} - -export function makeCommandThrowOnError(command: any): void { - command.error = (msg: string) => { - throw new Error(msg); - }; -} diff --git a/packages/b2c-cli/test/helpers/stub-parse.ts b/packages/b2c-cli/test/helpers/stub-parse.ts new file mode 100644 index 0000000..05c239c --- /dev/null +++ b/packages/b2c-cli/test/helpers/stub-parse.ts @@ -0,0 +1,22 @@ +/* + * 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 {stub, type SinonStub} from 'sinon'; + +export function stubParse( + command: unknown, + flags: Record = {}, + args: Record = {}, +): SinonStub { + return stub(command as {parse: unknown}, 'parse').resolves({ + args, + flags, + metadata: {}, + argv: [], + raw: [], + nonExistentFlags: {}, + }); +} diff --git a/packages/b2c-cli/test/helpers/test-setup.ts b/packages/b2c-cli/test/helpers/test-setup.ts new file mode 100644 index 0000000..471a5b5 --- /dev/null +++ b/packages/b2c-cli/test/helpers/test-setup.ts @@ -0,0 +1,58 @@ +/* + * 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 type {Config} from '@oclif/core'; +import sinon from 'sinon'; +import {isolateConfig, restoreConfig} from './config-isolation.js'; +import {stubParse} from './stub-parse.js'; + +export function createIsolatedEnvHooks(): { + beforeEach: () => void; + afterEach: () => void; +} { + return { + beforeEach() { + isolateConfig(); + }, + afterEach() { + sinon.restore(); + restoreConfig(); + }, + }; +} + +export function createIsolatedConfigHooks(): { + beforeEach: () => Promise; + afterEach: () => void; + getConfig: () => Config; +} { + let config: Config; + + return { + async beforeEach() { + isolateConfig(); + const {Config} = await import('@oclif/core'); + config = await Config.load(); + }, + afterEach() { + sinon.restore(); + restoreConfig(); + }, + getConfig: () => config, + }; +} + +export async function createTestCommand Promise}>( + CommandClass: new (argv: string[], config: Config) => T, + config: Config, + flags: Record = {}, + args: Record = {}, +): Promise { + const command: any = new CommandClass([], config); + stubParse(command, flags, args); + await command.init(); + return command as T; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 839fd5b..9a4270a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: shx: specifier: ^0.3.3 version: 0.3.4 + sinon: + specifier: ^21.0.1 + version: 21.0.1 tsx: specifier: ^4.20.6 version: 4.20.6