diff --git a/packages/b2c-cli/test/commands/_test/index.test.ts b/packages/b2c-cli/test/commands/_test/index.test.ts index def14d8d8..2cb0a59c0 100644 --- a/packages/b2c-cli/test/commands/_test/index.test.ts +++ b/packages/b2c-cli/test/commands/_test/index.test.ts @@ -33,9 +33,14 @@ describe('commands/_test', () => { await command.run(); - expect(logStub.calledWith('Using this.log() - goes through pino')).to.be.true; - expect(traceStub.calledWith('Trace level message')).to.be.true; - expect(infoStub.called).to.be.true; + // Structural assertions: each logger surface gets at least one non-empty + // string argument. The exact debug copy is not part of any contract. + const hadStringArg = (stub: sinon.SinonStub) => + stub.getCalls().some((call) => typeof call.args[0] === 'string' && (call.args[0] as string).length > 0); + + expect(hadStringArg(logStub), 'command.log called with non-empty string').to.equal(true); + expect(hadStringArg(traceStub), 'logger.trace called with non-empty string').to.equal(true); + expect(hadStringArg(infoStub), 'logger.info called with non-empty string').to.equal(true); }); it('logs with context objects', async () => { diff --git a/packages/b2c-cli/test/commands/content/validate.test.ts b/packages/b2c-cli/test/commands/content/validate.test.ts index baa8a5a9d..4aa90a87a 100644 --- a/packages/b2c-cli/test/commands/content/validate.test.ts +++ b/packages/b2c-cli/test/commands/content/validate.test.ts @@ -4,6 +4,9 @@ * 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 os from 'node:os'; +import path from 'node:path'; import {expect} from 'chai'; import {ux} from '@oclif/core'; import {afterEach, beforeEach} from 'mocha'; @@ -131,4 +134,92 @@ describe('content validate', () => { expect(result.totalFiles).to.equal(2); }); + + describe('with real validator (no SDK stub)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'content-validate-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, {force: true, recursive: true}); + }); + + it('reports valid pagetype file as valid', async () => { + const filePath = path.join(tmpDir, 'home.json'); + // Minimal valid pagetype: only region_definitions is required by the schema + fs.writeFileSync(filePath, JSON.stringify({region_definitions: []})); + + const command: any = await createCommand({type: 'pagetype'}, [filePath]); + // Use real glob - the file actually exists on disk + sinon.stub(command, 'jsonEnabled').returns(true); + + const result = await command.run(); + + expect(result.totalFiles).to.equal(1); + expect(result.validFiles).to.equal(1); + expect(result.totalErrors).to.equal(0); + expect(result.results[0].valid).to.equal(true); + expect(result.results[0].schemaType).to.equal('pagetype'); + }); + + it('reports invalid pagetype file (missing required region_definitions) with errors', async () => { + const filePath = path.join(tmpDir, 'broken.json'); + // Pagetype requires region_definitions - omitting it triggers a real validator error + fs.writeFileSync(filePath, JSON.stringify({name: {default: 'Bad'}})); + + const command: any = await createCommand({type: 'pagetype'}, [filePath]); + // Non-JSON mode is required to exercise the post-render `this.error('Validation failed')` branch + sinon.stub(command, 'jsonEnabled').returns(false); + const stdoutStub = sinon.stub(ux, 'stdout'); + const errorStub = sinon.stub(command, 'error').throws(new Error('Validation failed')); + + let caught: unknown; + try { + await command.run(); + } catch (error) { + caught = error; + } + + // The validator must have produced at least one error -> command.error called + expect(errorStub.called, 'command.error should be called for invalid file').to.equal(true); + expect(caught).to.exist; + + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join('\n'); + expect(stdoutOutput).to.include('FAIL'); + // The real validator surfaces the missing required property + expect(stdoutOutput).to.include('region_definitions'); + }); + + it('reports invalid JSON content with a JSON parse error', async () => { + const filePath = path.join(tmpDir, 'bad-json.json'); + fs.writeFileSync(filePath, '{not valid json'); + + const command: any = await createCommand({type: 'pagetype'}, [filePath]); + sinon.stub(command, 'jsonEnabled').returns(false); + const stdoutStub = sinon.stub(ux, 'stdout'); + const errorStub = sinon.stub(command, 'error').throws(new Error('Validation failed')); + + let caught: unknown; + try { + await command.run(); + } catch (error) { + caught = error; + } + + expect(errorStub.called).to.equal(true); + expect(caught).to.exist; + + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join('\n'); + expect(stdoutOutput).to.include('FAIL'); + expect(stdoutOutput.toLowerCase()).to.include('invalid json'); + }); + }); }); diff --git a/packages/b2c-cli/test/commands/job/run.test.ts b/packages/b2c-cli/test/commands/job/run.test.ts index ace94e0da..5cb54e682 100644 --- a/packages/b2c-cli/test/commands/job/run.test.ts +++ b/packages/b2c-cli/test/commands/job/run.test.ts @@ -125,7 +125,7 @@ describe('job run', () => { sinon.stub(command, 'runAfterHooks').resolves(void 0); const execStub = sinon.stub().resolves({id: 'e1', execution_status: 'running'}); command.operations = {...command.operations, executeJob: execStub}; - sinon.stub(command, 'showJobLog').resolves(void 0); + const showJobLogStub = 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'); @@ -143,6 +143,7 @@ describe('job run', () => { // expected } + expect(showJobLogStub.calledOnce).to.equal(true); expect(errorStub.called).to.equal(true); }); }); 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 8f6f455e8..c06c84125 100644 --- a/packages/b2c-cli/test/commands/scapi/custom/status.test.ts +++ b/packages/b2c-cli/test/commands/scapi/custom/status.test.ts @@ -6,7 +6,7 @@ import {expect} from 'chai'; import sinon from 'sinon'; -import {Config} from '@oclif/core'; +import {Config, ux} from '@oclif/core'; import ScapiCustomStatus from '../../../../src/commands/scapi/custom/status.js'; import {isolateConfig, restoreConfig} from '@salesforce/b2c-tooling-sdk/test-utils'; import {stubParse} from '../../../helpers/stub-parse.js'; @@ -114,7 +114,7 @@ describe('scapi custom status', () => { expect(fetchStub.called).to.equal(true); }); - it('does not block in non-JSON mode (renderEndpoints is stubbed)', async () => { + it('renders endpoints to stdout in non-JSON mode', async () => { const command: any = new ScapiCustomStatus([], config); stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); @@ -122,24 +122,69 @@ describe('scapi custom status', () => { 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', tenantId: 'zzxy_prd'}})); - sinon.stub(command, 'renderEndpoints').returns(void 0); + const logStub = sinon.stub(command, 'log'); + const stdoutStub = sinon.stub(ux, 'stdout'); 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'}, - }), + new Response( + JSON.stringify({ + total: 2, + activeCodeVersion: 'version1', + data: [ + { + apiName: 'OrdersApi', + apiVersion: 'v1', + cartridgeName: 'app_custom', + endpointPath: '/orders', + httpMethod: 'get', + status: 'active', + securityScheme: 'AmOAuth2', + siteId: 'RefArch', + }, + { + apiName: 'ProductsApi', + apiVersion: 'v2', + cartridgeName: 'app_custom', + endpointPath: '/products', + httpMethod: 'post', + status: 'not_registered', + securityScheme: 'ShopperToken', + siteId: 'RefArchGlobal', + }, + ], + }), + {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.total).to.equal(2); + + const logOutput = logStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join('\n'); + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join('\n'); + const allOutput = `${logOutput}\n${stdoutOutput}`; + + // Header info logged via command.log + expect(logOutput).to.include('version1'); + expect(logOutput).to.match(/Found\s+2/); + + // Table content rendered via ux.stdout (TableRenderer) + expect(allOutput).to.include('/orders'); + expect(allOutput).to.include('/products'); + expect(allOutput).to.include('active'); + expect(allOutput).to.include('not_registered'); }); }); diff --git a/packages/b2c-cli/test/commands/scapi/replications/list.test.ts b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts index ca1f4333c..667cdc513 100644 --- a/packages/b2c-cli/test/commands/scapi/replications/list.test.ts +++ b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts @@ -5,10 +5,10 @@ */ import {expect} from 'chai'; import sinon from 'sinon'; -import {Config} from '@oclif/core'; +import {Config, ux} from '@oclif/core'; import ReplicationsList from '../../../../src/commands/scapi/replications/list.js'; import {stubParse} from '../../../helpers/stub-parse.js'; -import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; +import {createIsolatedEnvHooks} from '../../../helpers/test-setup.js'; describe('scapi replications list', () => { const hooks = createIsolatedEnvHooks(); @@ -80,26 +80,45 @@ describe('scapi replications list', () => { sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const stdoutStub = sinon.stub(ux, 'stdout'); + sinon.stub(globalThis, 'fetch').resolves( new Response( JSON.stringify({ - total: 1, + total: 2, data: [ { - id: 'proc-1', + id: 'proc-abc', status: 'completed', startTime: '2025-01-01T00:00:00Z', initiatedBy: 'user@example.com', productItem: {productId: 'PROD-1'}, }, + { + id: 'proc-xyz', + status: 'in_progress', + startTime: '2025-01-01T01:00:00Z', + initiatedBy: 'user@example.com', + priceTableItem: {priceTableId: 'table-1'}, + }, ], }), {status: 200, headers: {'content-type': 'application/json'}}, ), ); - const result = (await runSilent(() => command.run())) as {total: number}; - expect(result.total).to.equal(1); + const result = (await command.run()) as {total: number}; + expect(result.total).to.equal(2); + + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join(''); + expect(stdoutOutput).to.include('proc-abc'); + expect(stdoutOutput).to.include('proc-xyz'); + expect(stdoutOutput).to.include('completed'); + expect(stdoutOutput).to.include('in_progress'); + expect(stdoutOutput).to.match(/Total:\s*2/); }); it('handles empty result set', async () => { diff --git a/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts b/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts index ef56c37d4..cd9f214a5 100644 --- a/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts +++ b/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts @@ -208,6 +208,9 @@ describe('scapi replications publish', () => { expect.fail('Should have thrown'); } catch { expect(errorStub.calledOnce).to.equal(true); + const message = String(errorStub.firstCall.args[0]); + // 422 surfaces the staging-instance detail returned by the API + expect(message).to.include('staging instances'); } }); @@ -236,6 +239,9 @@ describe('scapi replications publish', () => { expect.fail('Should have thrown'); } catch { expect(errorStub.calledOnce).to.equal(true); + const message = String(errorStub.firstCall.args[0]); + // 409 surfaces the full-replication-running detail + expect(message).to.include('full replication is running'); } }); }); diff --git a/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts b/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts index 75ac5e4d8..633055063 100644 --- a/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts +++ b/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts @@ -8,7 +8,7 @@ import sinon from 'sinon'; import {Config} from '@oclif/core'; import ReplicationsWait from '../../../../src/commands/scapi/replications/wait.js'; import {stubParse} from '../../../helpers/stub-parse.js'; -import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; +import {createIsolatedEnvHooks} from '../../../helpers/test-setup.js'; describe('scapi replications wait', () => { const hooks = createIsolatedEnvHooks(); @@ -100,7 +100,7 @@ describe('scapi replications wait', () => { expect(result.status).to.equal('failed'); }); - it('logs status updates in non-JSON mode', async () => { + it('logs status transitions in non-JSON mode', async () => { const command: any = new ReplicationsWait([], config); stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 10, interval: 0}, {'process-id': 'proc-789'}); await command.init(); @@ -111,8 +111,22 @@ describe('scapi replications wait', () => { sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); - sinon.stub(globalThis, 'fetch').resolves( - new Response( + let callCount = 0; + sinon.stub(globalThis, 'fetch').callsFake(async () => { + callCount++; + if (callCount === 1) { + return new Response( + JSON.stringify({ + id: 'proc-789', + status: 'in_progress', + startTime: '2025-01-01T02:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-3'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ); + } + return new Response( JSON.stringify({ id: 'proc-789', status: 'completed', @@ -122,11 +136,18 @@ describe('scapi replications wait', () => { productItem: {productId: 'PROD-3'}, }), {status: 200, headers: {'content-type': 'application/json'}}, - ), - ); + ); + }); + + await command.run(); - await runSilent(() => command.run()); - expect(logStub.called).to.equal(true); + const logMessages = logStub.getCalls().map((c) => String(c.args[0] ?? '')); + const allLogs = logMessages.join('\n'); + // Should have logged the transition through both statuses + expect(allLogs).to.include('in_progress'); + expect(allLogs).to.include('completed'); + // And the explicit "Process completed successfully" final message + expect(logMessages.some((m) => /completed successfully/i.test(m))).to.equal(true); }); it('times out if process does not complete', async () => { diff --git a/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts b/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts index 273439793..eadef8041 100644 --- a/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts +++ b/packages/b2c-cli/test/commands/scapi/schemas/list.test.ts @@ -6,7 +6,7 @@ import {runCommand} from '@oclif/test'; import {expect} from 'chai'; import sinon from 'sinon'; -import {Config} from '@oclif/core'; +import {Config, ux} from '@oclif/core'; import ScapiSchemasList from '../../../../src/commands/scapi/schemas/list.js'; import {stubParse} from '../../../helpers/stub-parse.js'; import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; @@ -113,22 +113,45 @@ describe('scapi schemas list', () => { 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', tenantId: 'zzxy_prd'}})); sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const logStub = sinon.stub(command, 'log'); + const stdoutStub = sinon.stub(ux, 'stdout'); + sinon.stub(globalThis, 'fetch').resolves( new Response( JSON.stringify({ - total: 1, - data: [{apiFamily: 'product', apiName: 'shopper-products', apiVersion: 'v1', status: 'current'}], + total: 2, + data: [ + {apiFamily: 'product', apiName: 'shopper-products', apiVersion: 'v1', status: 'current'}, + {apiFamily: 'checkout', apiName: 'shopper-baskets', apiVersion: 'v2', status: 'deprecated'}, + ], }), {status: 200, headers: {'content-type': 'application/json'}}, ), ); - const result = (await runSilent(() => command.run())) as {total: number}; - expect(result.total).to.equal(1); + const result = (await command.run()) as {total: number}; + expect(result.total).to.equal(2); + + const logOutput = logStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join('\n'); + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join(''); + const allOutput = `${logOutput}\n${stdoutOutput}`; + + expect(logOutput).to.match(/Found\s+2/); + expect(allOutput).to.include('shopper-products'); + expect(allOutput).to.include('shopper-baskets'); + expect(allOutput).to.include('v1'); + expect(allOutput).to.include('v2'); + expect(allOutput).to.include('current'); + expect(allOutput).to.include('deprecated'); }); it('handles API errors', async () => { diff --git a/packages/b2c-cli/test/commands/sites/list.test.ts b/packages/b2c-cli/test/commands/sites/list.test.ts index 4570b3a39..95aebe740 100644 --- a/packages/b2c-cli/test/commands/sites/list.test.ts +++ b/packages/b2c-cli/test/commands/sites/list.test.ts @@ -59,6 +59,11 @@ describe('sites list', () => { const result = await command.run(); expect(result.count).to.equal(0); expect(stdoutStub.calledOnce).to.equal(true); + const stdoutOutput = stdoutStub + .getCalls() + .map((c) => String(c.args[0] ?? '')) + .join(''); + expect(stdoutOutput).to.include('No sites found'); }); it('calls command.error when ocapi returns error', async () => { diff --git a/packages/b2c-cli/test/commands/webdav/put.test.ts b/packages/b2c-cli/test/commands/webdav/put.test.ts index e2c6e7537..f1149299a 100644 --- a/packages/b2c-cli/test/commands/webdav/put.test.ts +++ b/packages/b2c-cli/test/commands/webdav/put.test.ts @@ -53,11 +53,6 @@ describe('webdav put', () => { 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); @@ -70,8 +65,6 @@ describe('webdav put', () => { 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'); @@ -80,6 +73,7 @@ describe('webdav put', () => { expect(putStub.calledOnce).to.equal(true); expect(putStub.getCall(0).args[0]).to.equal('Impex/src/instance/export.zip'); + expect(putStub.getCall(0).args[2]).to.equal('application/zip'); expect(result.remotePath).to.equal('Impex/src/instance/export.zip'); expect(result.size).to.equal(3); expect(result.contentType).to.equal('application/zip'); @@ -97,11 +91,6 @@ describe('webdav put', () => { 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); @@ -114,8 +103,9 @@ describe('webdav put', () => { const result = await command.run(); - expect(buildPathStub.calledOnceWithExactly('src/instance/renamed.xml')).to.equal(true); + expect(putStub.calledOnce).to.equal(true); expect(putStub.getCall(0).args[0]).to.equal('Impex/src/instance/renamed.xml'); + expect(putStub.getCall(0).args[2]).to.equal('application/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/unzip.test.ts b/packages/b2c-cli/test/commands/webdav/unzip.test.ts index 84f8abf0f..26b9edb58 100644 --- a/packages/b2c-cli/test/commands/webdav/unzip.test.ts +++ b/packages/b2c-cli/test/commands/webdav/unzip.test.ts @@ -24,16 +24,13 @@ describe('webdav unzip', () => { 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; + log: (...args: unknown[]) => void; 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}`; - }); + sinon.stub(command, 'log').returns(); const requestStub = sinon.stub().resolves({ ok: true, @@ -51,10 +48,10 @@ describe('webdav unzip', () => { 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}]; + const [requestPath, init] = requestStub.getCall(0).args as [string, {body?: unknown; method?: string}]; + expect(requestPath).to.equal('Impex/src/instance/export.zip'); expect(init.method).to.equal('POST'); expect(String(init.body)).to.include('method=UNZIP'); @@ -65,14 +62,14 @@ describe('webdav unzip', () => { 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; + log: (...args: unknown[]) => void; run: () => Promise; }; sinon.stub(command, 'ensureWebDavAuth').returns(); - sinon.stub(command, 'buildPath').returns('Impex/src/instance/export.zip'); + sinon.stub(command, 'log').returns(); const requestStub = sinon.stub().resolves({ ok: false, diff --git a/packages/b2c-cli/test/commands/webdav/zip.test.ts b/packages/b2c-cli/test/commands/webdav/zip.test.ts index 5f8a6d64a..a63416a3a 100644 --- a/packages/b2c-cli/test/commands/webdav/zip.test.ts +++ b/packages/b2c-cli/test/commands/webdav/zip.test.ts @@ -24,16 +24,13 @@ describe('webdav zip', () => { 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; + log: (...args: unknown[]) => void; 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}`; - }); + sinon.stub(command, 'log').returns(); const requestStub = sinon.stub().resolves({ ok: true, @@ -51,10 +48,10 @@ describe('webdav zip', () => { 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}]; + const [requestPath, init] = requestStub.getCall(0).args as [string, {body?: unknown; method?: string}]; + expect(requestPath).to.equal('Impex/src/instance/data'); expect(init.method).to.equal('POST'); expect(String(init.body)).to.include('method=ZIP'); @@ -65,14 +62,14 @@ describe('webdav 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; + log: (...args: unknown[]) => void; run: () => Promise; }; sinon.stub(command, 'ensureWebDavAuth').returns(); - sinon.stub(command, 'buildPath').returns('Impex/src/instance/data'); + sinon.stub(command, 'log').returns(); const requestStub = sinon.stub().resolves({ ok: false, diff --git a/packages/b2c-cli/test/functional/e2e/am-operations.test.ts b/packages/b2c-cli/test/functional/e2e/am-operations.test.ts index 218bdd1f1..ddb5a5b70 100644 --- a/packages/b2c-cli/test/functional/e2e/am-operations.test.ts +++ b/packages/b2c-cli/test/functional/e2e/am-operations.test.ts @@ -309,8 +309,12 @@ describe('Account Manager Operations E2E Tests', function () { expect(result.exitCode, 'Command should fail for invalid org').to.not.equal(0); - const errorText = result.stderr || result.stdout; - expect(errorText).to.include('error'); + // The SDK throws `Organization not found` for a 404 lookup. Assert on + // user-visible message text rather than the loose word "error". + const errorText = String(result.stderr || result.stdout || ''); + expect(errorText, `Unexpected error output: ${errorText.slice(0, 300)}`).to.match( + /Organization .*invalid-org-id-12345.*not found/i, + ); }); it('should fail gracefully with invalid user ID', async function () { @@ -324,8 +328,12 @@ describe('Account Manager Operations E2E Tests', function () { expect(result.exitCode, 'Command should fail for invalid user').to.not.equal(0); - const errorText = result.stderr || result.stdout; - expect(errorText).to.include('error'); + // The SDK throws `User not found` for a 404 lookup. Assert on + // user-visible message text rather than the loose word "error". + const errorText = String(result.stderr || result.stdout || ''); + expect(errorText, `Unexpected error output: ${errorText.slice(0, 300)}`).to.match( + /User .*nonexistent-user-12345.*not found/i, + ); }); it('should require authentication', async function () { diff --git a/packages/b2c-cli/test/functional/e2e/code-lifecycle.test.ts b/packages/b2c-cli/test/functional/e2e/code-lifecycle.test.ts index c309ae78b..08a649ee2 100644 --- a/packages/b2c-cli/test/functional/e2e/code-lifecycle.test.ts +++ b/packages/b2c-cli/test/functional/e2e/code-lifecycle.test.ts @@ -25,6 +25,7 @@ describe('Code Lifecycle E2E Tests', function () { let serverHostname: string; let codeVersionA: string; let codeVersionB: string; + let deletedVersionAId: string; let watchProcess: any; let ownSandboxId: null | string = null; @@ -201,6 +202,9 @@ describe('Code Lifecycle E2E Tests', function () { it('should delete inactive version A', async function () { console.log(`Starting deletion of code version: ${codeVersionA}`); + // Capture the ID before clearing — Step 10 needs to verify this exact ID is gone. + deletedVersionAId = codeVersionA; + const result = await runCLIWithRetry( ['code', 'delete', codeVersionA, '--server', serverHostname, '--force', '--json'], { @@ -217,11 +221,13 @@ describe('Code Lifecycle E2E Tests', function () { describe('Step 10: Verify Code Version A Removed', function () { it('should not find deleted version A', async function () { + expect(deletedVersionAId, 'deletedVersionAId must be captured by Step 9').to.be.a('string').and.not.empty; + const result = await runCLIWithRetry(['code', 'list', '--server', serverHostname, '--json']); const response = parseJSONOutput(result); - const found = response.data.find((v: any) => v.id === codeVersionA); - expect(found).to.not.exist; + const found = response.data.find((v: any) => v.id === deletedVersionAId); + expect(found, `Deleted code version '${deletedVersionAId}' should not appear in the listing`).to.not.exist; }); }); }); diff --git a/packages/b2c-cli/test/functional/e2e/job-execution.test.ts b/packages/b2c-cli/test/functional/e2e/job-execution.test.ts index 7478b4c46..5ec95048d 100644 --- a/packages/b2c-cli/test/functional/e2e/job-execution.test.ts +++ b/packages/b2c-cli/test/functional/e2e/job-execution.test.ts @@ -164,7 +164,10 @@ describe('Job Execution E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); expect(response).to.be.an('object'); - expect(String(response.execution_status)).to.be.oneOf(['finished', 'running', 'pending']); + expect( + String(response.execution_status), + `--wait should leave the job in 'finished' state, but got '${response.execution_status}'`, + ).to.equal('finished'); }); }); @@ -244,7 +247,10 @@ describe('Job Execution E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); expect(response.id).to.equal(executionId); - expect(String(response.execution_status)).to.be.oneOf(['finished', 'running', 'pending']); + expect( + String(response.execution_status), + `'job wait' should leave the job in 'finished' state, but got '${response.execution_status}'`, + ).to.equal('finished'); }); }); @@ -265,7 +271,10 @@ describe('Job Execution E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); expect(response.execution).to.be.an('object'); - expect(String(response.execution.execution_status)).to.be.oneOf(['finished', 'running', 'pending']); + expect( + String(response.execution.execution_status), + `'job export' should produce a finished execution, but got '${response.execution.execution_status}'`, + ).to.equal('finished'); expect(response.localPath).to.be.a('string'); exportFilePath = response.localPath as string; @@ -331,7 +340,10 @@ describe('Job Execution E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); expect(response.execution).to.be.an('object'); - expect(String(response.execution.execution_status)).to.be.oneOf(['finished', 'running', 'pending']); + expect( + String(response.execution.execution_status), + `'job import' should produce a finished execution, but got '${response.execution.execution_status}'`, + ).to.equal('finished'); }); }); @@ -361,7 +373,10 @@ describe('Job Execution E2E Tests', function () { expect(result.exitCode).to.equal(0, `Import with keep-archive failed: ${toString(result.stderr)}`); const response = JSON.parse(toString(result.stdout)); - expect(String(response.execution.execution_status)).to.be.oneOf(['finished', 'running', 'pending']); + expect( + String(response.execution.execution_status), + `'job import --keep-archive' should produce a finished execution, but got '${response.execution.execution_status}'`, + ).to.equal('finished'); }); }); diff --git a/packages/b2c-cli/test/functional/e2e/mrt-lifecycle.test.ts b/packages/b2c-cli/test/functional/e2e/mrt-lifecycle.test.ts index ec5b51553..931a4a0ea 100644 --- a/packages/b2c-cli/test/functional/e2e/mrt-lifecycle.test.ts +++ b/packages/b2c-cli/test/functional/e2e/mrt-lifecycle.test.ts @@ -41,7 +41,6 @@ describe('MRT Lifecycle E2E Tests', function () { // Use a dedicated test project to avoid affecting other MRT resources const projectSlug = process.env.MRT_PROJECT || 'b2c-cli'; - const hasProject = true; before(async function () { // Check required environment variables for MRT @@ -168,11 +167,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should get specific project', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // Fixed: slug is a positional argument, not a flag const result = await runCLIWithRetry(['mrt', 'project', 'get', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -190,11 +184,6 @@ describe('MRT Lifecycle E2E Tests', function () { describe('Step 4: Project Members', () => { it('should list project members', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - const result = await runCLIWithRetry(['mrt', 'project', 'member', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, env: MRT_TEST_ENV, @@ -214,11 +203,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should get specific project member', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // First get a member email const listResult = await runCLI(['mrt', 'project', 'member', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -256,11 +240,6 @@ describe('MRT Lifecycle E2E Tests', function () { let environmentName: string; it('should list environments for project', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - const result = await runCLIWithRetry(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, env: MRT_TEST_ENV, @@ -280,11 +259,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should get specific environment', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // First ensure we have an environment const listResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -314,11 +288,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should get B2C connection for environment', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // Get first environment const listResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -367,10 +336,6 @@ describe('MRT Lifecycle E2E Tests', function () { let varCreated = false; before(async function () { - if (!hasProject) { - this.skip(); - } - // Get first environment for testing const listResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -511,10 +476,6 @@ describe('MRT Lifecycle E2E Tests', function () { let environmentName: string; before(async function () { - if (!hasProject) { - this.skip(); - } - // Get first environment const listResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -551,10 +512,6 @@ describe('MRT Lifecycle E2E Tests', function () { let environmentName: string; before(async function () { - if (!hasProject) { - this.skip(); - } - // Get first environment const listResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -590,11 +547,6 @@ describe('MRT Lifecycle E2E Tests', function () { describe('Step 9: Bundles', () => { it('should list bundles for project', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - const result = await runCLIWithRetry(['mrt', 'bundle', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, env: MRT_TEST_ENV, @@ -613,11 +565,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should view deployment history for environment', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // Get first environment const envListResult = await runCLI(['mrt', 'env', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -648,11 +595,6 @@ describe('MRT Lifecycle E2E Tests', function () { describe('Step 10: Project Notifications', () => { it('should list project notifications', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - const result = await runCLIWithRetry( ['mrt', 'project', 'notification', 'list', '--project', projectSlug, '--json'], { @@ -669,11 +611,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should get specific notification if any exist', async function () { - if (!hasProject) { - console.log(' ⚠ No project available, skipping test'); - this.skip(); - } - // First get list of notifications const listResult = await runCLI(['mrt', 'project', 'notification', 'list', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, @@ -727,10 +664,6 @@ describe('MRT Lifecycle E2E Tests', function () { }); it('should fail gracefully with invalid environment', async function () { - if (!hasProject) { - this.skip(); - } - const result = await runCLI(['mrt', 'env', 'get', 'nonexistent-env-12345', '--project', projectSlug, '--json'], { timeout: TIMEOUTS.DEFAULT, env: MRT_TEST_ENV, diff --git a/packages/b2c-cli/test/functional/e2e/sandbox-lifecycle.test.ts b/packages/b2c-cli/test/functional/e2e/sandbox-lifecycle.test.ts index 1b9410c07..078e94da2 100644 --- a/packages/b2c-cli/test/functional/e2e/sandbox-lifecycle.test.ts +++ b/packages/b2c-cli/test/functional/e2e/sandbox-lifecycle.test.ts @@ -179,12 +179,13 @@ describe('Sandbox Lifecycle E2E Tests', function () { expect(result.exitCode, `Stop failed: ${toString(result.stderr)}`).to.equal(0); - // Verify state + // Verify state — the SUT must be queryable after stop const statusResult = await runCLIWithRetry(['sandbox', 'get', sandboxId, '--json']); - if (statusResult.exitCode === 0) { - const sandbox = parseJSONOutput(statusResult); - expect(['stopped', 'stopping'], 'Sandbox should be stopped or stopping').to.include(sandbox.state); - } + expect(statusResult.exitCode, `Get after stop failed: ${toString(statusResult.stderr)}`).to.equal(0); + const sandbox = parseJSONOutput(statusResult); + expect(['stopped', 'stopping'], `Sandbox should be stopped or stopping, but got '${sandbox.state}'`).to.include( + sandbox.state, + ); }); }); @@ -200,12 +201,13 @@ describe('Sandbox Lifecycle E2E Tests', function () { expect(result.exitCode, `Start failed: ${toString(result.stderr)}`).to.equal(0); - // Verify state + // Verify state — the SUT must be queryable after start const statusResult = await runCLIWithRetry(['sandbox', 'get', sandboxId, '--json']); - if (statusResult.exitCode === 0) { - const sandbox = parseJSONOutput(statusResult); - expect(['started', 'starting'], 'Sandbox should be started or starting').to.include(sandbox.state); - } + expect(statusResult.exitCode, `Get after start failed: ${toString(statusResult.stderr)}`).to.equal(0); + const sandbox = parseJSONOutput(statusResult); + expect(['started', 'starting'], `Sandbox should be started or starting, but got '${sandbox.state}'`).to.include( + sandbox.state, + ); }); }); @@ -221,15 +223,14 @@ describe('Sandbox Lifecycle E2E Tests', function () { expect(result.exitCode, `Restart failed: ${toString(result.stderr)}`).to.equal(0); - // Verify state + // Verify state — the SUT must be queryable after restart const statusResult = await runCLIWithRetry(['sandbox', 'get', sandboxId, '--json']); - if (statusResult.exitCode === 0) { - const sandbox = parseJSONOutput(statusResult); - expect( - ['started', 'starting', 'restarting'], - `Sandbox should be started/starting/restarting, but got '${sandbox.state}'`, - ).to.include(sandbox.state); - } + expect(statusResult.exitCode, `Get after restart failed: ${toString(statusResult.stderr)}`).to.equal(0); + const sandbox = parseJSONOutput(statusResult); + expect( + ['started', 'starting', 'restarting'], + `Sandbox should be started/starting/restarting, but got '${sandbox.state}'`, + ).to.include(sandbox.state); }); }); @@ -271,27 +272,26 @@ describe('Sandbox Lifecycle E2E Tests', function () { // Response can be either a usage model or a wrapper with data property const usage: any = 'data' in response ? (response as any).data : response; - if (usage && typeof usage === 'object') { - if (usage.sandboxSeconds !== undefined) { - expect(usage.sandboxSeconds, 'sandboxSeconds should be a number when present').to.be.a('number'); - } - if (usage.minutesUp !== undefined) { - expect(usage.minutesUp, 'minutesUp should be a number when present').to.be.a('number'); - } - if (usage.minutesDown !== undefined) { - expect(usage.minutesDown, 'minutesDown should be a number when present').to.be.a('number'); - } - - // Some backends may also provide aggregate sandbox counters; validate types when available - if (usage.activeSandboxes !== undefined) { - expect(usage.activeSandboxes, 'activeSandboxes should be a number when present').to.be.a('number'); - } - if (usage.createdSandboxes !== undefined) { - expect(usage.createdSandboxes, 'createdSandboxes should be a number when present').to.be.a('number'); - } - if (usage.deletedSandboxes !== undefined) { - expect(usage.deletedSandboxes, 'deletedSandboxes should be a number when present').to.be.a('number'); - } + expect(usage, 'Usage response should contain a usage object').to.be.an('object'); + + // The sandbox usage model has both per-sandbox uptime metrics and + // aggregate counters. At least one numeric metric must be present — + // an empty object is not a valid response. + const knownMetrics = [ + 'sandboxSeconds', + 'minutesUp', + 'minutesDown', + 'activeSandboxes', + 'createdSandboxes', + 'deletedSandboxes', + ]; + const presentMetrics = knownMetrics.filter((key) => usage[key] !== undefined); + expect( + presentMetrics.length, + `Expected at least one usage metric, got: ${JSON.stringify(usage)}`, + ).to.be.greaterThan(0); + for (const key of presentMetrics) { + expect(usage[key], `${key} should be a number`).to.be.a('number'); } }); }); @@ -337,15 +337,15 @@ describe('Sandbox Lifecycle E2E Tests', function () { expect(result.exitCode, `Alias list failed: ${toString(result.stderr)}`).to.equal(0); const response = parseJSONOutput(result); - // sandbox alias list --json returns an array of alias objects (possibly empty) + // sandbox alias list --json returns an array of alias objects. We just created + // an alias in the previous test, so the list MUST include it. expect(response, 'Alias list response should be an array').to.be.an('array'); + expect((response as unknown[]).length, 'Alias list should include the alias created above').to.be.greaterThan(0); - if (Array.isArray(response) && response.length > 0) { - const found = (response as any[]).find( - (alias) => alias.id === createdAliasId || alias.name === createdAliasHostname, - ); - expect(found, 'Expected alias list to include the created alias').to.exist; - } + const found = (response as any[]).find( + (alias) => alias.id === createdAliasId || alias.name === createdAliasHostname, + ); + expect(found, 'Expected alias list to include the created alias').to.exist; }); it('should delete the created alias for the sandbox', async function () { @@ -391,7 +391,7 @@ describe('Sandbox Lifecycle E2E Tests', function () { }); describe('Step 11: Delete Sandbox', function () { - it('should delete the sandbox', async function () { + it('should delete the sandbox and verify it is gone', async function () { // Skip if we don't have a valid sandbox ID if (!sandboxId) { this.skip(); @@ -400,20 +400,32 @@ describe('Sandbox Lifecycle E2E Tests', function () { const result = await runCLIWithRetry(['sandbox', 'delete', sandboxId, '--force', '--json'], {verbose: true}); expect(result.exitCode, `Delete failed: ${toString(result.stderr)}`).to.equal(0); - console.log(' ✓ Sandbox deleted successfully'); + + // Verify the sandbox is actually gone — a follow-up `get` must fail. + const getResult = await runCLI(['sandbox', 'get', sandboxId, '--json']); + expect( + getResult.exitCode, + `Expected 'sandbox get' on deleted sandbox to fail, but it succeeded: ${toString(getResult.stdout)}`, + ).to.not.equal(0); + const errorText = String(getResult.stderr || getResult.stdout || ''); + expect(errorText, 'Get on deleted sandbox should report a recognizable error').to.match( + /not found|404|deleted|does not exist/i, + ); + console.log(' ✓ Sandbox deleted successfully and confirmed gone'); }); }); describe('Additional Test Cases', function () { describe('Error Handling', function () { - it('should handle invalid realm gracefully', async function () { + it('should fail with a clear error for an invalid realm id', async function () { const result = await runCLI(['sandbox', 'list', '--realm', 'invalid-realm-xyz', '--json']); - // Command should either succeed with empty list or fail with error - expect( - result.exitCode, - `Invalid realm command should either succeed (0) or fail (1), but got ${result.exitCode}`, - ).to.be.oneOf([0, 1]); + expect(result.exitCode, 'invalid realm should produce a non-zero exit').to.equal(1); + + const errorText = String(result.stderr || result.stdout || ''); + expect(errorText, 'invalid realm should surface a recognizable error message').to.match( + /Failed to fetch sandboxes|invalid|realm/i, + ); }); it('should handle missing sandbox ID gracefully', async function () { diff --git a/packages/b2c-cli/test/functional/e2e/sites-operations.test.ts b/packages/b2c-cli/test/functional/e2e/sites-operations.test.ts index c1490f86e..0803446ce 100644 --- a/packages/b2c-cli/test/functional/e2e/sites-operations.test.ts +++ b/packages/b2c-cli/test/functional/e2e/sites-operations.test.ts @@ -91,21 +91,16 @@ describe('Sites Operations E2E Tests', function () { }); describe('Step 1: List All Sites', function () { - it('should respond to sites list command', async function () { + it('should list sites successfully', async function () { const result = await runCLI(['sites', 'list', '--server', serverHostname, '--json']); - // sites list may fail if OCAPI is not enabled — accept controlled failure - expect(result.exitCode).to.be.oneOf([0, 1]); - - if (result.exitCode === 0) { - const response = JSON.parse(toString(result.stdout)); - expect(response.data).to.be.an('array'); - } else { - const errorText = toString(result.stderr) || toString(result.stdout); - expect(errorText).to.not.equal(''); - const error = JSON.parse(errorText); - expect(error.error).to.exist; - } + expect(result.exitCode, `sites list failed: ${toString(result.stderr) || toString(result.stdout)}`).to.equal(0); + + const response = JSON.parse(toString(result.stdout)); + expect(response.data, 'sites list response should contain a data array').to.be.an('array'); + // The TestSite imported in `before` should be visible in the listing. + const testSite = (response.data as Array>).find((s) => s.id === SITE_ID); + expect(testSite, `imported site '${SITE_ID}' should be present in sites list`).to.exist; }); }); diff --git a/packages/b2c-cli/test/functional/e2e/webdav-operations.test.ts b/packages/b2c-cli/test/functional/e2e/webdav-operations.test.ts index 89320b813..470939aa0 100644 --- a/packages/b2c-cli/test/functional/e2e/webdav-operations.test.ts +++ b/packages/b2c-cli/test/functional/e2e/webdav-operations.test.ts @@ -194,7 +194,7 @@ describe('WebDAV Operations E2E Tests', function () { }); describe('Step 2: List Files', function () { - it('should list files in WebDAV directory', async function () { + it('should list files in WebDAV directory and include the uploaded file', async function () { await waitFor(async () => { const result = await runCLI([ 'webdav', @@ -210,6 +210,23 @@ describe('WebDAV Operations E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); return response.entries?.some((e: any) => entryName(e) === testFileName); }); + + // Final deterministic assertion: the listing must contain the uploaded file. + const result = await runCLI([ + 'webdav', + 'ls', + remoteBasePath, + '--server', + serverHostname, + '--root', + 'impex', + '--json', + ]); + expect(result.exitCode, `webdav ls failed: ${toString(result.stderr) || toString(result.stdout)}`).to.equal(0); + const response = JSON.parse(toString(result.stdout)); + expect(response.entries, 'webdav ls --json should return an entries array').to.be.an('array'); + const found = (response.entries as Array).some((e) => entryName(e) === testFileName); + expect(found, `Expected '${testFileName}' to be present in directory listing of ${remoteBasePath}`).to.be.true; }); }); @@ -237,7 +254,19 @@ describe('WebDAV Operations E2E Tests', function () { describe('Step 4: Delete File', function () { it('should delete file from WebDAV', async function () { - await runCLI(['webdav', 'rm', remoteFilePath, '--server', serverHostname, '--force', '--root', 'impex']); + const rmResult = await runCLI([ + 'webdav', + 'rm', + remoteFilePath, + '--server', + serverHostname, + '--force', + '--root', + 'impex', + ]); + expect(rmResult.exitCode, `webdav rm failed: ${toString(rmResult.stderr) || toString(rmResult.stdout)}`).to.equal( + 0, + ); await waitFor(async () => { const result = await runCLI([ @@ -254,6 +283,24 @@ describe('WebDAV Operations E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); return !response.entries?.some((e: any) => entryName(e) === testFileName); }); + + // Final deterministic assertion: the file is gone from the listing. + const lsResult = await runCLI([ + 'webdav', + 'ls', + remoteBasePath, + '--server', + serverHostname, + '--root', + 'impex', + '--json', + ]); + expect(lsResult.exitCode, `webdav ls failed: ${toString(lsResult.stderr) || toString(lsResult.stdout)}`).to.equal( + 0, + ); + const response = JSON.parse(toString(lsResult.stdout)); + const stillThere = (response.entries as Array | undefined)?.some((e) => entryName(e) === testFileName); + expect(stillThere, `Deleted file '${testFileName}' should not appear in directory listing`).to.not.be.true; }); }); @@ -296,7 +343,20 @@ describe('WebDAV Operations E2E Tests', function () { describe('Step 7: Delete Directory', function () { it('should delete directory from WebDAV', async function () { - await runCLI(['webdav', 'rm', remoteDirPath, '--server', serverHostname, '--force', '--root', 'impex']); + const rmResult = await runCLI([ + 'webdav', + 'rm', + remoteDirPath, + '--server', + serverHostname, + '--force', + '--root', + 'impex', + ]); + expect( + rmResult.exitCode, + `webdav rm (directory) failed: ${toString(rmResult.stderr) || toString(rmResult.stdout)}`, + ).to.equal(0); await waitFor(async () => { const result = await runCLI([ @@ -313,6 +373,22 @@ describe('WebDAV Operations E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); return !response.entries?.some((e: any) => entryName(e) === testDirName); }); + + // Final deterministic assertion: the directory is gone. + const lsResult = await runCLI([ + 'webdav', + 'ls', + remoteBasePath, + '--server', + serverHostname, + '--root', + 'impex', + '--json', + ]); + expect(lsResult.exitCode).to.equal(0); + const response = JSON.parse(toString(lsResult.stdout)); + const stillThere = (response.entries as Array | undefined)?.some((e) => entryName(e) === testDirName); + expect(stillThere, `Deleted directory '${testDirName}' should not appear in listing`).to.not.be.true; }); }); @@ -355,6 +431,24 @@ describe('WebDAV Operations E2E Tests', function () { const response = JSON.parse(toString(result.stdout)); return response.entries?.some((e: any) => entryName(e) === `${testDirName}.zip`); }); + + // Final deterministic assertion: the zip is present in the listing. + const lsResult = await runCLI([ + 'webdav', + 'ls', + remoteBasePath, + '--server', + serverHostname, + '--root', + 'impex', + '--json', + ]); + expect(lsResult.exitCode).to.equal(0); + const response = JSON.parse(toString(lsResult.stdout)); + expect(response.entries, 'webdav ls --json should return an entries array').to.be.an('array'); + const zipName = `${testDirName}.zip`; + const found = (response.entries as Array).some((e) => entryName(e) === zipName); + expect(found, `Expected zip '${zipName}' to be present in listing of ${remoteBasePath}`).to.be.true; }); }); diff --git a/packages/b2c-cli/test/utils/am/user-display.test.ts b/packages/b2c-cli/test/utils/am/user-display.test.ts index 5b46ab9bc..c33251244 100644 --- a/packages/b2c-cli/test/utils/am/user-display.test.ts +++ b/packages/b2c-cli/test/utils/am/user-display.test.ts @@ -89,6 +89,12 @@ describe('utils/am/user-display', () => { printUserDetails(user, baseRoleMapping, baseOrgMapping); expect(stdoutStub.calledOnce).to.equal(true); + const text = stdoutStub.firstCall.args[0]; + // String-form org IDs must still resolve through orgMapping into the + // user-visible " ()" display. + expect(text).to.include('Organizations'); + expect(text).to.include('My Org'); + expect(text).to.include('org-1'); }); it('prints user with roles', () => { @@ -215,6 +221,14 @@ describe('utils/am/user-display', () => { printUserDetails(user, baseRoleMapping, baseOrgMapping); expect(stdoutStub.calledOnce).to.equal(true); + const text = stdoutStub.firstCall.args[0]; + // Date fields should produce labelled output. The exact formatted date + // is locale-dependent (Date#toLocaleString), so assert on the labels and + // on the lastLoginDate raw passthrough. + expect(text).to.include('Created At'); + expect(text).to.include('Last Modified'); + expect(text).to.include('Last Login'); + expect(text).to.include('2025-01-25'); }); it('handles non-expired password', () => { @@ -266,6 +280,10 @@ describe('utils/am/user-display', () => { printUserDetails(user, baseRoleMapping, baseOrgMapping); expect(stdoutStub.calledOnce).to.equal(true); + const text = stdoutStub.firstCall.args[0]; + // When an organization has no id, the SUT falls back to 'Unknown'. + expect(text).to.include('Organizations'); + expect(text).to.include('Unknown'); }); it('handles optional phone fields', () => { diff --git a/packages/b2c-cli/test/utils/scapi/schemas.test.ts b/packages/b2c-cli/test/utils/scapi/schemas.test.ts index 8f749475f..c53f459a5 100644 --- a/packages/b2c-cli/test/utils/scapi/schemas.test.ts +++ b/packages/b2c-cli/test/utils/scapi/schemas.test.ts @@ -8,24 +8,44 @@ import {formatApiError} from '../../../src/utils/scapi/schemas.js'; describe('utils/scapi/schemas', () => { describe('formatApiError', () => { - it('formats error from response', () => { + it('extracts the standard `message` field from an error object', () => { const error = {message: 'Not Found'}; const response = new Response(null, {status: 404, statusText: 'Not Found'}); const result = formatApiError(error, response); - expect(result).to.be.a('string'); - expect(result.length).to.be.greaterThan(0); + expect(result).to.equal('Not Found'); }); - it('formats string error', () => { - const response = new Response(null, {status: 500}); + it('extracts SCAPI/Problem+JSON `detail` over `title`', () => { + const error = {detail: 'The widget is missing.', title: 'Widget Error'}; + const response = new Response(null, {status: 422, statusText: 'Unprocessable Entity'}); + const result = formatApiError(error, response); + expect(result).to.equal('The widget is missing.'); + }); + + it('extracts ODS-style nested `error.message`', () => { + const error = {error: {message: 'Sandbox not found'}}; + const response = new Response(null, {status: 404, statusText: 'Not Found'}); + const result = formatApiError(error, response); + expect(result).to.equal('Sandbox not found'); + }); + + it('extracts OCAPI fault.message', () => { + const error = {fault: {message: 'Invalid filter expression'}}; + const response = new Response(null, {status: 400, statusText: 'Bad Request'}); + const result = formatApiError(error, response); + expect(result).to.equal('Invalid filter expression'); + }); + + it('falls back to the HTTP status line for non-object errors', () => { + const response = new Response(null, {status: 500, statusText: 'Internal Server Error'}); const result = formatApiError('Server error', response); - expect(result).to.be.a('string'); + expect(result).to.equal('HTTP 500 Internal Server Error'); }); - it('formats null error', () => { - const response = new Response(null, {status: 400}); + it('falls back to the HTTP status line for null errors', () => { + const response = new Response(null, {status: 400, statusText: 'Bad Request'}); const result = formatApiError(null, response); - expect(result).to.be.a('string'); + expect(result).to.equal('HTTP 400 Bad Request'); }); }); }); diff --git a/packages/b2c-cli/test/utils/slas/client.test.ts b/packages/b2c-cli/test/utils/slas/client.test.ts index 4034f1b6e..39e13890f 100644 --- a/packages/b2c-cli/test/utils/slas/client.test.ts +++ b/packages/b2c-cli/test/utils/slas/client.test.ts @@ -165,6 +165,14 @@ describe('utils/slas/client', () => { printClientDetails(output); expect(stdoutStub.calledOnce).to.equal(true); + const text = stdoutStub.firstCall.args[0]; + // The rendered output must show the client identity, channel, scopes, + // and redirect URI — these are the fields users rely on. + expect(text).to.include('client-1'); + expect(text).to.include('Test Client'); + expect(text).to.include('SiteA'); + expect(text).to.include('sfcc.products'); + expect(text).to.include('https://example.com/callback'); }); it('prints secret when showSecret is true', () => { diff --git a/packages/b2c-dx-mcp/test/commands/mcp.test.ts b/packages/b2c-dx-mcp/test/commands/mcp.test.ts index 9604d7f1e..9c562a76f 100644 --- a/packages/b2c-dx-mcp/test/commands/mcp.test.ts +++ b/packages/b2c-dx-mcp/test/commands/mcp.test.ts @@ -483,12 +483,12 @@ describe('McpServerCommand', () => { sandbox.restore(); }); - it('should combine MRT and instance flags', async () => { + it('should combine MRT and instance flags into the resolved config values', async () => { // Stub init to set up flags sandbox.stub(command, 'init').resolves(); (command as unknown as {flags: Record}).flags = { 'api-key': 'test-mrt-key', - server: 'test-server', + server: 'test-server.demandware.net', username: 'test-user', }; @@ -499,11 +499,17 @@ describe('McpServerCommand', () => { }); // Call loadConfiguration via protected access - const config = await (command as unknown as {loadConfiguration(): Promise}).loadConfiguration(); - - // Verify config was loaded (should return a ResolvedB2CConfig object) - expect(config).to.exist; - expect(config).to.have.property('values'); + const config = await ( + command as unknown as {loadConfiguration(): Promise<{values: Record}>} + ).loadConfiguration(); + + // Each flag must surface in config.values under its normalized key: + // --server -> hostname + // --username -> username + // --api-key -> mrtApiKey + expect(config.values.hostname).to.equal('test-server.demandware.net'); + expect(config.values.username).to.equal('test-user'); + expect(config.values.mrtApiKey).to.equal('test-mrt-key'); }); }); diff --git a/packages/b2c-dx-mcp/test/server.test.ts b/packages/b2c-dx-mcp/test/server.test.ts index 70e287988..2527e6d21 100644 --- a/packages/b2c-dx-mcp/test/server.test.ts +++ b/packages/b2c-dx-mcp/test/server.test.ts @@ -130,28 +130,81 @@ describe('B2CDxMcpServer', () => { }); }); - it('should register a tool without throwing', () => { - expect(() => { - server.addTool('test_tool', 'A test tool', {}, simpleHandler); - }).to.not.throw(); + /** + * Captures the configs passed to `registerTool` by `addTool`. + * The base McpServer keeps registered tools in a private `_registeredTools` + * map; intercepting `registerTool` is a stable seam used elsewhere in this + * file and avoids touching SDK internals. + */ + function captureRegisteredTools(srv: B2CDxMcpServer): Map< + string, + { + config: {description?: string; inputSchema?: unknown}; + handler: (args: Record, extra: unknown) => Promise; + } + > { + const captured = new Map< + string, + { + config: {description?: string; inputSchema?: unknown}; + handler: (args: Record, extra: unknown) => Promise; + } + >(); + const original = srv.registerTool.bind(srv); + srv.registerTool = (name, config, handler) => { + captured.set(name, { + config: config as {description?: string; inputSchema?: unknown}, + handler: handler as (args: Record, extra: unknown) => Promise, + }); + return original(name, config as never, handler as never); + }; + return captured; + } + + it('should register a tool that is callable via the server tool registry', async () => { + const captured = captureRegisteredTools(server); + server.addTool('test_tool', 'A test tool', {}, simpleHandler); + + const entry = captured.get('test_tool'); + expect(entry, 'test_tool should be registered').to.exist; + expect(entry!.config.description).to.equal('A test tool'); + expect(entry!.handler).to.be.a('function'); + + // The wrapped handler must be callable and forward to the user's handler. + const result = (await entry!.handler({}, {})) as {content: Array<{text: string; type: string}>}; + expect(result.content[0]).to.deep.include({type: 'text', text: 'ok'}); }); - it('should register multiple tools', () => { - expect(() => { - server.addTool('tool_one', 'First tool', {}, toolOneHandler); - server.addTool('tool_two', 'Second tool', {}, toolTwoHandler); - }).to.not.throw(); + it('should register multiple tools independently', async () => { + const captured = captureRegisteredTools(server); + server.addTool('tool_one', 'First tool', {}, toolOneHandler); + server.addTool('tool_two', 'Second tool', {}, toolTwoHandler); + + expect([...captured.keys()]).to.have.members(['tool_one', 'tool_two']); + expect(captured.get('tool_one')!.config.description).to.equal('First tool'); + expect(captured.get('tool_two')!.config.description).to.equal('Second tool'); + + const r1 = (await captured.get('tool_one')!.handler({}, {})) as {content: Array<{text: string}>}; + const r2 = (await captured.get('tool_two')!.handler({}, {})) as {content: Array<{text: string}>}; + expect(r1.content[0].text).to.equal('one'); + expect(r2.content[0].text).to.equal('two'); }); - it('should accept tools with input schema', () => { - // Use Zod schema (ZodRawShape format) + it('should propagate the input schema to the registered tool', () => { + const captured = captureRegisteredTools(server); const inputSchema = { name: z.string().describe('Name parameter'), count: z.number().optional().describe('Count parameter'), }; - expect(() => { - server.addTool('parameterized_tool', 'A tool with parameters', inputSchema, paramHandler); - }).to.not.throw(); + server.addTool('parameterized_tool', 'A tool with parameters', inputSchema, paramHandler); + + const entry = captured.get('parameterized_tool'); + expect(entry, 'parameterized_tool should be registered').to.exist; + expect(entry!.config.description).to.equal('A tool with parameters'); + // Schema must be the exact ZodRawShape we passed (same keys, same instances). + expect(entry!.config.inputSchema).to.equal(inputSchema); + const schema = entry!.config.inputSchema as Record; + expect(Object.keys(schema)).to.have.members(['name', 'count']); }); }); @@ -552,13 +605,26 @@ describe('B2CDxMcpServer', () => { it('should handle undefined telemetry gracefully in all operations', async () => { const server = new B2CDxMcpServer({name: 'test-server', version: '1.0.0'}, {telemetry: undefined}); - // All operations should work without throwing - expect(() => { - server.addTool('test', 'Test', {}, successHandler); - }).to.not.throw(); + // Capture the wrapped handler so we can invoke it directly. + let wrappedHandler: + | ((args: Record, extra: unknown) => Promise<{content: Array<{text: string}>}>) + | null = null; + const originalRegisterTool = server.registerTool.bind(server); + server.registerTool = (name, config, h) => { + wrappedHandler = h as typeof wrappedHandler; + return originalRegisterTool(name, config, h); + }; + + server.addTool('test', 'Test', {}, successHandler); + + // The wrapped handler must run the underlying handler and return the + // expected result even with no telemetry attached. + expect(wrappedHandler, 'wrapped handler should have been captured').to.not.equal(null); + const result = await wrappedHandler!({}, {}); + expect(result.content[0]).to.deep.include({type: 'text', text: 'success'}); + // connect() must also succeed and report connected state. const transport = new MockTransport(); - // Connect should succeed without throwing await server.connect(transport); expect(server.isConnected()).to.be.true; }); @@ -594,45 +660,40 @@ describe('B2CDxMcpServer', () => { }); }); - it('should handle multiple sequential connects', async () => { + it('should reject a second connect with Already connected and emit a SERVER_STATUS error', async () => { const mockTelemetry = new MockTelemetry(); const server = new B2CDxMcpServer( {name: 'test-server', version: '1.0.0'}, {telemetry: mockTelemetry as unknown as Telemetry}, ); - // First connect + // First connect succeeds and emits SERVER_STATUS=started. const transport1 = new MockTransport(); await server.connect(transport1); - expect(mockTelemetry.events.filter((e) => e.name === 'SERVER_STATUS')).to.have.lengthOf(1); - // Subsequent connect attempts may be prevented by MCP SDK (throws error) - // or may succeed (if MockTransport doesn't enforce the restriction) - // In either case, verify that telemetry continues to work + const firstStatusEvents = mockTelemetry.events.filter((e) => e.name === 'SERVER_STATUS'); + expect(firstStatusEvents).to.have.lengthOf(1); + expect(firstStatusEvents[0].attributes.status).to.equal('started'); + + // The MCP SDK Protocol.connect throws "Already connected to a transport..." + // when a second transport is supplied — this is the deterministic branch. const transport2 = new MockTransport(); - let connectSucceeded = false; + let caught: Error | undefined; try { await server.connect(transport2); - connectSucceeded = true; } catch (error) { - // Expected error if MCP SDK prevents multiple connects - expect(error).to.be.instanceOf(Error); - expect((error as Error).message).to.include('Already connected'); + caught = error as Error; } + expect(caught, 'second connect must throw').to.be.instanceOf(Error); + expect(caught!.message).to.include('Already connected'); - // Verify telemetry recorded events for both connect attempts + // The server.connect catch block must emit a SERVER_STATUS error event + // carrying the SDK error message. const statusEvents = mockTelemetry.events.filter((e) => e.name === 'SERVER_STATUS'); - expect(statusEvents).to.have.lengthOf.at.least(2); - - if (connectSucceeded) { - // If second connect succeeded, last event should be 'started' - const lastEvent = statusEvents.at(-1); - expect(lastEvent?.attributes.status).to.equal('started'); - } else { - // If second connect failed, last event should be 'error' - const lastEvent = statusEvents.at(-1); - expect(lastEvent?.attributes.status).to.equal('error'); - } + expect(statusEvents).to.have.lengthOf(2); + const lastEvent = statusEvents.at(-1); + expect(lastEvent?.attributes.status).to.equal('error'); + expect(lastEvent?.attributes.errorMessage).to.include('Already connected'); }); }); }); diff --git a/packages/b2c-dx-mcp/test/services.test.ts b/packages/b2c-dx-mcp/test/services.test.ts index 3c991131d..99a33b1f7 100644 --- a/packages/b2c-dx-mcp/test/services.test.ts +++ b/packages/b2c-dx-mcp/test/services.test.ts @@ -123,18 +123,43 @@ describe('services', () => { }); describe('getCustomApisClient', () => { - it('should create Custom APIs client with valid config', () => { + it('should create a Custom APIs client wired to createOAuth and the right base URL', async () => { const config = createMockResolvedConfig({ shortCode: 'test-shortcode', tenantId: 'test_tenant', }); stub(config, 'hasOAuthConfig').returns(true); - stub(config, 'createOAuth').returns(mockOAuthStrategy); + const createOAuthStub = stub(config, 'createOAuth').returns(mockOAuthStrategy); const services = new Services({resolvedConfig: config}); const client = services.getCustomApisClient(); - expect(client).to.exist; + // Client construction must consult the resolved config's OAuth factory + // (rather than e.g. constructing a strategy from raw values directly). + expect(createOAuthStub.calledOnce, 'createOAuth must be invoked exactly once').to.be.true; + expect(createOAuthStub.firstCall.args).to.deep.equal([]); + + // Client must expose the openapi-fetch shape. + expect(client.GET).to.be.a('function'); + expect(client.use).to.be.a('function'); + + // Capture the resolved request URL via middleware to verify the base URL + // is built from the configured shortCode and points at the Custom APIs path. + const capturedUrls: string[] = []; + client.use({ + async onRequest({request}) { + capturedUrls.push(request.url); + // Short-circuit so we don't actually hit the network. + return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}}); + }, + }); + await client.GET('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_test_tenant'}}, + }); + expect(capturedUrls).to.have.lengthOf(1); + expect(capturedUrls[0]).to.match( + /^https:\/\/test-shortcode\.api\.commercecloud\.salesforce\.com\/dx\/custom-apis\/v1\//, + ); }); it('should throw error when shortCode is missing', () => { @@ -257,18 +282,37 @@ describe('services', () => { }); describe('getScapiSchemasClient', () => { - it('should create SCAPI Schemas client with valid config', () => { + it('should create a SCAPI Schemas client wired to createOAuth and the right base URL', async () => { const config = createMockResolvedConfig({ shortCode: 'test-shortcode', tenantId: 'test_tenant', }); stub(config, 'hasOAuthConfig').returns(true); - stub(config, 'createOAuth').returns(mockOAuthStrategy); + const createOAuthStub = stub(config, 'createOAuth').returns(mockOAuthStrategy); const services = new Services({resolvedConfig: config}); const client = services.getScapiSchemasClient(); - expect(client).to.exist; + expect(createOAuthStub.calledOnce, 'createOAuth must be invoked exactly once').to.be.true; + expect(createOAuthStub.firstCall.args).to.deep.equal([]); + + expect(client.GET).to.be.a('function'); + expect(client.use).to.be.a('function'); + + const capturedUrls: string[] = []; + client.use({ + async onRequest({request}) { + capturedUrls.push(request.url); + return new Response('{}', {status: 200, headers: {'content-type': 'application/json'}}); + }, + }); + await client.GET('/organizations/{organizationId}/schemas', { + params: {path: {organizationId: 'f_ecom_test_tenant'}}, + }); + expect(capturedUrls).to.have.lengthOf(1); + expect(capturedUrls[0]).to.match( + /^https:\/\/test-shortcode\.api\.commercecloud\.salesforce\.com\/dx\/scapi-schemas\/v1\//, + ); }); it('should throw error when shortCode is missing', () => { diff --git a/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-apis-get-status.test.ts b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-apis-get-status.test.ts index 62d78d61a..5aff45e3a 100644 --- a/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-apis-get-status.test.ts +++ b/packages/b2c-dx-mcp/test/tools/scapi/scapi-custom-apis-get-status.test.ts @@ -96,12 +96,8 @@ describe('tools/scapi/scapi-custom-apis-get-status', () => { it('should create scapi_custom_apis_get_status tool with correct metadata', () => { const tool = createScapiCustomApisStatusTool(() => services); - expect(tool).to.exist; expect(tool.name).to.equal('scapi_custom_apis_get_status'); - expect(tool.description).to.include('Custom'); - expect(tool.description).to.include('endpoint'); - expect(tool.description).to.include('Custom'); - expect(tool.description).to.include('b2c scapi custom status'); + expect(tool.description).to.be.a('string').and.not.empty; expect(tool.inputSchema).to.exist; expect(tool.handler).to.be.a('function'); expect(tool.toolsets).to.deep.equal(['PWAV3', 'SCAPI', 'STOREFRONTNEXT']); @@ -111,10 +107,7 @@ describe('tools/scapi/scapi-custom-apis-get-status', () => { it('should have optional input params: status, groupBy, columns', () => { const tool = createScapiCustomApisStatusTool(() => services); - expect(tool.inputSchema).to.have.property('status'); - expect(tool.inputSchema).to.have.property('groupBy'); - expect(tool.inputSchema).to.have.property('columns'); - expect(tool.inputSchema).to.not.have.property('extended'); + expect(Object.keys(tool.inputSchema as object)).to.have.members(['status', 'groupBy', 'columns']); }); }); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/page-designer-decorator/index.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/page-designer-decorator/index.test.ts index 5d19ebec5..90e170dd7 100644 --- a/packages/b2c-dx-mcp/test/tools/storefrontnext/page-designer-decorator/index.test.ts +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/page-designer-decorator/index.test.ts @@ -347,12 +347,16 @@ export default function DecoratedComponent({title}: DecoratedComponentProps) { autoMode: true, }); - // Should handle already-decorated components gracefully - // May return an error or provide guidance - expect(result).to.exist; + // Auto mode must short-circuit and refuse to re-decorate, returning + // the deterministic "Already Decorated" notice that names the component + // and offers to modify the existing decorators. const text = getResultText(result); - // Should mention the component is already decorated or provide appropriate guidance - expect(text).to.match(/decorated|already|existing|Component/i); + expect(text).to.include('Component Already Decorated'); + expect(text).to.include('DecoratedComponent'); + expect(text).to.include('already has Page Designer decorators'); + expect(text).to.include('modify the existing decorators'); + // It must NOT emit a fresh @Component/@AttributeDefinition decorator block. + expect(text).to.not.match(/@AttributeDefinition\s*\(/); }); it('should handle component with no props in auto mode', async () => { @@ -1399,30 +1403,44 @@ export default ProductItem; const tool = createPageDesignerDecoratorTool(getServices); createTestComponent(testDir, 'ConversationComponent'); - const steps = ['analyze', 'select_props', 'configure_attrs', 'configure_regions', 'confirm_generation']; + const steps = ['analyze', 'select_props', 'configure_attrs', 'configure_regions', 'confirm_generation'] as const; const results = await Promise.all( steps.map((step) => tool.handler({ component: 'ConversationComponent', - conversationContext: { - step: step as 'analyze' | 'configure_attrs' | 'configure_regions' | 'confirm_generation' | 'select_props', - }, + conversationContext: {step}, }), ), ); - // Should not error on valid step - for (const [i, step] of steps.entries()) { - const result = results[i]; - if (step === 'select_props' || step === 'confirm_generation') { - // These steps require metadata, so they'll error without it - // But the step itself should be accepted - expect(result).to.exist; - } else { - expect(result.isError).to.be.undefined; - } - } + const byStep = Object.fromEntries(steps.map((s, i) => [s, results[i]])) as Record< + (typeof steps)[number], + (typeof results)[number] + >; + + // analyze: deterministic header + component name in output + expect(byStep.analyze.isError).to.be.undefined; + expect(getResultText(byStep.analyze)).to.include('Step 1: Component Analysis'); + expect(getResultText(byStep.analyze)).to.include('ConversationComponent'); + + // select_props: requires componentMetadata; without it, errors with deterministic message + expect(byStep.select_props.isError).to.equal(true); + expect(getResultText(byStep.select_props)).to.include('Missing component metadata'); + + // configure_attrs: deterministic step header + expect(byStep.configure_attrs.isError).to.be.undefined; + expect(getResultText(byStep.configure_attrs)).to.include('Step 2: Attribute Configuration'); + + // configure_regions: deterministic step header + component name + expect(byStep.configure_regions.isError).to.be.undefined; + const regionsText = getResultText(byStep.configure_regions); + expect(regionsText).to.include('Step 3: Region Configuration'); + expect(regionsText).to.include('ConversationComponent'); + + // confirm_generation: requires componentMetadata; errors with deterministic message + expect(byStep.confirm_generation.isError).to.equal(true); + expect(getResultText(byStep.confirm_generation)).to.include('Missing component metadata'); }); }); diff --git a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts index da0e9e3d9..4d45101df 100644 --- a/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts +++ b/packages/b2c-dx-mcp/test/tools/storefrontnext/site-theming/index.test.ts @@ -127,20 +127,29 @@ describe('tools/storefrontnext/site-theming', () => { const firstText = getResultText(firstResult); expect(firstText).to.include('Questions to Ask the User'); + // Extract a question id actually shown in the first response. The internal + // section formats each question as: ### Question N (category): id + const idMatch = firstText.match(/\((?:colors|typography|general)\):\s*([\w-]+)/); + expect(idMatch, 'expected first response to contain at least one question id').to.not.equal(null); + const askedId = idMatch![1]; + const secondResult = await tool.handler({ fileKeys: ['theming-questions'], conversationContext: { - questionsAsked: ['color-primary'], + questionsAsked: [askedId], collectedAnswers: { - colors: [], + colors: [{hex: '#635BFF', type: 'primary'}], fonts: [], - 'color-primary': '#635BFF', + [askedId]: 'answered', }, }, }); const secondText = getResultText(secondResult); - expect(secondText).to.be.a('string'); + // The asked question id must be excluded from the next batch of questions. + // The internal section uses `### Question N (category): ` so we look + // specifically for that header pattern. + expect(secondText).to.not.match(new RegExp(`\\((?:colors|typography|general)\\):\\s*${askedId}\\b`)); }); it('should include collected theming info in response', async () => { diff --git a/packages/b2c-tooling-sdk/test/auth/middleware.test.ts b/packages/b2c-tooling-sdk/test/auth/middleware.test.ts index ff0c92fcc..eab6c1ae5 100644 --- a/packages/b2c-tooling-sdk/test/auth/middleware.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/middleware.test.ts @@ -295,10 +295,11 @@ describe('auth/middleware', () => { await strategy.getTokenResponse(); - // User-Agent headers should be set (the exact value depends on whether - // CLI has overridden it, but they should be present) - expect(capturedUserAgent).to.not.be.null; - expect(capturedSfdcUserAgent).to.not.be.null; + // User-Agent headers must follow the b2c-tooling-sdk/ or b2c-cli/ format + // (CLI may override; both forms start with `b2c-` and include a slash + version) + expect(capturedUserAgent).to.match(/^b2c-(?:cli|tooling-sdk)\/\d/); + expect(capturedSfdcUserAgent).to.match(/^b2c-(?:cli|tooling-sdk)\/\d/); + expect(capturedUserAgent).to.equal(capturedSfdcUserAgent); }); it('applies custom auth middleware', async () => { diff --git a/packages/b2c-tooling-sdk/test/auth/oauth-jwt.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth-jwt.test.ts index 1918dbdc3..0ce435865 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth-jwt.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth-jwt.test.ts @@ -169,27 +169,41 @@ describe('auth/oauth-jwt', () => { } }); - it('should accept valid configuration', () => { - expect(() => { - new JwtOAuthStrategy({ - clientId: 'test-client', - certPath: TEST_CERT_PATH, - keyPath: TEST_KEY_PATH, - accountManagerHost: AM_HOST, - }); - }).to.not.throw(); + it('should accept valid configuration and produce a Bearer Authorization header', async () => { + server.use( + http.post(AM_URL, () => + HttpResponse.json({access_token: createMockAccessToken('test-client'), expires_in: 1800}), + ), + ); + + const strategy = new JwtOAuthStrategy({ + clientId: 'test-client', + certPath: TEST_CERT_PATH, + keyPath: TEST_KEY_PATH, + accountManagerHost: AM_HOST, + }); + + const header = await strategy.getAuthorizationHeader(); + expect(header).to.match(/^Bearer .+\..+\..+$/); }); - it('should accept encrypted key with passphrase', () => { - expect(() => { - new JwtOAuthStrategy({ - clientId: 'test-client', - certPath: ENCRYPTED_CERT_PATH, - keyPath: ENCRYPTED_KEY_PATH, - passphrase: 'testpass123', - accountManagerHost: AM_HOST, - }); - }).to.not.throw(); + it('should accept encrypted key with passphrase and produce a Bearer Authorization header', async () => { + server.use( + http.post(AM_URL, () => + HttpResponse.json({access_token: createMockAccessToken('test-client'), expires_in: 1800}), + ), + ); + + const strategy = new JwtOAuthStrategy({ + clientId: 'test-client', + certPath: ENCRYPTED_CERT_PATH, + keyPath: ENCRYPTED_KEY_PATH, + passphrase: 'testpass123', + accountManagerHost: AM_HOST, + }); + + const header = await strategy.getAuthorizationHeader(); + expect(header).to.match(/^Bearer .+\..+\..+$/); }); it('should throw error for encrypted key without passphrase', () => { diff --git a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts index c4ea45694..09610485d 100644 --- a/packages/b2c-tooling-sdk/test/cli/base-command.test.ts +++ b/packages/b2c-tooling-sdk/test/cli/base-command.test.ts @@ -645,14 +645,17 @@ describe('cli/base-command', () => { expect(attrs.realm).to.be.undefined; }); - it('does not throw when resolvedConfig has unexpected shape', async () => { + it('skips telemetry attribute attachment when resolvedConfig has unexpected shape', async () => { stubParse(command); await command.init(); setupTelemetry(command); // Force a broken resolvedConfig to simulate unexpected runtime state command.setResolvedConfig(null as unknown as ReturnType); + telemetryAddAttributesStub.resetHistory(); expect(() => command.testAddTelemetryContext()).to.not.throw(); + // Telemetry must not record any attributes when config is malformed + expect(telemetryAddAttributesStub.called).to.be.false; }); it('does nothing when telemetry is not initialized', async () => { diff --git a/packages/b2c-tooling-sdk/test/clients/am-apiclients-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-apiclients-api.test.ts index 6d8ab0f85..f038a0534 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-apiclients-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-apiclients-api.test.ts @@ -44,20 +44,6 @@ describe('Account Manager API Clients API', () => { client = createAccountManagerApiClientsClient({hostname: TEST_HOST}, mockAuth); }); - describe('client creation', () => { - it('should create client with default host', () => { - const auth = new MockAuthStrategy(); - const c = createAccountManagerApiClientsClient({}, auth); - expect(c).to.exist; - }); - - it('should create client with custom host', () => { - const auth = new MockAuthStrategy(); - const c = createAccountManagerApiClientsClient({hostname: 'custom.host.com'}, auth); - expect(c).to.exist; - }); - }); - describe('listApiClients', () => { it('should list API clients with default pagination', async () => { const mockContent = [ diff --git a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts index db11c5535..c65d8345e 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-orgs-api.test.ts @@ -36,20 +36,6 @@ describe('Account Manager Organizations API Client', () => { client = createAccountManagerOrgsClient({hostname: TEST_HOST}, mockAuth); }); - describe('client creation', () => { - it('should create client with default host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerOrgsClient({}, auth); - expect(client).to.exist; - }); - - it('should create client with custom host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerOrgsClient({hostname: 'custom.host.com'}, auth); - expect(client).to.exist; - }); - }); - describe('getOrg', () => { it('should get organization by ID', async () => { const mockOrg = { diff --git a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts index af1308176..570f3f2f1 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-roles-api.test.ts @@ -36,20 +36,6 @@ describe('Account Manager Roles API Client', () => { client = createAccountManagerRolesClient({hostname: TEST_HOST}, mockAuth); }); - describe('client creation', () => { - it('should create client with default host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerRolesClient({}, auth); - expect(client).to.exist; - }); - - it('should create client with custom host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerRolesClient({hostname: 'custom.host.com'}, auth); - expect(client).to.exist; - }); - }); - describe('getRole', () => { it('should get role by ID', async () => { const mockRole = { @@ -63,15 +49,17 @@ describe('Account Manager Roles API Client', () => { }; server.use( - http.get(`${BASE_URL}/roles/bm-admin`, () => { + http.get(`${BASE_URL}/roles/bm-admin`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); return HttpResponse.json(mockRole); }), ); const role = await getRole(client, 'bm-admin'); - expect(role).to.deep.equal(mockRole); expect(role.id).to.equal('bm-admin'); + expect(role.roleEnumName).to.equal('ECOM_ADMIN'); + expect(role.permissions).to.deep.equal(['permission1', 'permission2']); }); it('should throw error when role not found', async () => { diff --git a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts index 830066196..796d6e06a 100644 --- a/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/am-users-api.test.ts @@ -49,20 +49,6 @@ describe('Account Manager Users API Client', () => { client = createAccountManagerUsersClient({hostname: TEST_HOST}, mockAuth); }); - describe('client creation', () => { - it('should create client with default host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerUsersClient({}, auth); - expect(client).to.exist; - }); - - it('should create client with custom host', () => { - const auth = new MockAuthStrategy(); - const client = createAccountManagerUsersClient({hostname: 'custom.host.com'}, auth); - expect(client).to.exist; - }); - }); - describe('getUser', () => { it('should get user by ID', async () => { const mockUser = { @@ -74,15 +60,17 @@ describe('Account Manager Users API Client', () => { }; server.use( - http.get(`${BASE_URL}/users/user-123`, () => { + http.get(`${BASE_URL}/users/user-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); return HttpResponse.json(mockUser); }), ); const user = await getUser(client, 'user-123'); - expect(user).to.deep.equal(mockUser); + expect(user.id).to.equal('user-123'); expect(user.mail).to.equal('user@example.com'); + expect(user.userState).to.equal('ENABLED'); }); it('should throw error when user not found', async () => { @@ -266,7 +254,8 @@ describe('Account Manager Users API Client', () => { const user = await findUserByLogin(client, 'user@example.com'); - expect(user).to.deep.equal(mockUser); + expect(user?.id).to.equal('user-123'); + expect(user?.mail).to.equal('user@example.com'); }); it('should return undefined when user not found', async () => { diff --git a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts index e860bdddb..f5754854b 100644 --- a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts @@ -240,7 +240,7 @@ describe('clients/custom-apis', () => { expect(response.status).to.equal(204); }); - it('handles pagination for large endpoint lists', async () => { + it('forwards offset/limit query params and parses paged response', async () => { server.use( http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, ({request}) => { const url = new URL(request.url); diff --git a/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts b/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts index 3df721952..79676c6f2 100644 --- a/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts @@ -28,10 +28,6 @@ describe('Granular Replications Client', () => { client = createGranularReplicationsClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, mockAuth); }); - it('should create client with config', () => { - expect(client).to.exist; - }); - describe('LIST granular-processes', () => { it('should list granular processes', async () => { server.use( @@ -115,6 +111,7 @@ describe('Granular Replications Client', () => { it('should queue product for publishing', async () => { server.use( http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, async ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); const body = (await request.json()) as Record; expect(body).to.deep.equal({product: {productId: 'PROD-1'}}); return HttpResponse.json({id: 'proc-123'}, {status: 201}); diff --git a/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts b/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts index e79778cc0..04e2a5c75 100644 --- a/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts @@ -279,12 +279,14 @@ describe('clients/ocapi', () => { expect(error).to.have.nested.property('fault.arguments'); }); - it('handles large result sets with count and total', async () => { + it('forwards start/count query params and parses paged response', async () => { const hostname = 'test.demandware.net'; const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + let capturedAuth: string | null = null; server.use( http.get(`${baseUrl}/products`, ({request}) => { + capturedAuth = request.headers.get('Authorization'); const url = new URL(request.url); const start = Number.parseInt(url.searchParams.get('start') || '0'); const count = Number.parseInt(url.searchParams.get('count') || '25'); @@ -309,11 +311,15 @@ describe('clients/ocapi', () => { }, }); - expect((data as any)?.total).to.equal(1000); - expect((data as any)?.count).to.equal(25); + // Authorization header is propagated through middleware + expect(capturedAuth).to.equal('Bearer test-token'); + // Forwarded query params land on the request and the parsed body reflects them expect((data as any)?.start).to.equal(50); + expect((data as any)?.count).to.equal(25); + // Body shape is parsed correctly: produces 25 items starting at index 50 expect((data as any)?.data).to.have.length(25); expect((data as any)?.data?.[0]?.id).to.equal('product-51'); + expect((data as any)?.data?.[24]?.id).to.equal('product-75'); }); }); }); diff --git a/packages/b2c-tooling-sdk/test/clients/ods.test.ts b/packages/b2c-tooling-sdk/test/clients/ods.test.ts index 14bc2651b..8a4eafa37 100644 --- a/packages/b2c-tooling-sdk/test/clients/ods.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/ods.test.ts @@ -36,31 +36,31 @@ describe('ODS Client', () => { odsClient = createOdsClient({host: TEST_HOST}, mockAuth); }); - describe('client creation', () => { - it('should create client with default host', () => { - const auth = new MockAuthStrategy(); - const client = createOdsClient({}, auth); - expect(client).to.exist; - }); - - it('should create client with custom host', () => { - const auth = new MockAuthStrategy(); - const client = createOdsClient({host: 'custom.host.com'}, auth); - expect(client).to.exist; - }); + describe('extraParams middleware', () => { + it('should forward configured extraParams query into outgoing request', async () => { + let capturedQuery: string | null = null; + server.use( + http.get(`${BASE_URL}/sandboxes`, ({request}) => { + const url = new URL(request.url); + capturedQuery = url.searchParams.get('debug'); + return HttpResponse.json({data: []}); + }), + ); - it('should create client with extra params', () => { const auth = new MockAuthStrategy(); - const client = createOdsClient( + const clientWithExtras = createOdsClient( { + host: TEST_HOST, extraParams: { query: {debug: 'true'}, - body: {_internal: {trace: true}}, }, }, auth, ); - expect(client).to.exist; + + const {error} = await clientWithExtras.GET('/sandboxes', {}); + expect(error).to.be.undefined; + expect(capturedQuery).to.equal('true'); }); }); @@ -159,15 +159,11 @@ describe('ODS Client', () => { describe('GET /sandboxes/{sandboxId}', () => { it('should get sandbox by ID', async () => { - const mockSandbox = { - id: 'sb-123', - realm: 'zzzv', - state: 'started', - }; - + let capturedAuth: string | null = null; server.use( - http.get(`${BASE_URL}/sandboxes/sb-123`, () => { - return HttpResponse.json({data: mockSandbox}); + http.get(`${BASE_URL}/sandboxes/sb-123`, ({request}) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({data: {id: 'sb-123', realm: 'zzzv', state: 'started'}}); }), ); @@ -177,10 +173,9 @@ describe('ODS Client', () => { }, }); + expect(capturedAuth).to.equal('Bearer test-token'); expect(error).to.be.undefined; expect(data?.data?.id).to.equal('sb-123'); - expect(data?.data?.state).to.equal('started'); - expect(data?.data?.realm).to.equal('zzzv'); }); it('should handle 404 not found', async () => { diff --git a/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts b/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts index 2d8b78033..d36fac87c 100644 --- a/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts @@ -294,7 +294,7 @@ describe('clients/slas-admin', () => { expect((data as any)?.total).to.equal(0); }); - it('handles pagination parameters', async () => { + it('forwards limit/offset query params across multiple pages', async () => { const shortCode = 'kv7kzm78'; const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; diff --git a/packages/b2c-tooling-sdk/test/config/sources.test.ts b/packages/b2c-tooling-sdk/test/config/sources.test.ts index c374c9e20..6051e59de 100644 --- a/packages/b2c-tooling-sdk/test/config/sources.test.ts +++ b/packages/b2c-tooling-sdk/test/config/sources.test.ts @@ -273,235 +273,91 @@ describe('config/sources', () => { }); describe('MobifySource', () => { - it('loads mrtApiKey from ~/.mobify', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } - - if (canMock) { - const mobifyPath = path.join(tempDir, '.mobify'); - fs.writeFileSync( - mobifyPath, - JSON.stringify({ - username: 'user@example.com', - api_key: 'test-api-key', - }), - ); - - const resolver = new ConfigResolver(); - const {config} = await resolver.resolve(); + it('loads mrtApiKey from credentialsFile path', async () => { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + username: 'user@example.com', + api_key: 'test-api-key', + }), + ); - expect(config.mrtApiKey).to.equal('test-api-key'); + const resolver = new ConfigResolver(); + const {config} = await resolver.resolve({}, {credentialsFile: mobifyPath}); - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } + expect(config.mrtApiKey).to.equal('test-api-key'); }); - it('returns undefined when ~/.mobify does not exist', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } - - if (canMock) { - const resolver = new ConfigResolver(); - const {config} = await resolver.resolve(); - - expect(config.mrtApiKey).to.be.undefined; - - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } - }); - - it('returns undefined when api_key is missing from ~/.mobify', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } + it('returns undefined when credentialsFile does not exist', async () => { + const resolver = new ConfigResolver(); + const {config} = await resolver.resolve({}, {credentialsFile: path.join(tempDir, '.mobify-missing')}); - if (canMock) { - const mobifyPath = path.join(tempDir, '.mobify'); - fs.writeFileSync( - mobifyPath, - JSON.stringify({ - username: 'user@example.com', - }), - ); + expect(config.mrtApiKey).to.be.undefined; + }); - const resolver = new ConfigResolver(); - const {config} = await resolver.resolve(); + it('returns undefined when api_key is missing from the credentialsFile', async () => { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + username: 'user@example.com', + }), + ); - expect(config.mrtApiKey).to.be.undefined; + const resolver = new ConfigResolver(); + const {config} = await resolver.resolve({}, {credentialsFile: mobifyPath}); - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } + expect(config.mrtApiKey).to.be.undefined; }); - it('handles cloudOrigin for custom mobify file', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } - - if (canMock) { - const mobifyPath = path.join(tempDir, '.mobify--example.com'); - fs.writeFileSync( - mobifyPath, - JSON.stringify({ - api_key: 'cloud-api-key', - }), - ); - - const resolver = new ConfigResolver(); - const {config} = await resolver.resolve({}, {cloudOrigin: 'https://example.com'}); + it('handles cloudOrigin-suffixed credentials file', async () => { + const mobifyPath = path.join(tempDir, '.mobify--example.com'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + api_key: 'cloud-api-key', + }), + ); - expect(config.mrtApiKey).to.equal('cloud-api-key'); + const resolver = new ConfigResolver(); + const {config} = await resolver.resolve({}, {credentialsFile: mobifyPath, cloudOrigin: 'https://example.com'}); - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } + expect(config.mrtApiKey).to.equal('cloud-api-key'); }); - it('creates SOURCE_ERROR warning for invalid JSON in ~/.mobify', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } - - if (canMock) { - const mobifyPath = path.join(tempDir, '.mobify'); - fs.writeFileSync(mobifyPath, 'invalid json'); - - const resolver = new ConfigResolver(); - const {config, warnings} = await resolver.resolve(); - - // Config should not have the API key - expect(config.mrtApiKey).to.be.undefined; - // Should have a SOURCE_ERROR warning for MobifySource - const sourceError = warnings.find((w) => w.code === 'SOURCE_ERROR' && w.message.includes('MobifySource')); - expect(sourceError).to.not.be.undefined; - expect(sourceError?.message).to.include('Failed to load configuration'); - - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } + it('creates SOURCE_ERROR warning for invalid JSON in credentialsFile', async () => { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync(mobifyPath, 'invalid json'); + + const resolver = new ConfigResolver(); + const {config, warnings} = await resolver.resolve({}, {credentialsFile: mobifyPath}); + + // Config should not have the API key + expect(config.mrtApiKey).to.be.undefined; + // Should have a SOURCE_ERROR warning for MobifySource + const sourceError = warnings.find((w) => w.code === 'SOURCE_ERROR' && w.message.includes('MobifySource')); + expect(sourceError).to.not.be.undefined; + expect(sourceError?.message).to.include('Failed to load configuration'); }); - it('provides location from load result', async function () { - const originalHomedir = os.homedir; - let canMock = false; - try { - Object.defineProperty(os, 'homedir', { - value: () => tempDir, - writable: true, - enumerable: true, - configurable: true, - }); - canMock = true; - } catch { - this.skip(); - } + it('provides location from load result', async () => { + const mobifyPath = path.join(tempDir, '.mobify'); + fs.writeFileSync( + mobifyPath, + JSON.stringify({ + api_key: 'test-api-key', + }), + ); - if (canMock) { - const mobifyPath = path.join(tempDir, '.mobify'); - fs.writeFileSync( - mobifyPath, - JSON.stringify({ - api_key: 'test-api-key', - }), - ); + const resolver = new ConfigResolver(); + const {sources} = await resolver.resolve({}, {credentialsFile: mobifyPath}); - const resolver = new ConfigResolver(); - const {sources} = await resolver.resolve(); - - const mobifySource = sources.find((s) => s.name === 'MobifySource'); - // Normalize paths to handle macOS symlinks - const expectedPath = fs.realpathSync(mobifyPath); - const actualLocation = mobifySource?.location ? fs.realpathSync(mobifySource.location) : undefined; - expect(actualLocation).to.equal(expectedPath); - - // Restore - Object.defineProperty(os, 'homedir', { - value: originalHomedir, - writable: true, - enumerable: true, - configurable: true, - }); - } + const mobifySource = sources.find((s) => s.name === 'MobifySource'); + // Normalize paths to handle macOS symlinks + const expectedPath = fs.realpathSync(mobifyPath); + const actualLocation = mobifySource?.location ? fs.realpathSync(mobifySource.location) : undefined; + expect(actualLocation).to.equal(expectedPath); }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts b/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts index 6a58e18fc..e11a748d8 100644 --- a/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts @@ -313,18 +313,50 @@ describe('operations/code/watch', () => { await errorPromise; }); - it('should stop watching when stop() is called', async () => { - // Create a cartridge directory + it('should stop watching when stop() is called and ignore subsequent file changes', async function () { + this.timeout(5000); + const cartridgeDir = path.join(tempDir, 'app_test'); fs.mkdirSync(cartridgeDir, {recursive: true}); fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); - watchResult = await watchCartridges(mockInstance, tempDir); + // Track upload calls; if any happen after stop(), this test must fail. + let uploadCount = 0; + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'PUT') uploadCount++; + return new HttpResponse(null, {status: 201}); + }), + ); - expect(watchResult.watcher).to.exist; + let onUploadCount = 0; + const result = await watchCartridges(mockInstance, tempDir, { + debounceTime: 50, + onUpload: () => { + onUploadCount++; + }, + }); - await watchResult.stop(); - watchResult = null; // Prevent double cleanup + // Allow watcher to settle + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Stop the watcher + await result.stop(); + + // Capture counts at the time of stop + const uploadCountBefore = uploadCount; + const onUploadCountBefore = onUploadCount; + + // Trigger a file change AFTER stop — must not be picked up. + fs.writeFileSync(path.join(cartridgeDir, 'after-stop.js'), 'console.log("ignored");'); + + // Wait long enough that an active watcher would have fired (debounce + buffer). + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(uploadCount).to.equal(uploadCountBefore, 'no PUT uploads should occur after stop()'); + expect(onUploadCount).to.equal(onUploadCountBefore, 'onUpload callback should not fire after stop()'); + + watchResult = null; // already stopped }); }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/organization-member.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/organization-member.test.ts index 26097e55c..3174c08ac 100644 --- a/packages/b2c-tooling-sdk/test/operations/mrt/organization-member.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/mrt/organization-member.test.ts @@ -31,10 +31,15 @@ describe('operations/mrt/organization-member', () => { server.close(); }); - it('listOrgMembers returns paginated results', async () => { + it('listOrgMembers forwards limit/offset query params and maps response to {count, members}', async () => { + let capturedLimit: string | null = null; + let capturedOffset: string | null = null; server.use( - http.get(`${DEFAULT_MRT_ORIGIN}/api/organizations/:org/members/`, () => - HttpResponse.json({ + http.get(`${DEFAULT_MRT_ORIGIN}/api/organizations/:org/members/`, ({request}) => { + const url = new URL(request.url); + capturedLimit = url.searchParams.get('limit'); + capturedOffset = url.searchParams.get('offset'); + return HttpResponse.json({ count: 2, next: null, previous: null, @@ -42,13 +47,22 @@ describe('operations/mrt/organization-member', () => { {user: 'a@x.com', email: 'a@x.com', role: 0, can_view_all_projects: true}, {user: 'b@x.com', email: 'b@x.com', role: 1, can_view_all_projects: false}, ], - }), - ), + }); + }), ); - const result = await listOrgMembers({organizationSlug: 'my-org'}, auth); + const result = await listOrgMembers({organizationSlug: 'my-org', limit: 25, offset: 50}, auth); + + // Query params forwarded into the request + expect(capturedLimit).to.equal('25'); + expect(capturedOffset).to.equal('50'); + + // SUT maps `results` → `members` (the observable transform on top of the raw API) expect(result.count).to.equal(2); + expect(result.next).to.be.null; + expect(result.previous).to.be.null; expect(result.members).to.have.lengthOf(2); + expect(result.members[0].email).to.equal('a@x.com'); }); it('addOrgMember posts the member and re-fetches the full record', async () => { diff --git a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts index e96d24bef..4382cc63c 100644 --- a/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/orgs/index.test.ts @@ -51,16 +51,17 @@ describe('operations/orgs', () => { }; server.use( - http.get(`${BASE_URL}/organizations/org-123`, () => { + http.get(`${BASE_URL}/organizations/org-123`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); return HttpResponse.json(mockOrg); }), ); const result = await getOrg(client, 'org-123'); - expect(result).to.deep.equal(mockOrg); expect(result.id).to.equal('org-123'); expect(result.name).to.equal('Test Organization'); + expect(result.realms).to.deep.equal(['realm1', 'realm2']); }); it('should throw error when organization not found', async () => { @@ -143,6 +144,7 @@ describe('operations/orgs', () => { server.use( http.get(`${BASE_URL}/organizations`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); const url = new URL(request.url); expect(url.searchParams.get('size')).to.equal('25'); expect(url.searchParams.get('page')).to.equal('0'); @@ -152,7 +154,9 @@ describe('operations/orgs', () => { const result = await listOrgs(client); - expect(result).to.deep.equal(mockOrgs); + expect(result.content).to.have.lengthOf(1); + expect(result.content[0].id).to.equal('org-1'); + expect(result.totalElements).to.equal(1); }); it('should list organizations with pagination', async () => { @@ -186,7 +190,10 @@ describe('operations/orgs', () => { const result = await listOrgs(client, {size: 50, page: 1}); - expect(result).to.deep.equal(mockOrgs); + expect(result.size).to.equal(50); + expect(result.number).to.equal(1); + expect(result.totalElements).to.equal(50); + expect(result.content).to.have.lengthOf(1); }); it('should list all organizations when all flag is set', async () => { diff --git a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts index 54494541c..563d5dd2b 100644 --- a/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts +++ b/packages/b2c-tooling-sdk/test/operations/roles/index.test.ts @@ -50,14 +50,32 @@ describe('operations/roles', () => { }; server.use( - http.get(`${BASE_URL}/roles/bm-admin`, () => { + http.get(`${BASE_URL}/roles/bm-admin`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); return HttpResponse.json(mockRole); }), ); const result = await getRole(client, 'bm-admin'); - expect(result).to.deep.equal(mockRole); + expect(result.id).to.equal('bm-admin'); + expect(result.roleEnumName).to.equal('ECOM_ADMIN'); + expect(result.permissions).to.deep.equal(['permission1', 'permission2']); + }); + + it('should throw a not-found error when role missing', async () => { + server.use( + http.get(`${BASE_URL}/roles/missing`, () => { + return HttpResponse.json({error: {message: 'Not found'}}, {status: 404}); + }), + ); + + try { + await getRole(client, 'missing'); + expect.fail('Should have thrown'); + } catch (error: unknown) { + expect((error as Error).message).to.include('Role missing not found'); + } }); }); @@ -75,6 +93,7 @@ describe('operations/roles', () => { server.use( http.get(`${BASE_URL}/roles`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); const url = new URL(request.url); expect(url.searchParams.get('size')).to.equal('20'); expect(url.searchParams.get('page')).to.equal('0'); @@ -84,55 +103,37 @@ describe('operations/roles', () => { const result = await listRoles(client); - expect(result).to.deep.equal(mockRoles); + expect(result.content).to.have.lengthOf(1); + expect(result.content?.[0].id).to.equal('bm-admin'); }); - it('should list roles with pagination', async () => { - const mockRoles = { - content: [ - { - id: 'bm-admin', - description: 'Business Manager Administrator', - }, - ], - }; - + it('should forward custom pagination to the request', async () => { server.use( http.get(`${BASE_URL}/roles`, ({request}) => { const url = new URL(request.url); expect(url.searchParams.get('size')).to.equal('50'); expect(url.searchParams.get('page')).to.equal('1'); - return HttpResponse.json(mockRoles); + return HttpResponse.json({content: []}); }), ); const result = await listRoles(client, {size: 50, page: 1}); - expect(result).to.deep.equal(mockRoles); + expect(result.content).to.deep.equal([]); }); - it('should list roles with target type filter', async () => { - const mockRoles = { - content: [ - { - id: 'bm-admin', - description: 'Business Manager Administrator', - targetType: 'User', - }, - ], - }; - + it('should forward roleTargetType filter to the request', async () => { server.use( http.get(`${BASE_URL}/roles`, ({request}) => { const url = new URL(request.url); expect(url.searchParams.get('roleTargetType')).to.equal('User'); - return HttpResponse.json(mockRoles); + return HttpResponse.json({content: []}); }), ); const result = await listRoles(client, {roleTargetType: 'User'}); - expect(result).to.deep.equal(mockRoles); + expect(result.content).to.deep.equal([]); }); }); }); diff --git a/packages/b2c-tooling-sdk/test/plugins/loader.test.ts b/packages/b2c-tooling-sdk/test/plugins/loader.test.ts index b2beb99ed..7691500ba 100644 --- a/packages/b2c-tooling-sdk/test/plugins/loader.test.ts +++ b/packages/b2c-tooling-sdk/test/plugins/loader.test.ts @@ -30,14 +30,6 @@ describe('plugins/loader', () => { expect(ctx).to.have.property('config').that.is.an('object'); }); - it('methods do not throw without a logger', () => { - const ctx = createHookContext(); - expect(() => ctx.debug('test')).to.not.throw(); - expect(() => ctx.log('test')).to.not.throw(); - expect(() => ctx.warn('test')).to.not.throw(); - expect(() => ctx.error('test')).to.not.throw(); - }); - it('routes messages through provided logger', () => { const messages: {level: string; msg: string}[] = []; const logger = {