diff --git a/packages/b2c-tooling-sdk/test/auth/api-key.test.ts b/packages/b2c-tooling-sdk/test/auth/api-key.test.ts new file mode 100644 index 00000000..db6090de --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/api-key.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {ApiKeyStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('auth/api-key', () => { + describe('ApiKeyStrategy', () => { + it('getAuthorizationHeader returns Bearer token when headerName is Authorization', async () => { + const auth = new ApiKeyStrategy('my-key', 'Authorization'); + const header = await auth.getAuthorizationHeader(); + expect(header).to.equal('Bearer my-key'); + }); + + it('getAuthorizationHeader returns raw key when headerName is not Authorization', async () => { + const auth = new ApiKeyStrategy('my-key', 'x-api-key'); + const header = await auth.getAuthorizationHeader(); + expect(header).to.equal('my-key'); + }); + + it('fetch injects configured header and preserves existing headers', async () => { + const originalFetch = globalThis.fetch; + try { + let seenAuth: string | null = null; + let seenCustom: string | null = null; + + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenAuth = headers.get('Authorization'); + seenCustom = headers.get('x-custom'); + return new Response('ok', {status: 200}); + }) as typeof fetch; + + const auth = new ApiKeyStrategy('my-key', 'Authorization'); + const res = await auth.fetch('https://example.com', {headers: {'x-custom': '1'}}); + + expect(res.status).to.equal(200); + expect(seenCustom).to.equal('1'); + expect(seenAuth).to.equal('Bearer my-key'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('fetch injects raw key for non-Authorization headerName', async () => { + const originalFetch = globalThis.fetch; + try { + let seenKey: string | null = null; + + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenKey = headers.get('x-api-key'); + return new Response('ok', {status: 200}); + }) as typeof fetch; + + const auth = new ApiKeyStrategy('my-key', 'x-api-key'); + await auth.fetch('https://example.com'); + + expect(seenKey).to.equal('my-key'); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/auth/basic.test.ts b/packages/b2c-tooling-sdk/test/auth/basic.test.ts new file mode 100644 index 00000000..cb0894b3 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/basic.test.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {BasicAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('auth/basic', () => { + describe('BasicAuthStrategy', () => { + it('getAuthorizationHeader returns a Basic header with base64 encoded user:pass', async () => { + const auth = new BasicAuthStrategy('user', 'pass'); + const header = await auth.getAuthorizationHeader(); + expect(header).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`); + }); + + it('fetch injects Authorization header and preserves existing headers', async () => { + const originalFetch = globalThis.fetch; + try { + let seenHeader: string | null = null; + let seenCustom: string | null = null; + + globalThis.fetch = (async (_url: string | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenHeader = headers.get('Authorization'); + seenCustom = headers.get('x-custom'); + return new Response('ok', {status: 200}); + }) as typeof fetch; + + const auth = new BasicAuthStrategy('user', 'pass'); + const res = await auth.fetch('https://example.com', {headers: {'x-custom': '1'}}); + + expect(res.status).to.equal(200); + expect(seenCustom).to.equal('1'); + expect(seenHeader).to.equal(`Basic ${Buffer.from('user:pass').toString('base64')}`); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/auth/index.test.ts b/packages/b2c-tooling-sdk/test/auth/index.test.ts new file mode 100644 index 00000000..c661fb73 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/index.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + ALL_AUTH_METHODS, + ApiKeyStrategy, + BasicAuthStrategy, + ImplicitOAuthStrategy, + OAuthStrategy, + checkAvailableAuthMethods, + decodeJWT, + resolveAuthStrategy, +} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('auth/index', () => { + it('exports core strategies and helpers from the auth entrypoint', () => { + expect(ALL_AUTH_METHODS).to.be.an('array'); + expect(ApiKeyStrategy).to.be.a('function'); + expect(BasicAuthStrategy).to.be.a('function'); + expect(ImplicitOAuthStrategy).to.be.a('function'); + expect(OAuthStrategy).to.be.a('function'); + expect(checkAvailableAuthMethods).to.be.a('function'); + expect(resolveAuthStrategy).to.be.a('function'); + expect(decodeJWT).to.be.a('function'); + }); + + it('ALL_AUTH_METHODS is stable and can be iterated', () => { + const methods = [...ALL_AUTH_METHODS]; + expect(methods).to.include('implicit'); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/auth/oauth-implicit.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth-implicit.test.ts new file mode 100644 index 00000000..33c45f0a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/oauth-implicit.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {ImplicitOAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; + +type TokenResponse = { + accessToken: string; + expires: Date; + scopes: string[]; +}; + +function futureDate(minutes: number): Date { + return new Date(Date.now() + minutes * 60 * 1000); +} + +describe('auth/oauth-implicit', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('adds Authorization and x-dw-client-id headers on fetch', async () => { + const clientId = 'implicit-client-headers'; + const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']}); + + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => ({ + accessToken: 'tok-1', + expires: futureDate(30), + scopes: ['a'], + }); + + let seenAuth: string | null = null; + let seenClientId: string | null = null; + + globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers); + seenAuth = headers.get('Authorization'); + seenClientId = headers.get('x-dw-client-id'); + return new Response('ok', {status: 200}); + }; + + const res = await strategy.fetch('https://example.com/test'); + expect(res.status).to.equal(200); + expect(seenAuth).to.equal('Bearer tok-1'); + expect(seenClientId).to.equal(clientId); + + strategy.invalidateToken(); + }); + + it('retries once on 401 after invalidating the cached token', async () => { + const clientId = 'implicit-client-401'; + const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']}); + + let tokenCalls = 0; + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => { + tokenCalls++; + return { + accessToken: tokenCalls === 1 ? 'tok-1' : 'tok-2', + expires: futureDate(30), + scopes: ['a'], + }; + }; + + const seenAuth: string[] = []; + let fetchCalls = 0; + + globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => { + fetchCalls++; + const headers = new Headers(init?.headers); + seenAuth.push(headers.get('Authorization') ?? ''); + + if (fetchCalls === 1) { + return new Response('unauthorized', {status: 401}); + } + + return new Response('ok', {status: 200}); + }; + + const res = await strategy.fetch('https://example.com/test'); + expect(res.status).to.equal(200); + + expect(fetchCalls).to.equal(2); + expect(tokenCalls).to.equal(2); + expect(seenAuth[0]).to.equal('Bearer tok-1'); + expect(seenAuth[1]).to.equal('Bearer tok-2'); + + strategy.invalidateToken(); + }); + + it('reuses cached token when scopes and expiry are valid', async () => { + const clientId = 'implicit-client-cache'; + const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']}); + + let tokenCalls = 0; + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => { + tokenCalls++; + return { + accessToken: 'tok-cache', + expires: futureDate(30), + scopes: ['a'], + }; + }; + + const t1 = await strategy.getTokenResponse(); + const t2 = await strategy.getTokenResponse(); + + expect(tokenCalls).to.equal(1); + expect(t2.accessToken).to.equal(t1.accessToken); + + strategy.invalidateToken(); + }); + + it('re-authenticates when cached token is missing required scopes', async () => { + const clientId = 'implicit-client-scopes'; + const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a', 'b']}); + + let tokenCalls = 0; + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => { + tokenCalls++; + return { + accessToken: tokenCalls === 1 ? 'tok-missing-scope' : 'tok-all-scopes', + expires: futureDate(30), + scopes: tokenCalls === 1 ? ['a'] : ['a', 'b'], + }; + }; + + const t1 = await strategy.getTokenResponse(); + const t2 = await strategy.getTokenResponse(); + + expect(tokenCalls).to.equal(2); + expect(t1.accessToken).to.equal('tok-missing-scope'); + expect(t2.accessToken).to.equal('tok-all-scopes'); + + strategy.invalidateToken(); + }); + + it('deduplicates concurrent token requests using pending auth mutex', async () => { + const clientId = 'implicit-client-pending'; + const strategy = new ImplicitOAuthStrategy({clientId, scopes: ['a']}); + + let resolveToken: ((t: TokenResponse) => void) | undefined; + let tokenCalls = 0; + + (strategy as unknown as {implicitFlowLogin: () => Promise}).implicitFlowLogin = async () => { + tokenCalls++; + return await new Promise((resolve) => { + resolveToken = resolve; + }); + }; + + globalThis.fetch = async () => new Response('ok', {status: 200}); + + const p1 = strategy.fetch('https://example.com/test'); + const p2 = strategy.fetch('https://example.com/test'); + + if (!resolveToken) { + throw new Error('Expected token request to be started'); + } + + resolveToken({accessToken: 'tok-pending', expires: futureDate(30), scopes: ['a']}); + + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1.status).to.equal(200); + expect(r2.status).to.equal(200); + expect(tokenCalls).to.equal(1); + + strategy.invalidateToken(); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts index e76c9198..f3c74fe9 100644 --- a/packages/b2c-tooling-sdk/test/auth/oauth.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/oauth.test.ts @@ -3,11 +3,350 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import {expect} from 'chai'; -import {OAuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {OAuthStrategy, decodeJWT} from '@salesforce/b2c-tooling-sdk/auth'; + +const AM_HOST = 'account.demandware.com'; +const AM_URL = `https://${AM_HOST}/dwsso/oauth2/access_token`; +const TEST_API_URL = 'https://api.test.com/endpoint'; describe('auth/oauth', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + // Clear token cache between tests to avoid interference + // We need to create a dummy strategy and invalidate to clear the cache + const dummy = new OAuthStrategy({clientId: 'test-client', clientSecret: 'test-secret'}); + dummy.invalidateToken(); + }); + + after(() => { + server.close(); + }); + + describe('decodeJWT', () => { + it('should decode a valid JWT', () => { + // Create a simple JWT: header.payload.signature + const header = {alg: 'HS256', typ: 'JWT'}; + const payload = {sub: '1234567890', name: 'John Doe', iat: 1516239022}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + const jwt = `${headerB64}.${payloadB64}.signature`; + + const decoded = decodeJWT(jwt); + + expect(decoded.header).to.deep.equal(header); + expect(decoded.payload).to.deep.equal(payload); + }); + + it('should throw error for invalid JWT format', () => { + expect(() => decodeJWT('invalid')).to.throw('Invalid JWT format'); + expect(() => decodeJWT('only.two')).to.throw('Invalid JWT format'); + }); + }); + describe('OAuthStrategy', () => { + describe('constructor', () => { + it('should create strategy with default account manager host', () => { + const strategy = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + }); + + expect(strategy).to.be.instanceOf(OAuthStrategy); + }); + + it('should create strategy with custom account manager host', () => { + const strategy = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + accountManagerHost: 'custom.host.com', + }); + + expect(strategy).to.be.instanceOf(OAuthStrategy); + }); + }); + + describe('clientCredentialsGrant', () => { + it('should fetch access token successfully', async () => { + const mockToken = createMockJWT({sub: 'test-client'}); + + server.use( + http.post(AM_URL, () => { + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: 'sfcc.sandbox.manage', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['sfcc.sandbox.manage'], + }); + + const tokenResponse = await strategy.getTokenResponse(); + + expect(tokenResponse.accessToken).to.equal(mockToken); + expect(tokenResponse.scopes).to.deep.equal(['sfcc.sandbox.manage']); + expect(tokenResponse.expires).to.be.instanceOf(Date); + }); + + it('should handle token request without scopes', async () => { + const mockToken = createMockJWT({sub: 'test-client-noscope'}); + + server.use( + http.post(AM_URL, () => { + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'test-client-noscope', + clientSecret: 'test-secret', + }); + + const tokenResponse = await strategy.getTokenResponse(); + + expect(tokenResponse.accessToken).to.equal(mockToken); + expect(tokenResponse.scopes).to.deep.equal([]); + }); + + it('should throw error on failed token request', async () => { + server.use( + http.post(AM_URL, () => { + return new HttpResponse('Unauthorized', {status: 401}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId: 'bad-client', + clientSecret: 'bad-secret', + }); + + try { + await strategy.getTokenResponse(); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to get access token'); + expect(error.message).to.include('401'); + } + }); + }); + + describe('token caching', () => { + it('should cache and reuse valid tokens', async () => { + const clientId = 'test-client-cache'; + const mockToken = createMockJWT({sub: clientId}); + let requestCount = 0; + + server.use( + http.post(AM_URL, () => { + requestCount++; + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + scope: 'sfcc.sandbox.manage', + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + scopes: ['sfcc.sandbox.manage'], + }); + + // First call - should fetch + const token1 = await strategy.getTokenResponse(); + expect(requestCount).to.equal(1); + + // Second call - should use cache + const token2 = await strategy.getTokenResponse(); + expect(requestCount).to.equal(1); // No additional request + expect(token2.accessToken).to.equal(token1.accessToken); + }); + + it('should invalidate and refetch token when invalidateToken is called', async () => { + const clientId = 'test-client-invalidate'; + const mockToken = createMockJWT({sub: clientId}); + let requestCount = 0; + + server.use( + http.post(AM_URL, () => { + requestCount++; + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + }); + + // First call + await strategy.getTokenResponse(); + expect(requestCount).to.equal(1); + + // Invalidate + strategy.invalidateToken(); + + // Third call - should fetch again + await strategy.getTokenResponse(); + expect(requestCount).to.equal(2); + }); + }); + + describe('fetch', () => { + it('should add authorization header to requests', async () => { + const clientId = 'test-client-fetch'; + const mockToken = createMockJWT({sub: clientId}); + + server.use( + http.post(AM_URL, () => { + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + http.get(TEST_API_URL, ({request}) => { + const authHeader = request.headers.get('Authorization'); + const clientIdHeader = request.headers.get('x-dw-client-id'); + + if (authHeader === `Bearer ${mockToken}` && clientIdHeader === clientId) { + return HttpResponse.json({success: true}); + } + + return new HttpResponse(null, {status: 401}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + }); + + const response = await strategy.fetch(TEST_API_URL); + + expect(response.status).to.equal(200); + const data = await response.json(); + expect(data).to.deep.equal({success: true}); + }); + + it('should retry on 401 with fresh token', async () => { + const clientId = 'test-client-retry'; + const mockToken1 = createMockJWT({sub: clientId, iat: 1000}); + const mockToken2 = createMockJWT({sub: clientId, iat: 2000}); + let tokenRequestCount = 0; + let apiRequestCount = 0; + + server.use( + http.post(AM_URL, () => { + tokenRequestCount++; + const token = tokenRequestCount === 1 ? mockToken1 : mockToken2; + return HttpResponse.json({ + access_token: token, + expires_in: 1800, + }); + }), + http.get(TEST_API_URL, ({request}) => { + apiRequestCount++; + const authHeader = request.headers.get('Authorization'); + + // First request with old token fails + if (apiRequestCount === 1 && authHeader === `Bearer ${mockToken1}`) { + return new HttpResponse(null, {status: 401}); + } + + // Second request with new token succeeds + if (apiRequestCount === 2 && authHeader === `Bearer ${mockToken2}`) { + return HttpResponse.json({success: true}); + } + + return new HttpResponse(null, {status: 401}); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + }); + + const response = await strategy.fetch(TEST_API_URL); + + expect(tokenRequestCount).to.equal(2); // Fetched twice + expect(apiRequestCount).to.equal(2); // API called twice + expect(response.status).to.equal(200); + }); + }); + + describe('getAuthorizationHeader', () => { + it('should return Bearer token header', async () => { + const clientId = 'test-client-authheader'; + const mockToken = createMockJWT({sub: clientId}); + + server.use( + http.post(AM_URL, () => { + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + }); + + const header = await strategy.getAuthorizationHeader(); + + expect(header).to.equal(`Bearer ${mockToken}`); + }); + }); + + describe('getJWT', () => { + it('should return decoded JWT', async () => { + const clientId = 'test-client-jwt'; + const payload = {sub: clientId, name: 'Test Client', iat: 1516239022}; + const mockToken = createMockJWT(payload); + + server.use( + http.post(AM_URL, () => { + return HttpResponse.json({ + access_token: mockToken, + expires_in: 1800, + }); + }), + ); + + const strategy = new OAuthStrategy({ + clientId, + clientSecret: 'test-secret', + }); + + const jwt = await strategy.getJWT(); + + expect(jwt.payload).to.deep.include(payload); + }); + }); + describe('withAdditionalScopes', () => { it('creates new strategy with additional scopes', () => { const original = new OAuthStrategy({ @@ -80,3 +419,13 @@ describe('auth/oauth', () => { }); }); }); + +/** + * Helper to create a mock JWT token + */ +function createMockJWT(payload: Record): string { + const header = {alg: 'HS256', typ: 'JWT'}; + const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64'); + const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64'); + return `${headerB64}.${payloadB64}.mock-signature`; +} diff --git a/packages/b2c-tooling-sdk/test/auth/resolve.test.ts b/packages/b2c-tooling-sdk/test/auth/resolve.test.ts index 3a2d7eaa..e329c8f4 100644 --- a/packages/b2c-tooling-sdk/test/auth/resolve.test.ts +++ b/packages/b2c-tooling-sdk/test/auth/resolve.test.ts @@ -3,8 +3,17 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import {expect} from 'chai'; -import {checkAvailableAuthMethods} from '@salesforce/b2c-tooling-sdk/auth'; +import { + ApiKeyStrategy, + BasicAuthStrategy, + ImplicitOAuthStrategy, + OAuthStrategy, + checkAvailableAuthMethods, + resolveAuthStrategy, +} from '@salesforce/b2c-tooling-sdk/auth'; describe('auth/resolve', () => { describe('checkAvailableAuthMethods', () => { @@ -62,6 +71,17 @@ describe('auth/resolve', () => { expect(result.unavailable[1].reason).to.equal('clientId is required'); }); + it('returns unavailable with reason when password is missing for basic', () => { + const result = checkAvailableAuthMethods({username: 'test-user'}, ['basic']); + + expect(result.available).to.have.length(0); + expect(result.unavailable).to.have.length(1); + expect(result.unavailable[0]).to.deep.equal({ + method: 'basic', + reason: 'password is required', + }); + }); + it('only checks allowed methods', () => { const result = checkAvailableAuthMethods( { @@ -93,4 +113,126 @@ describe('auth/resolve', () => { expect(result.available).to.include('api-key'); }); }); + + describe('resolveAuthStrategy', () => { + it('returns OAuthStrategy when clientId and clientSecret are provided', () => { + const strategy = resolveAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + scopes: ['scope-a'], + }); + + expect(strategy).to.be.instanceOf(OAuthStrategy); + }); + + it('returns ImplicitOAuthStrategy when only clientId is provided', () => { + const strategy = resolveAuthStrategy({ + clientId: 'test-client', + scopes: ['scope-a'], + }); + + expect(strategy).to.be.instanceOf(ImplicitOAuthStrategy); + }); + + it('returns BasicAuthStrategy when username and password are provided and allowedMethods restricts to basic', () => { + const strategy = resolveAuthStrategy( + { + username: 'user', + password: 'pass', + }, + {allowedMethods: ['basic']}, + ); + + expect(strategy).to.be.instanceOf(BasicAuthStrategy); + }); + + it('returns ApiKeyStrategy when apiKey is provided and allowedMethods restricts to api-key', () => { + const strategy = resolveAuthStrategy( + { + apiKey: 'key', + apiKeyHeaderName: 'X-Api-Key', + }, + {allowedMethods: ['api-key']}, + ); + + expect(strategy).to.be.instanceOf(ApiKeyStrategy); + }); + + it('respects allowedMethods ordering (picks basic before client-credentials when basic is first)', () => { + const strategy = resolveAuthStrategy( + { + clientId: 'test-client', + clientSecret: 'test-secret', + username: 'user', + password: 'pass', + }, + {allowedMethods: ['basic', 'client-credentials']}, + ); + + expect(strategy).to.be.instanceOf(BasicAuthStrategy); + }); + + it('throws a helpful error when no allowed method has required credentials', () => { + try { + resolveAuthStrategy( + { + clientId: 'test-client', + }, + {allowedMethods: ['client-credentials', 'basic']}, + ); + expect.fail('Expected resolveAuthStrategy to throw'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.include('No valid auth method available'); + expect((err as Error).message).to.include('Allowed methods: [client-credentials, basic]'); + expect((err as Error).message).to.include('client-credentials: clientSecret is required'); + expect((err as Error).message).to.include('basic: username is required'); + } + }); + + it('throws error for unknown allowed method', () => { + try { + resolveAuthStrategy( + { + clientId: 'test-client', + clientSecret: 'test-secret', + }, + {allowedMethods: ['unknown-method' as any]}, + ); + expect.fail('Expected resolveAuthStrategy to throw'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.include('No valid auth method available'); + expect((err as Error).message).to.include('Allowed methods: [unknown-method]'); + } + }); + + it('throws error when allowedMethods is explicitly empty array', () => { + // With explicitly empty allowedMethods array, no methods are allowed + try { + resolveAuthStrategy( + { + clientId: 'test-client', + clientSecret: 'test-secret', + }, + {allowedMethods: []}, + ); + expect.fail('Expected resolveAuthStrategy to throw'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.include('No valid auth method available'); + expect((err as Error).message).to.include('Allowed methods: []'); + } + }); + + it('falls back to default when allowedMethods is undefined', () => { + const strategy = resolveAuthStrategy({ + clientId: 'test-client', + clientSecret: 'test-secret', + }); + + // Without allowedMethods option, should default to all methods + expect(strategy).to.be.instanceOf(OAuthStrategy); + }); + }); }); diff --git a/packages/b2c-tooling-sdk/test/auth/types.test.ts b/packages/b2c-tooling-sdk/test/auth/types.test.ts new file mode 100644 index 00000000..fd2e5f6a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/auth/types.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {ALL_AUTH_METHODS} from '@salesforce/b2c-tooling-sdk/auth'; + +describe('auth/types', () => { + it('exports ALL_AUTH_METHODS in default priority order', () => { + expect(ALL_AUTH_METHODS).to.deep.equal(['client-credentials', 'implicit', 'basic', 'api-key']); + }); +}); 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 d2b22ceb..8e5c3078 100644 --- a/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/custom-apis.test.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2 * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; @@ -172,6 +174,159 @@ describe('clients/custom-apis', () => { expect(error).to.have.property('title', 'Bad Request'); expect(error).to.have.property('detail'); }); + + it('handles POST requests to register endpoints', async () => { + server.use( + http.post(`${BASE_URL}/organizations/:organizationId/endpoints`, async ({request, params}) => { + const body = (await request.json()) as any; + + expect(params.organizationId).to.equal('f_ecom_zzxy_prd'); + expect(body.apiName).to.equal('loyalty-info'); + expect(body.httpMethod).to.equal('GET'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + + return HttpResponse.json( + { + id: 'endpoint-123', + apiName: 'loyalty-info', + apiVersion: 'v1', + httpMethod: 'GET', + endpointPath: '/loyalty', + status: 'active', + }, + {status: 201}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await (client as any).POST('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + body: { + apiName: 'loyalty-info', + apiVersion: 'v1', + httpMethod: 'GET', + endpointPath: '/loyalty', + cartridgeName: 'app_custom', + }, + }); + + expect(error).to.be.undefined; + expect(data).to.have.property('id', 'endpoint-123'); + expect(data).to.have.property('status', 'active'); + }); + + it('handles DELETE requests to unregister endpoints', async () => { + server.use( + http.delete(`${BASE_URL}/organizations/:organizationId/endpoints/:endpointId`, ({params}) => { + expect(params.organizationId).to.equal('f_ecom_zzxy_prd'); + expect(params.endpointId).to.equal('endpoint-123'); + return new HttpResponse(null, {status: 204}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {response, error} = await (client as any).DELETE('/organizations/{organizationId}/endpoints/{endpointId}', { + params: { + path: {organizationId: 'f_ecom_zzxy_prd', endpointId: 'endpoint-123'}, + }, + }); + + expect(error).to.be.undefined; + expect(response.status).to.equal(204); + }); + + it('handles pagination for large endpoint lists', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints`, ({request}) => { + const url = new URL(request.url); + const offset = Number.parseInt(url.searchParams.get('offset') || '0'); + const limit = Number.parseInt(url.searchParams.get('limit') || '10'); + + return HttpResponse.json({ + limit, + offset, + total: 100, + data: Array.from({length: limit}, (_, i) => ({ + id: `endpoint-${offset + i + 1}`, + apiName: `api-${offset + i + 1}`, + status: 'active', + })), + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data} = await client.GET('/organizations/{organizationId}/endpoints', { + params: { + path: {organizationId: 'f_ecom_zzxy_prd'}, + query: {offset: 10, limit: 20} as any, + }, + }); + + expect((data as any)?.total).to.equal(100); + expect((data as any)?.offset).to.equal(10); + expect((data as any)?.limit).to.equal(20); + expect(data?.data).to.have.length(20); + expect(data?.data?.[0]?.id).to.equal('endpoint-11'); + }); + + it('handles 404 for non-existent endpoints', async () => { + server.use( + http.get(`${BASE_URL}/organizations/:organizationId/endpoints/:endpointId`, () => { + return HttpResponse.json( + { + title: 'Not Found', + type: 'https://api.commercecloud.salesforce.com/documentation/error/v1/errors/not-found', + detail: 'Endpoint not found', + }, + {status: 404}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await (client as any).GET('/organizations/{organizationId}/endpoints/{endpointId}', { + params: {path: {organizationId: 'f_ecom_zzxy_prd', endpointId: 'nonexistent'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('title', 'Not Found'); + }); + + it('handles authorization errors (403)', async () => { + server.use( + http.post(`${BASE_URL}/organizations/:organizationId/endpoints`, () => { + return HttpResponse.json( + { + title: 'Forbidden', + type: 'https://api.commercecloud.salesforce.com/documentation/error/v1/errors/forbidden', + detail: 'Insufficient permissions to register endpoints', + }, + {status: 403}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createCustomApisClient({shortCode: SHORT_CODE, tenantId: TENANT_ID}, auth); + + const {data, error} = await (client as any).POST('/organizations/{organizationId}/endpoints', { + params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + body: {apiName: 'test'}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('title', 'Forbidden'); + }); }); describe('toOrganizationId', () => { diff --git a/packages/b2c-tooling-sdk/test/clients/middleware-registry.test.ts b/packages/b2c-tooling-sdk/test/clients/middleware-registry.test.ts new file mode 100644 index 00000000..03e1ab3a --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/middleware-registry.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import type {UnifiedMiddleware} from '@salesforce/b2c-tooling-sdk/clients'; +import {MiddlewareRegistry} from '@salesforce/b2c-tooling-sdk/clients'; + +describe('clients/middleware-registry', () => { + it('register() adds providers and getProviderNames() returns them', () => { + const registry = new MiddlewareRegistry(); + + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + registry.register({ + name: 'p2', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(2); + expect(registry.getProviderNames()).to.deep.equal(['p1', 'p2']); + }); + + it('unregister() removes an existing provider by name', () => { + const registry = new MiddlewareRegistry(); + + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(1); + expect(registry.unregister('p1')).to.equal(true); + expect(registry.size).to.equal(0); + }); + + it('unregister() returns false when provider does not exist', () => { + const registry = new MiddlewareRegistry(); + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.unregister('missing')).to.equal(false); + expect(registry.size).to.equal(1); + }); + + it('getMiddleware() returns middleware in registration order and skips undefined', () => { + const registry = new MiddlewareRegistry(); + + const m1: UnifiedMiddleware = { + async onRequest({request}) { + request.headers.set('x-m1', '1'); + return request; + }, + }; + + const m2: UnifiedMiddleware = { + async onRequest({request}) { + request.headers.set('x-m2', '2'); + return request; + }, + }; + + registry.register({ + name: 'skip', + getMiddleware() { + return undefined; + }, + }); + + registry.register({ + name: 'p1', + getMiddleware() { + return m1; + }, + }); + + registry.register({ + name: 'p2', + getMiddleware() { + return m2; + }, + }); + + const middlewares = registry.getMiddleware('ocapi'); + expect(middlewares).to.have.length(2); + expect(middlewares[0]).to.equal(m1); + expect(middlewares[1]).to.equal(m2); + }); + + it('clear() removes all providers', () => { + const registry = new MiddlewareRegistry(); + registry.register({ + name: 'p1', + getMiddleware() { + return undefined; + }, + }); + + expect(registry.size).to.equal(1); + registry.clear(); + expect(registry.size).to.equal(0); + expect(registry.getProviderNames()).to.deep.equal([]); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/middleware.test.ts b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts new file mode 100644 index 00000000..5e7ba2a1 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/middleware.test.ts @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + createAuthMiddleware, + createExtraParamsMiddleware, + createLoggingMiddleware, +} from '@salesforce/b2c-tooling-sdk/clients'; +import type {AuthStrategy} from '@salesforce/b2c-tooling-sdk/auth'; +import {configureLogger, resetLogger} from '@salesforce/b2c-tooling-sdk/logging'; + +describe('clients/middleware', () => { + describe('createAuthMiddleware', () => { + it('adds Authorization header when auth strategy provides getAuthorizationHeader', async () => { + const auth: AuthStrategy = { + async fetch() { + throw new Error('not used in this unit test'); + }, + async getAuthorizationHeader() { + return 'Bearer test-token'; + }, + }; + + const middleware = createAuthMiddleware(auth); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/ping', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('Authorization')).to.equal('Bearer test-token'); + }); + + it('does not set Authorization header when auth strategy does not provide getAuthorizationHeader', async () => { + const auth: AuthStrategy = { + async fetch() { + throw new Error('not used in this unit test'); + }, + }; + + const middleware = createAuthMiddleware(auth); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/ping', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.headers.get('Authorization')).to.equal(null); + }); + }); + + describe('createExtraParamsMiddleware', () => { + it('adds extra query params without overriding explicit query params', async () => { + const middleware = createExtraParamsMiddleware({query: {debug: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items?a=1', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const url = new URL(modifiedRequest.url); + expect(url.searchParams.get('a')).to.equal('1'); + expect(url.searchParams.get('debug')).to.equal('true'); + }); + + it('merges extra body fields into JSON requests', async () => { + const middleware = createExtraParamsMiddleware({body: {forced: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({name: 'x'}), + duplex: 'half', + } as RequestInit); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + const body = JSON.parse(await modifiedRequest.text()) as Record; + + expect(body).to.deep.include({name: 'x'}); + expect(body).to.deep.include({forced: true}); + }); + + it('creates a new JSON body when request has no body', async () => { + const middleware = createExtraParamsMiddleware({body: {forced: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {}, + }); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + expect(modifiedRequest.headers.get('content-type')).to.include('application/json'); + + const body = JSON.parse(await modifiedRequest.text()) as Record; + expect(body).to.deep.equal({forced: true}); + }); + + it('does not throw if body is invalid JSON (skips merge)', async () => { + const middleware = createExtraParamsMiddleware({body: {forced: true}}); + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: 'not-json', + duplex: 'half', + } as RequestInit); + + type OnRequestParams = Parameters>[0]; + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + const text = await modifiedRequest.text(); + + expect(text).to.equal('not-json'); + expect(text).to.not.include('forced'); + }); + + it('does not merge extra body when request is not JSON', async () => { + const middleware = createExtraParamsMiddleware({body: {forced: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {'content-type': 'text/plain'}, + body: 'hello', + duplex: 'half', + } as RequestInit); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + const text = await modifiedRequest.text(); + + expect(text).to.equal('hello'); + expect(text).to.not.include('forced'); + }); + + it('skips adding query params when value is undefined', async () => { + const middleware = createExtraParamsMiddleware({query: {debug: undefined}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items?a=1', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const url = new URL(modifiedRequest.url); + expect(url.searchParams.has('debug')).to.equal(false); + expect(url.searchParams.get('a')).to.equal('1'); + }); + + it('does nothing when config is empty', async () => { + const middleware = createExtraParamsMiddleware({}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items?a=1', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.url).to.equal(request.url); + }); + + it('does nothing when query config is an empty object', async () => { + const middleware = createExtraParamsMiddleware({query: {}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items?a=1', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + expect(modifiedRequest.url).to.equal(request.url); + }); + + it('adds empty-string query values', async () => { + const middleware = createExtraParamsMiddleware({query: {debug: ''}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const url = new URL(modifiedRequest.url); + expect(url.searchParams.has('debug')).to.equal(true); + expect(url.searchParams.get('debug')).to.equal(''); + }); + + it('overwrites existing query params when the same key is provided', async () => { + const middleware = createExtraParamsMiddleware({query: {debug: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items?debug=false', {method: 'GET'}); + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const url = new URL(modifiedRequest.url); + expect(url.searchParams.get('debug')).to.equal('true'); + }); + + it('overwrites body fields when extra body contains same key', async () => { + const middleware = createExtraParamsMiddleware({body: {forced: true}}); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({forced: false}), + duplex: 'half', + } as RequestInit); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const body = JSON.parse(await modifiedRequest.text()) as Record; + expect(body.forced).to.equal(true); + }); + }); + + describe('createLoggingMiddleware', () => { + it('logs request and response metadata and masks configured body keys', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-logger-')); + const logFile = path.join(tmpDir, 'log.jsonl'); + + try { + configureLogger({level: 'trace', json: true, redact: false, fd: fs.openSync(logFile, 'w')}); + + const middleware = createLoggingMiddleware({prefix: 'TEST', maskBodyKeys: ['data']}); + type OnRequestParams = Parameters>[0]; + type OnResponseParams = Parameters>[0]; + + const request = new Request('https://example.com/items?x=1', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify({data: 'SECRET', other: 1}), + duplex: 'half', + } as RequestInit); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const response = new Response(JSON.stringify({data: 'SECRET2', ok: true}), { + status: 200, + headers: {'content-type': 'application/json'}, + }); + + await middleware.onResponse!({request: modifiedRequest, response} as unknown as OnResponseParams); + + const lines = fs + .readFileSync(logFile, 'utf-8') + .split('\n') + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as {msg?: string; body?: unknown}); + + // We log debug + trace for request and response. + // Validate that at least one line contains masked request body and masked response body. + const requestBodyLog = lines.find( + (l) => String(l.msg).includes('[TEST REQ]') && String(l.msg).includes('body'), + ); + const responseBodyLog = lines.find( + (l) => String(l.msg).includes('[TEST RESP]') && String(l.msg).includes('body'), + ); + + expect(requestBodyLog).to.not.equal(undefined); + expect(responseBodyLog).to.not.equal(undefined); + + expect(requestBodyLog!.body).to.deep.include({data: '...', other: 1}); + expect(responseBodyLog!.body).to.deep.include({data: '...', ok: true}); + } finally { + resetLogger(); + fs.rmSync(tmpDir, {recursive: true, force: true}); + } + }); + + it('logs non-JSON bodies as text', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-logger-')); + const logFile = path.join(tmpDir, 'log.jsonl'); + + try { + configureLogger({level: 'trace', json: true, redact: false, fd: fs.openSync(logFile, 'w')}); + + const middleware = createLoggingMiddleware('TEST'); + type OnRequestParams = Parameters>[0]; + + const request = new Request('https://example.com/items', { + method: 'POST', + headers: {'content-type': 'text/plain'}, + body: 'hello', + duplex: 'half', + } as RequestInit); + + const modifiedRequest = await middleware.onRequest!({request} as unknown as OnRequestParams); + if (!modifiedRequest) { + throw new Error('Expected middleware to return a Request'); + } + + const lines = fs + .readFileSync(logFile, 'utf-8') + .split('\n') + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l) as {msg?: string; body?: unknown}); + + const requestBodyLog = lines.find( + (l) => String(l.msg).includes('[TEST REQ]') && String(l.msg).includes('body'), + ); + expect(requestBodyLog).to.not.equal(undefined); + expect(requestBodyLog!.body).to.equal('hello'); + } finally { + resetLogger(); + fs.rmSync(tmpDir, {recursive: true, force: true}); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts b/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts new file mode 100644 index 00000000..e79778cc --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/ocapi.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createOcapiClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +describe('clients/ocapi', () => { + describe('createOcapiClient', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + it('uses the default API version and applies auth middleware', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.get(`${baseUrl}/sites`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({count: 0, data: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await client.GET('/sites', {}); + + expect(error).to.be.undefined; + expect(data).to.deep.equal({count: 0, data: []}); + }); + + it('uses a custom API version when provided', async () => { + const hostname = 'test.demandware.net'; + const apiVersion = 'v99_9'; + const baseUrl = `https://${hostname}/s/-/dw/data/${apiVersion}`; + + server.use( + http.get(`${baseUrl}/sites`, () => { + return HttpResponse.json({count: 1, data: [{id: 'RefArch'}]}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth, apiVersion); + + const {data, error} = await client.GET('/sites', {}); + + expect(error).to.be.undefined; + expect(data?.count).to.equal(1); + }); + + it('returns structured error for non-2xx responses', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.get(`${baseUrl}/sites/nonexistent`, () => { + return HttpResponse.json({fault: {message: 'Site not found'}}, {status: 404}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await client.GET('/sites/{site_id}', { + params: {path: {site_id: 'nonexistent'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.deep.equal({fault: {message: 'Site not found'}}); + }); + + it('handles POST requests for creating resources', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.post(`${baseUrl}/customer_lists/:list_id/customers`, async ({request, params}) => { + const body = (await request.json()) as any; + + expect(params.list_id).to.equal('TestList'); + expect(body.customer_no).to.equal('00001'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + + return HttpResponse.json({ + customer_no: '00001', + email: 'test@example.com', + enabled: true, + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await client.POST('/customer_lists/{list_id}/customers', { + params: {path: {list_id: 'TestList'}}, + body: { + customer_no: '00001', + email: 'test@example.com', + }, + }); + + expect(error).to.be.undefined; + expect(data).to.have.property('customer_no', '00001'); + }); + + it('handles PATCH requests for partial updates', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.patch(`${baseUrl}/sites/:site_id`, async ({request, params}) => { + const body = (await request.json()) as any; + + expect(params.site_id).to.equal('RefArch'); + expect(body.status).to.equal('online'); + + return HttpResponse.json({ + id: 'RefArch', + status: 'online', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await (client as any).PATCH('/sites/{site_id}', { + params: {path: {site_id: 'RefArch'}}, + body: {status: 'online'}, + }); + + expect(error).to.be.undefined; + expect(data).to.have.property('status', 'online'); + }); + + it('handles DELETE requests', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.delete(`${baseUrl}/customer_lists/:list_id/customers/:customer_no`, ({params}) => { + expect(params.list_id).to.equal('TestList'); + expect(params.customer_no).to.equal('00001'); + return new HttpResponse(null, {status: 204}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {response, error} = await client.DELETE('/customer_lists/{list_id}/customers/{customer_no}', { + params: { + path: {list_id: 'TestList', customer_no: '00001'}, + }, + }); + + expect(error).to.be.undefined; + expect(response.status).to.equal(204); + }); + + it('handles query parameters for filtering and sorting', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.get(`${baseUrl}/sites`, ({request}) => { + const url = new URL(request.url); + const select = url.searchParams.get('select'); + const count = url.searchParams.get('count'); + + expect(select).to.equal('(id,status)'); + expect(count).to.equal('10'); + + return HttpResponse.json({ + count: 2, + total: 2, + data: [ + {id: 'RefArch', status: 'online'}, + {id: 'SiteGenesis', status: 'offline'}, + ], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await client.GET('/sites', { + params: { + query: { + select: '(id,status)', + count: 10, + }, + }, + }); + + expect(error).to.be.undefined; + expect(data?.data).to.have.length(2); + expect(data?.data?.[0]).to.have.property('id', 'RefArch'); + }); + + it('handles authentication errors (401)', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.get(`${baseUrl}/sites`, () => { + return HttpResponse.json( + { + fault: { + type: 'AuthenticationException', + message: 'Invalid credentials', + }, + }, + {status: 401}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await client.GET('/sites', {}); + + expect(data).to.be.undefined; + expect(error).to.have.nested.property('fault.type', 'AuthenticationException'); + }); + + it('handles validation errors (400)', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.post(`${baseUrl}/sites`, () => { + return HttpResponse.json( + { + fault: { + type: 'InvalidInputException', + message: 'Invalid site configuration', + arguments: { + id: 'Site ID is required', + }, + }, + }, + {status: 400}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data, error} = await (client as any).POST('/sites', { + body: {}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.nested.property('fault.type', 'InvalidInputException'); + expect(error).to.have.nested.property('fault.arguments'); + }); + + it('handles large result sets with count and total', async () => { + const hostname = 'test.demandware.net'; + const baseUrl = `https://${hostname}/s/-/dw/data/v25_6`; + + server.use( + http.get(`${baseUrl}/products`, ({request}) => { + const url = new URL(request.url); + const start = Number.parseInt(url.searchParams.get('start') || '0'); + const count = Number.parseInt(url.searchParams.get('count') || '25'); + + return HttpResponse.json({ + count, + start, + total: 1000, + data: Array.from({length: count}, (_, i) => ({ + id: `product-${start + i + 1}`, + })), + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createOcapiClient(hostname, auth); + + const {data} = await (client as any).GET('/products', { + params: { + query: {start: 50, count: 25}, + }, + }); + + expect((data as any)?.total).to.equal(1000); + expect((data as any)?.count).to.equal(25); + expect((data as any)?.start).to.equal(50); + expect((data as any)?.data).to.have.length(25); + expect((data as any)?.data?.[0]?.id).to.equal('product-51'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts b/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts new file mode 100644 index 00000000..2d8b7803 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/slas-admin.test.ts @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createSlasClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +describe('clients/slas-admin', () => { + describe('createSlasClient', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + it('uses shortCode to build base URL and applies auth middleware', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, ({request}) => { + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + return HttpResponse.json({total: 0, data: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'tenant-1'}}, + }); + + expect(error).to.be.undefined; + expect(data).to.deep.equal({total: 0, data: []}); + }); + + it('returns structured error for non-2xx responses', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, () => { + return HttpResponse.json( + { + status: 403, + title: 'Forbidden', + type: 'about:blank', + detail: 'Not allowed', + }, + {status: 403}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'tenant-1'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.deep.equal({ + status: 403, + title: 'Forbidden', + type: 'about:blank', + detail: 'Not allowed', + }); + }); + + it('handles POST requests with body', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.post(`${baseUrl}/tenants/:tenantId/clients`, async ({request}) => { + const body = (await request.json()) as any; + + expect(body.clientId).to.equal('new-client-123'); + expect(body.clientName).to.equal('Test Client'); + expect(request.headers.get('Authorization')).to.equal('Bearer test-token'); + + return HttpResponse.json({ + clientId: 'new-client-123', + clientName: 'Test Client', + createdAt: '2025-01-01T00:00:00Z', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await (client as any).POST('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'tenant-1'}}, + body: { + clientId: 'new-client-123', + clientName: 'Test Client', + }, + }); + + expect(error).to.be.undefined; + expect(data).to.have.property('clientId', 'new-client-123'); + expect(data).to.have.property('clientName', 'Test Client'); + }); + + it('handles PUT/PATCH requests for updates', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.patch(`${baseUrl}/tenants/:tenantId/clients/:clientId`, async ({request, params}) => { + const body = (await request.json()) as any; + + expect(params.clientId).to.equal('client-123'); + expect(body.clientName).to.equal('Updated Name'); + + return HttpResponse.json({ + clientId: 'client-123', + clientName: 'Updated Name', + updatedAt: '2025-01-01T00:00:00Z', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await (client as any).PATCH('/tenants/{tenantId}/clients/{clientId}', { + params: { + path: {tenantId: 'tenant-1', clientId: 'client-123'}, + }, + body: { + clientName: 'Updated Name', + }, + }); + + expect(error).to.be.undefined; + expect(data).to.have.property('clientName', 'Updated Name'); + }); + + it('handles DELETE requests', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.delete(`${baseUrl}/tenants/:tenantId/clients/:clientId`, ({params}) => { + expect(params.clientId).to.equal('client-to-delete'); + return new HttpResponse(null, {status: 204}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {response, error} = await client.DELETE('/tenants/{tenantId}/clients/{clientId}', { + params: { + path: {tenantId: 'tenant-1', clientId: 'client-to-delete'}, + }, + }); + + expect(error).to.be.undefined; + expect(response.status).to.equal(204); + }); + + it('handles query parameters correctly', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, ({request}) => { + const url = new URL(request.url); + const limit = url.searchParams.get('limit'); + const offset = url.searchParams.get('offset'); + + expect(limit).to.equal('10'); + expect(offset).to.equal('20'); + + return HttpResponse.json({ + total: 100, + limit: 10, + offset: 20, + data: [{clientId: 'client-1'}], + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients', { + params: { + path: {tenantId: 'tenant-1'}, + query: {limit: 10, offset: 20} as any, + }, + }); + + expect(error).to.be.undefined; + expect(data as any).to.have.property('limit', 10); + expect(data as any).to.have.property('offset', 20); + }); + + it('handles 404 not found errors', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients/:clientId`, () => { + return HttpResponse.json( + { + status: 404, + title: 'Not Found', + detail: 'Client not found', + }, + {status: 404}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients/{clientId}', { + params: {path: {tenantId: 'tenant-1', clientId: 'nonexistent'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('status', 404); + expect(error).to.have.property('title', 'Not Found'); + }); + + it('handles 500 server errors', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, () => { + return HttpResponse.json( + { + status: 500, + title: 'Internal Server Error', + detail: 'Something went wrong', + }, + {status: 500}, + ); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'tenant-1'}}, + }); + + expect(data).to.be.undefined; + expect(error).to.have.property('status', 500); + }); + + it('handles empty response arrays', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, () => { + return HttpResponse.json({total: 0, data: []}); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + const {data, error} = await client.GET('/tenants/{tenantId}/clients', { + params: {path: {tenantId: 'tenant-1'}}, + }); + + expect(error).to.be.undefined; + expect(data?.data).to.be.an('array').that.is.empty; + expect((data as any)?.total).to.equal(0); + }); + + it('handles pagination parameters', async () => { + const shortCode = 'kv7kzm78'; + const baseUrl = `https://${shortCode}.api.commercecloud.salesforce.com/shopper/auth-admin/v1`; + + server.use( + http.get(`${baseUrl}/tenants/:tenantId/clients`, ({request}) => { + const url = new URL(request.url); + const limit = Number.parseInt(url.searchParams.get('limit') || '25'); + const offset = Number.parseInt(url.searchParams.get('offset') || '0'); + + return HttpResponse.json({ + total: 100, + limit, + offset, + data: Array.from({length: limit}, (_, i) => ({ + clientId: `client-${offset + i + 1}`, + })), + }); + }), + ); + + const auth = new MockAuthStrategy(); + const client = createSlasClient({shortCode}, auth); + + // First page + const page1 = await client.GET('/tenants/{tenantId}/clients', { + params: { + path: {tenantId: 'tenant-1'}, + query: {limit: 25, offset: 0} as any, + }, + }); + + expect((page1.data as any)?.data).to.have.length(25); + expect((page1.data as any)?.data?.[0]?.clientId).to.equal('client-1'); + + // Second page + const page2 = await client.GET('/tenants/{tenantId}/clients', { + params: { + path: {tenantId: 'tenant-1'}, + query: {limit: 25, offset: 25} as any, + }, + }); + + expect((page2.data as any)?.data).to.have.length(25); + expect((page2.data as any)?.data?.[0]?.clientId).to.equal('client-26'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/clients/webdav.test.ts b/packages/b2c-tooling-sdk/test/clients/webdav.test.ts index bd438b09..a83ef673 100644 --- a/packages/b2c-tooling-sdk/test/clients/webdav.test.ts +++ b/packages/b2c-tooling-sdk/test/clients/webdav.test.ts @@ -6,7 +6,7 @@ import {expect} from 'chai'; import {http, HttpResponse} from 'msw'; import {setupServer} from 'msw/node'; -import {WebDavClient} from '@salesforce/b2c-tooling-sdk/clients'; +import {MiddlewareRegistry, WebDavClient} from '@salesforce/b2c-tooling-sdk/clients'; import {HTTPError} from '@salesforce/b2c-tooling-sdk/errors'; import {MockAuthStrategy} from '../helpers/mock-auth.js'; @@ -70,6 +70,63 @@ describe('clients/webdav', () => { client = new WebDavClient(TEST_HOST, mockAuth); }); + describe('request middleware adaptation', () => { + it('applies onRequest middleware that returns a new Request', async () => { + const registry = new MiddlewareRegistry(); + registry.register({ + name: 'set-header', + getMiddleware() { + return { + async onRequest({request}) { + const nextHeaders = new Headers(request.headers); + nextHeaders.set('x-from-middleware', '1'); + return new Request(request, {headers: nextHeaders}); + }, + }; + }, + }); + + client = new WebDavClient(TEST_HOST, mockAuth, {middlewareRegistry: registry}); + + server.use( + http.all(`${BASE_URL}/*`, ({request}) => { + requests.push({method: request.method, url: request.url, headers: request.headers}); + return new HttpResponse(null, {status: 200}); + }), + ); + + await client.exists('Cartridges/v1'); + expect(requests).to.have.length(1); + expect(requests[0].headers.get('x-from-middleware')).to.equal('1'); + }); + + it('applies onResponse middleware that returns a new Response', async () => { + const registry = new MiddlewareRegistry(); + registry.register({ + name: 'override-response', + getMiddleware() { + return { + async onResponse() { + return new Response('overridden', {status: 200, headers: {'content-type': 'text/plain'}}); + }, + }; + }, + }); + + client = new WebDavClient(TEST_HOST, mockAuth, {middlewareRegistry: registry}); + + server.use( + http.all(`${BASE_URL}/*`, () => { + return new HttpResponse(null, {status: 404}); + }), + ); + + // If onResponse replacement works, exists() should see ok response and return true + const exists = await client.exists('Cartridges/v1'); + expect(exists).to.equal(true); + }); + }); + describe('buildUrl', () => { it('builds correct URL for path without leading slash', () => { const url = client.buildUrl('Cartridges/v1'); @@ -454,5 +511,42 @@ describe('clients/webdav', () => { expect(result).to.equal(false); }); }); + + describe('private helpers (branch coverage)', () => { + it('headersToObject supports array and record headers', async () => { + const impl = client as unknown as { + headersToObject: (headers: Headers | [string, string][] | Record) => Record; + }; + + expect(impl.headersToObject([['a', '1']])).to.deep.equal({a: '1'}); + expect(impl.headersToObject({b: '2'})).to.deep.equal({b: '2'}); + }); + + it('formatBody describes Blob bodies', async () => { + const impl = client as unknown as { + formatBody: (body?: RequestInit['body']) => string | undefined; + }; + + const blob = new Blob(['hello'], {type: 'text/plain'}); + expect(impl.formatBody(blob)).to.include('[Blob:'); + }); + + it('parsePropfindResponse returns empty when response has no multistatus', async () => { + const impl = client as unknown as { + parsePropfindResponse: (xml: string) => Promise; + }; + + const entries = await impl.parsePropfindResponse(''); + expect(entries).to.deep.equal([]); + }); + + it('getXmlText returns undefined for objects without text content', async () => { + const impl = client as unknown as { + getXmlText: (value: unknown) => string | undefined; + }; + + expect(impl.getXmlText({})).to.equal(undefined); + }); + }); }); }); diff --git a/packages/b2c-tooling-sdk/test/operations/code/cartridges.test.ts b/packages/b2c-tooling-sdk/test/operations/code/cartridges.test.ts new file mode 100644 index 00000000..78b2243f --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/code/cartridges.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {findCartridges} from '@salesforce/b2c-tooling-sdk/operations/code'; + +describe('operations/code/cartridges', () => { + describe('findCartridges', () => { + it('returns empty array when no cartridges exist', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-cartridges-')); + try { + const result = findCartridges(dir); + expect(result).to.deep.equal([]); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('finds cartridges by locating .project files and maps name/src/dest', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-cartridges-')); + try { + const c1 = path.join(dir, 'app_storefront_base'); + const c2 = path.join(dir, 'bm_tools'); + + fs.mkdirSync(c1, {recursive: true}); + fs.mkdirSync(c2, {recursive: true}); + + fs.writeFileSync(path.join(c1, '.project'), ''); + fs.writeFileSync(path.join(c2, '.project'), ''); + + const result = findCartridges(dir); + const names = result.map((c) => c.name).sort(); + + expect(names).to.deep.equal(['app_storefront_base', 'bm_tools']); + + const sfra = result.find((c) => c.name === 'app_storefront_base')!; + expect(sfra.dest).to.equal('app_storefront_base'); + expect(sfra.src).to.equal(c1); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('applies include filter when include list is provided', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-cartridges-')); + try { + const c1 = path.join(dir, 'a'); + const c2 = path.join(dir, 'b'); + + fs.mkdirSync(c1, {recursive: true}); + fs.mkdirSync(c2, {recursive: true}); + + fs.writeFileSync(path.join(c1, '.project'), ''); + fs.writeFileSync(path.join(c2, '.project'), ''); + + const result = findCartridges(dir, {include: ['b']}); + expect(result.map((c) => c.name)).to.deep.equal(['b']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('applies exclude filter when exclude list is provided', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-cartridges-')); + try { + const c1 = path.join(dir, 'a'); + const c2 = path.join(dir, 'b'); + + fs.mkdirSync(c1, {recursive: true}); + fs.mkdirSync(c2, {recursive: true}); + + fs.writeFileSync(path.join(c1, '.project'), ''); + fs.writeFileSync(path.join(c2, '.project'), ''); + + const result = findCartridges(dir, {exclude: ['a']}); + expect(result.map((c) => c.name)).to.deep.equal(['b']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + + it('handles empty include/exclude lists as no-op', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-cartridges-')); + try { + const c1 = path.join(dir, 'a'); + fs.mkdirSync(c1, {recursive: true}); + fs.writeFileSync(path.join(c1, '.project'), ''); + + const result = findCartridges(dir, {include: [], exclude: []}); + expect(result.map((c) => c.name)).to.deep.equal(['a']); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/code/deploy.test.ts b/packages/b2c-tooling-sdk/test/operations/code/deploy.test.ts new file mode 100644 index 00000000..1eedeb3b --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/code/deploy.test.ts @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {WebDavClient} from '../../../src/clients/webdav.js'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import {deleteCartridges, uploadCartridges, findAndDeployCartridges} from '../../../src/operations/code/deploy.js'; +import type {CartridgeMapping} from '../../../src/operations/code/cartridges.js'; + +const TEST_HOST = 'test.demandware.net'; +const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; +const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; + +describe('operations/code/deploy', () => { + const server = setupServer(); + let mockInstance: any; + let tempDir: string; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + // Create temp directory for test cartridges + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-deploy-')); + + // Create a real instance with mocked HTTP + const auth = new MockAuthStrategy(); + const webdav = new WebDavClient(TEST_HOST, auth); + const ocapi = createOcapiClient(TEST_HOST, auth); + + mockInstance = { + config: { + codeVersion: 'v1', + }, + webdav, + ocapi, + }; + }); + + afterEach(() => { + server.resetHandlers(); + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + after(() => { + server.close(); + }); + + describe('deleteCartridges', () => { + it('should delete all cartridges from WebDAV', async () => { + const cartridges: CartridgeMapping[] = [ + {name: 'app_storefront', src: '/path/to/app', dest: 'app_storefront'}, + {name: 'app_core', src: '/path/to/core', dest: 'app_storefront_core'}, + ]; + + const deletedPaths: string[] = []; + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'DELETE') { + deletedPaths.push(new URL(request.url).pathname); + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + await deleteCartridges(mockInstance, cartridges); + + expect(deletedPaths).to.have.lengthOf(2); + expect(deletedPaths[0]).to.include('app_storefront'); + expect(deletedPaths[1]).to.include('app_storefront_core'); + }); + + it('should not delete when cartridges array is empty', async () => { + // No HTTP handlers needed - if any request is made, MSW will error + await deleteCartridges(mockInstance, []); + // Success - no requests made + }); + + it('should throw error when code version is not set', async () => { + mockInstance.config.codeVersion = undefined; + const cartridges: CartridgeMapping[] = [{name: 'app', src: '/path', dest: 'app'}]; + + try { + await deleteCartridges(mockInstance, cartridges); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Code version required'); + } + }); + + it('should handle 404 errors gracefully (cartridge does not exist)', async () => { + const cartridges: CartridgeMapping[] = [{name: 'app', src: '/path', dest: 'app'}]; + + server.use( + http.all(`${WEBDAV_BASE}/*`, () => { + return new HttpResponse(null, {status: 404}); + }), + ); + + // Should not throw - 404 is expected when cartridge doesn't exist + await deleteCartridges(mockInstance, cartridges); + }); + }); + + describe('uploadCartridges', () => { + it('should upload and unzip cartridges', async () => { + // Create a test cartridge directory + const cartridgeDir = path.join(tempDir, 'app_storefront_base'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'console.log("test");'); + + const cartridges: CartridgeMapping[] = [ + {name: 'app_storefront_base', src: cartridgeDir, dest: 'app_storefront_base'}, + ]; + + let uploadedZip: Buffer | null = null; + let unzipRequested = false; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + // uploadCartridges uses a temporary _sync-*.zip file + if (request.method === 'PUT' && url.pathname.includes('_sync-') && url.pathname.endsWith('.zip')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + if (request.method === 'POST' && url.pathname.includes('_sync-') && url.pathname.endsWith('.zip')) { + unzipRequested = true; + return new HttpResponse(null, {status: 204}); + } + if (request.method === 'DELETE' && url.pathname.includes('_sync-') && url.pathname.endsWith('.zip')) { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + await uploadCartridges(mockInstance, cartridges); + + expect(uploadedZip).to.not.be.null; + expect(uploadedZip!.length).to.be.greaterThan(0); + expect(unzipRequested).to.be.true; + }); + + it('should throw error when cartridges array is empty', async () => { + try { + await uploadCartridges(mockInstance, []); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('No cartridges to upload'); + } + }); + + it('should throw error when code version is missing', async () => { + mockInstance.config.codeVersion = undefined; + const cartridges: CartridgeMapping[] = [{name: 'app', src: tempDir, dest: 'app'}]; + + try { + await uploadCartridges(mockInstance, cartridges); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Code version required'); + } + }); + + it('should handle upload failures', async () => { + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'test'); + + const cartridges: CartridgeMapping[] = [{name: 'app_test', src: cartridgeDir, dest: 'app_test'}]; + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'PUT') { + return new HttpResponse('Upload failed', {status: 500}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + try { + await uploadCartridges(mockInstance, cartridges); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('PUT failed'); + } + }); + }); + + describe('findAndDeployCartridges', () => { + it('should deploy cartridges from directory', async () => { + // Create test cartridge structure + const cartridgeDir = path.join(tempDir, 'my_cartridge'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'console.log("test");'); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + // Handle _sync-*.zip uploads + if (request.method === 'PUT' || request.method === 'POST' || request.method === 'DELETE') { + return new HttpResponse(null, {status: request.method === 'PUT' ? 201 : 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await findAndDeployCartridges(mockInstance, tempDir, {reload: false, delete: false}); + + expect(result.cartridges).to.have.lengthOf(1); + expect(result.cartridges[0].name).to.equal('my_cartridge'); + expect(result.codeVersion).to.equal('v1'); + expect(result.reloaded).to.be.false; + }); + + it('should reload code version when reload option is true', async () => { + const cartridgeDir = path.join(tempDir, 'my_cartridge'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'test'); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'PUT' || request.method === 'POST' || request.method === 'DELETE') { + return new HttpResponse(null, {status: request.method === 'PUT' ? 201 : 204}); + } + return new HttpResponse(null, {status: 404}); + }), + // Mock reloadCodeVersion (which calls listCodeVersions and activateCodeVersion) + http.get(`${OCAPI_BASE}/code_versions`, () => { + return HttpResponse.json({ + data: [ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ], + }); + }), + http.patch(`${OCAPI_BASE}/code_versions/v2`, () => { + return HttpResponse.json({id: 'v2', active: true}); + }), + http.patch(`${OCAPI_BASE}/code_versions/v1`, () => { + return HttpResponse.json({id: 'v1', active: true}); + }), + ); + + const result = await findAndDeployCartridges(mockInstance, tempDir, {reload: true, delete: false}); + + expect(result.reloaded).to.be.true; + }); + + it('should delete existing cartridges when delete option is true', async () => { + const cartridgeDir = path.join(tempDir, 'my_cartridge'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'test'); + + let deleteRequested = false; + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + const url = new URL(request.url); + if ( + request.method === 'DELETE' && + url.pathname.includes('/v1/my_cartridge') && + !url.pathname.includes('.zip') + ) { + deleteRequested = true; + } + if (request.method === 'PUT' || request.method === 'POST' || request.method === 'DELETE') { + return new HttpResponse(null, {status: request.method === 'PUT' ? 201 : 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await findAndDeployCartridges(mockInstance, tempDir, {reload: false, delete: true}); + + expect(deleteRequested).to.be.true; + expect(result.cartridges).to.have.lengthOf(1); + }); + + it('should apply include filter when provided', async () => { + // Create multiple cartridges + const cart1 = path.join(tempDir, 'app_storefront'); + const cart2 = path.join(tempDir, 'app_core'); + + fs.mkdirSync(cart1, {recursive: true}); + fs.mkdirSync(cart2, {recursive: true}); + fs.writeFileSync(path.join(cart1, '.project'), ''); + fs.writeFileSync(path.join(cart2, '.project'), ''); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'PUT' || request.method === 'POST' || request.method === 'DELETE') { + return new HttpResponse(null, {status: request.method === 'PUT' ? 201 : 204}); + } + return new HttpResponse(null, {status: 404}); + }), + ); + + const result = await findAndDeployCartridges(mockInstance, tempDir, { + reload: false, + delete: false, + include: ['app_storefront'], + }); + + expect(result.cartridges).to.have.lengthOf(1); + expect(result.cartridges[0].name).to.equal('app_storefront'); + }); + + it('should throw error when no cartridges found', async () => { + // No cartridges in tempDir + try { + await findAndDeployCartridges(mockInstance, tempDir, {reload: false, delete: false}); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('No cartridges found'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts new file mode 100644 index 00000000..e7d7b848 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/code/versions.test.ts @@ -0,0 +1,318 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import { + listCodeVersions, + getActiveCodeVersion, + activateCodeVersion, + createCodeVersion, + deleteCodeVersion, + reloadCodeVersion, +} from '../../../src/operations/code/versions.js'; + +const TEST_HOST = 'test.demandware.net'; +const BASE_URL = `https://${TEST_HOST}/s/-/dw/data/v25_6`; + +describe('operations/code/versions', () => { + const server = setupServer(); + let mockInstance: any; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + // Create a real OCAPI client with mocked HTTP + const auth = new MockAuthStrategy(); + const ocapi = createOcapiClient(TEST_HOST, auth); + + mockInstance = { + ocapi, + }; + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('listCodeVersions', () => { + it('should return list of code versions', async () => { + const mockVersions = [ + {id: 'v1', active: true, last_modification_time: '2025-01-01T00:00:00Z'}, + {id: 'v2', active: false, last_modification_time: '2025-01-02T00:00:00Z'}, + ]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + ); + + const result = await listCodeVersions(mockInstance); + + expect(result).to.deep.equal(mockVersions); + }); + + it('should return empty array when no versions exist', async () => { + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: []}); + }), + ); + + const result = await listCodeVersions(mockInstance); + + expect(result).to.deep.equal([]); + }); + + it('should handle undefined data gracefully', async () => { + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({}); + }), + ); + + const result = await listCodeVersions(mockInstance); + + expect(result).to.deep.equal([]); + }); + + it('should throw error when API call fails', async () => { + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({fault: {message: 'Unauthorized'}}, {status: 401}); + }), + ); + + try { + await listCodeVersions(mockInstance); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to list code versions'); + } + }); + }); + + describe('getActiveCodeVersion', () => { + it('should return the active code version', async () => { + const mockVersions = [ + {id: 'v1', active: false}, + {id: 'v2', active: true}, + {id: 'v3', active: false}, + ]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + ); + + const result = await getActiveCodeVersion(mockInstance); + + expect(result).to.deep.equal({id: 'v2', active: true}); + }); + + it('should return undefined when no version is active', async () => { + const mockVersions = [ + {id: 'v1', active: false}, + {id: 'v2', active: false}, + ]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + ); + + const result = await getActiveCodeVersion(mockInstance); + + expect(result).to.be.undefined; + }); + + it('should return undefined when no versions exist', async () => { + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: []}); + }), + ); + + const result = await getActiveCodeVersion(mockInstance); + + expect(result).to.be.undefined; + }); + }); + + describe('activateCodeVersion', () => { + it('should activate a code version successfully', async () => { + server.use( + http.patch(`${BASE_URL}/code_versions/v2`, () => { + return HttpResponse.json({id: 'v2', active: true}); + }), + ); + + await activateCodeVersion(mockInstance, 'v2'); + // Success - no error thrown + }); + + it('should throw error when activation fails', async () => { + server.use( + http.patch(`${BASE_URL}/code_versions/nonexistent`, () => { + return HttpResponse.json({fault: {message: 'Version not found'}}, {status: 404}); + }), + ); + + try { + await activateCodeVersion(mockInstance, 'nonexistent'); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to activate code version'); + } + }); + }); + + describe('createCodeVersion', () => { + it('should create a new code version', async () => { + server.use( + http.put(`${BASE_URL}/code_versions/new-version`, () => { + return HttpResponse.json({id: 'new-version', active: false}); + }), + ); + + await createCodeVersion(mockInstance, 'new-version'); + // Success - no error thrown + }); + + it('should throw error when creation fails', async () => { + server.use( + http.put(`${BASE_URL}/code_versions/:id`, () => { + return HttpResponse.json({fault: {message: 'Invalid version ID'}}, {status: 400}); + }), + ); + + try { + await createCodeVersion(mockInstance, 'invalid!version'); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create code version'); + } + }); + }); + + describe('deleteCodeVersion', () => { + it('should delete a code version', async () => { + server.use( + http.delete(`${BASE_URL}/code_versions/old-version`, () => { + return new HttpResponse(null, {status: 204}); + }), + ); + + await deleteCodeVersion(mockInstance, 'old-version'); + // Success - no error thrown + }); + + it('should throw error when deletion fails', async () => { + server.use( + http.delete(`${BASE_URL}/code_versions/nonexistent`, () => { + return HttpResponse.json({fault: {message: 'Version not found'}}, {status: 404}); + }), + ); + + try { + await deleteCodeVersion(mockInstance, 'nonexistent'); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete code version'); + } + }); + }); + + describe('reloadCodeVersion', () => { + it('should reload a code version successfully when already active', async () => { + const mockVersions = [ + {id: 'v1', active: false}, + {id: 'v2', active: true}, + ]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + http.patch(`${BASE_URL}/code_versions/v1`, () => { + return HttpResponse.json({id: 'v1', active: true}); + }), + http.patch(`${BASE_URL}/code_versions/v2`, () => { + return HttpResponse.json({id: 'v2', active: true}); + }), + ); + + await reloadCodeVersion(mockInstance, 'v2'); + // Success - no error thrown + }); + + it('should reload when not currently active', async () => { + const mockVersions = [ + {id: 'v1', active: true}, + {id: 'v2', active: false}, + ]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + http.patch(`${BASE_URL}/code_versions/v2`, () => { + return HttpResponse.json({id: 'v2', active: true}); + }), + ); + + await reloadCodeVersion(mockInstance, 'v2'); + // Success - no error thrown + }); + + it('should throw error when no alternate version available', async () => { + const mockVersions = [{id: 'v1', active: true}]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + ); + + try { + await reloadCodeVersion(mockInstance, 'v1'); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('no alternate code version available'); + } + }); + + it('should throw error when no active version and none specified', async () => { + const mockVersions = [{id: 'v1', active: false}]; + + server.use( + http.get(`${BASE_URL}/code_versions`, () => { + return HttpResponse.json({data: mockVersions}); + }), + ); + + try { + await reloadCodeVersion(mockInstance); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('No code version specified'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts b/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts new file mode 100644 index 00000000..26f96f3e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/code/watch.test.ts @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {WebDavClient} from '../../../src/clients/webdav.js'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import {watchCartridges, type WatchResult} from '../../../src/operations/code/watch.js'; + +const TEST_HOST = 'test.demandware.net'; +const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; +const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; + +describe('operations/code/watch', () => { + const server = setupServer(); + let mockInstance: any; + let tempDir: string; + let watchResult: WatchResult | null = null; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + // Create temp directory for test cartridges + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-watch-')); + + // Create a real instance with mocked HTTP + const auth = new MockAuthStrategy(); + const webdav = new WebDavClient(TEST_HOST, auth); + const ocapi = createOcapiClient(TEST_HOST, auth); + + mockInstance = { + config: { + codeVersion: 'v1', + hostname: TEST_HOST, + }, + webdav, + ocapi, + }; + + watchResult = null; + }); + + afterEach(async () => { + server.resetHandlers(); + + // Stop any active watcher + if (watchResult) { + await watchResult.stop(); + watchResult = null; + } + + // Clean up temp directory + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + after(() => { + server.close(); + }); + + describe('watchCartridges', () => { + it('should throw error when no cartridges found', async () => { + // Empty directory - no cartridges + try { + await watchCartridges(mockInstance, tempDir); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('No cartridges found'); + } + }); + + it('should throw error when no code version specified and no active version', async () => { + mockInstance.config.codeVersion = undefined; + + // Create a cartridge directory + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + server.use( + http.get(`${OCAPI_BASE}/code_versions`, () => { + return HttpResponse.json({data: [{id: 'v1', active: false}]}); // No active version + }), + ); + + try { + await watchCartridges(mockInstance, tempDir); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('no active code version found'); + } + }); + + it('should use active code version when not specified', async () => { + mockInstance.config.codeVersion = undefined; + + // Create a cartridge directory + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + server.use( + http.get(`${OCAPI_BASE}/code_versions`, () => { + return HttpResponse.json({ + data: [ + {id: 'v2', active: true}, + {id: 'v1', active: false}, + ], + }); + }), + ); + + watchResult = await watchCartridges(mockInstance, tempDir); + + expect(watchResult.codeVersion).to.equal('v2'); + expect(mockInstance.config.codeVersion).to.equal('v2'); + }); + + it('should start watching cartridges', async () => { + // Create a cartridge directory + const cartridgeDir = path.join(tempDir, 'app_storefront'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + watchResult = await watchCartridges(mockInstance, tempDir); + + expect(watchResult.cartridges).to.have.lengthOf(1); + expect(watchResult.cartridges[0].name).to.equal('app_storefront'); + expect(watchResult.codeVersion).to.equal('v1'); + expect(watchResult.watcher).to.exist; + expect(watchResult.stop).to.be.a('function'); + }); + + it('should apply include filter', async () => { + // Create multiple cartridges + const cart1 = path.join(tempDir, 'app_storefront'); + const cart2 = path.join(tempDir, 'app_core'); + + fs.mkdirSync(cart1, {recursive: true}); + fs.mkdirSync(cart2, {recursive: true}); + fs.writeFileSync(path.join(cart1, '.project'), ''); + fs.writeFileSync(path.join(cart2, '.project'), ''); + + watchResult = await watchCartridges(mockInstance, tempDir, { + include: ['app_storefront'], + }); + + expect(watchResult.cartridges).to.have.lengthOf(1); + expect(watchResult.cartridges[0].name).to.equal('app_storefront'); + }); + + it('should apply exclude filter', async () => { + // Create multiple cartridges + const cart1 = path.join(tempDir, 'app_storefront'); + const cart2 = path.join(tempDir, 'app_core'); + + fs.mkdirSync(cart1, {recursive: true}); + fs.mkdirSync(cart2, {recursive: true}); + fs.writeFileSync(path.join(cart1, '.project'), ''); + fs.writeFileSync(path.join(cart2, '.project'), ''); + + watchResult = await watchCartridges(mockInstance, tempDir, { + exclude: ['app_core'], + }); + + expect(watchResult.cartridges).to.have.lengthOf(1); + expect(watchResult.cartridges[0].name).to.equal('app_storefront'); + }); + + it('should watch and upload file changes', async function () { + this.timeout(5000); + + // Create a cartridge directory with initial files + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + fs.writeFileSync(path.join(cartridgeDir, 'initial.js'), '// initial'); + + server.use( + http.all(`${WEBDAV_BASE}/*`, async () => { + return new HttpResponse(null, { + status: 201, // All requests succeed + }); + }), + ); + + const uploadPromise = new Promise((resolve, reject) => { + watchCartridges(mockInstance, tempDir, { + debounceTime: 50, // Short debounce for testing + onUpload: (files) => { + try { + expect(files.length).to.be.greaterThan(0); + // Check if test.js is in the uploaded files (not initial.js) + if (files.some((f) => f.includes('test.js'))) { + resolve(); + } + } catch (error) { + reject(error); + } + }, + onError: (error) => { + reject(error); + }, + }).then((result) => { + watchResult = result; + }); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => setTimeout(resolve, 300)); + + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'console.log("test");'); + + await uploadPromise; + }); + + it('should watch and delete files', async function () { + this.timeout(5000); + + // Create a cartridge directory with a file + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + const testFile = path.join(cartridgeDir, 'test.js'); + fs.writeFileSync(testFile, 'console.log("test");'); + + server.use( + http.all(`${WEBDAV_BASE}/*`, () => { + return new HttpResponse(null, {status: 204}); + }), + ); + + const deletePromise = new Promise((resolve, reject) => { + watchCartridges(mockInstance, tempDir, { + debounceTime: 50, // Short debounce for testing + onDelete: (files) => { + try { + expect(files).to.have.lengthOf(1); + expect(files[0]).to.include('test.js'); + resolve(); + } catch (error) { + reject(error); + } + }, + onError: (error) => { + reject(error); + }, + }).then((result) => { + watchResult = result; + }); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Delete the file + fs.unlinkSync(testFile); + + await deletePromise; + }); + + it('should handle upload errors', async function () { + this.timeout(5000); + + // Create a cartridge directory + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + server.use( + http.all(`${WEBDAV_BASE}/*`, ({request}) => { + if (request.method === 'PUT') { + return new HttpResponse('Upload failed', {status: 500}); + } + return new HttpResponse(null, {status: 204}); + }), + ); + + const errorPromise = new Promise((resolve, reject) => { + watchCartridges(mockInstance, tempDir, { + debounceTime: 50, + onError: (error) => { + try { + expect(error.message).to.include('PUT failed'); + resolve(); + } catch (err) { + reject(err); + } + }, + }).then((result) => { + watchResult = result; + }); + }); + + // Wait for watcher to be ready + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Trigger a file change + fs.writeFileSync(path.join(cartridgeDir, 'test.js'), 'console.log("test");'); + + await errorPromise; + }); + + it('should stop watching when stop() is called', async () => { + // Create a cartridge directory + const cartridgeDir = path.join(tempDir, 'app_test'); + fs.mkdirSync(cartridgeDir, {recursive: true}); + fs.writeFileSync(path.join(cartridgeDir, '.project'), ''); + + watchResult = await watchCartridges(mockInstance, tempDir); + + expect(watchResult.watcher).to.exist; + + await watchResult.stop(); + watchResult = null; // Prevent double cleanup + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/run.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/run.test.ts new file mode 100644 index 00000000..254f3e97 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/jobs/run.test.ts @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import { + executeJob, + getJobExecution, + getJobErrorMessage, + searchJobExecutions, + findRunningJobExecution, + getJobLog, + JobExecutionError, +} from '@salesforce/b2c-tooling-sdk/operations/jobs'; + +type FakeOcapi = { + POST: (path: string, init: unknown) => Promise<{data?: unknown; error?: unknown; response: Response}>; + GET: (path: string, init: unknown) => Promise<{data?: unknown; error?: unknown}>; +}; + +async function expectRejectsWith(promise: Promise, message: string): Promise { + try { + await promise; + throw new Error('Expected promise to reject'); + } catch (err) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.include(message); + } +} + +describe('operations/jobs/run', () => { + describe('getJobExecution', () => { + it('returns execution when data exists', async () => { + const instance = { + ocapi: { + async GET() { + return {data: {id: 'e1', execution_status: 'running'}}; + }, + } as unknown as FakeOcapi, + }; + + const execution = await getJobExecution(instance as unknown as never, 'job-1', 'e1'); + expect(execution).to.deep.equal({id: 'e1', execution_status: 'running'}); + }); + + it('throws with OCAPI fault message when error exists', async () => { + const instance = { + ocapi: { + async GET() { + return {error: {fault: {message: 'nope'}}}; + }, + } as unknown as FakeOcapi, + }; + + await expectRejectsWith(getJobExecution(instance as unknown as never, 'job-1', 'e1'), 'nope'); + }); + + it('throws with fallback message when no data and no error', async () => { + const instance = { + ocapi: { + async GET() { + return {}; + }, + } as unknown as FakeOcapi, + }; + + await expectRejectsWith( + getJobExecution(instance as unknown as never, 'job-1', 'e1'), + 'Failed to get job execution e1', + ); + }); + }); + + describe('getJobErrorMessage', () => { + it('returns undefined when no step executions', () => { + expect(getJobErrorMessage({} as unknown as never)).to.equal(undefined); + expect(getJobErrorMessage({step_executions: []} as unknown as never)).to.equal(undefined); + }); + + it('returns the last ERROR step message', () => { + const msg = getJobErrorMessage({ + step_executions: [ + {exit_status: {code: 'OK', message: 'ok-1'}}, + {exit_status: {code: 'ERROR', message: 'bad-1'}}, + {exit_status: {code: 'ERROR', message: 'bad-2'}}, + ], + } as unknown as never); + + expect(msg).to.equal('bad-2'); + }); + + it('returns undefined when ERROR step has no message', () => { + const msg = getJobErrorMessage({ + step_executions: [{exit_status: {code: 'ERROR'}}], + } as unknown as never); + + expect(msg).to.equal(undefined); + }); + }); + + describe('searchJobExecutions', () => { + it('builds match_all_query when no filters provided', async () => { + let seenBody: unknown; + const instance = { + ocapi: { + async POST(_path: string, init: unknown) { + const typedInit = init as {body?: unknown}; + seenBody = typedInit.body; + return {data: {total: 0, count: 0, start: 0, hits: []}, response: new Response('{}', {status: 200})}; + }, + } as unknown as FakeOcapi, + }; + + const res = await searchJobExecutions(instance as unknown as never); + expect(res).to.deep.equal({total: 0, count: 0, start: 0, hits: []}); + expect((seenBody as {query: unknown}).query).to.deep.equal({match_all_query: {}}); + }); + + it('builds bool_query when multiple filters provided', async () => { + let seenBody: unknown; + const instance = { + ocapi: { + async POST(_path: string, init: unknown) { + const typedInit = init as {body?: unknown}; + seenBody = typedInit.body; + return { + data: {total: 1, count: 1, start: 0, hits: [{id: 'e1'}]}, + response: new Response('{}', {status: 200}), + }; + }, + } as unknown as FakeOcapi, + }; + + const res = await searchJobExecutions(instance as unknown as never, { + jobId: 'job-1', + status: ['RUNNING', 'PENDING'], + }); + expect(res.hits[0]).to.deep.equal({id: 'e1'}); + expect((seenBody as {query: {bool_query: {must: unknown[]}}}).query.bool_query.must.length).to.equal(2); + }); + + it('throws when OCAPI returns error', async () => { + const instance = { + ocapi: { + async POST() { + return {error: {fault: {message: 'boom'}}, response: new Response('{}', {status: 500})}; + }, + } as unknown as FakeOcapi, + }; + + await expectRejectsWith(searchJobExecutions(instance as unknown as never), 'boom'); + }); + + it('returns defaults when OCAPI returns partial response', async () => { + const instance = { + ocapi: { + async POST() { + return {data: {}, response: new Response('{}', {status: 200})}; + }, + } as unknown as FakeOcapi, + }; + + const res = await searchJobExecutions(instance as unknown as never); + expect(res).to.deep.equal({total: 0, count: 0, start: 0, hits: []}); + }); + }); + + describe('findRunningJobExecution', () => { + it('returns first hit (or undefined)', async () => { + const instance1 = { + ocapi: { + async POST() { + return {data: {hits: [{id: 'e1'}]}, response: new Response('{}', {status: 200})}; + }, + } as unknown as FakeOcapi, + }; + + const instance2 = { + ocapi: { + async POST() { + return {data: {hits: []}, response: new Response('{}', {status: 200})}; + }, + } as unknown as FakeOcapi, + }; + + expect((await findRunningJobExecution(instance1 as unknown as never, 'job-1'))?.id).to.equal('e1'); + expect(await findRunningJobExecution(instance2 as unknown as never, 'job-1')).to.equal(undefined); + }); + }); + + describe('getJobLog', () => { + it('throws when log_file_path is missing', async () => { + await expectRejectsWith(getJobLog({} as unknown as never, {} as unknown as never), 'No log file path available'); + }); + + it('throws when is_log_file_existing is false', async () => { + await expectRejectsWith( + getJobLog( + {} as unknown as never, + {log_file_path: '/Sites/LOGS/jobs/x.log', is_log_file_existing: false} as unknown as never, + ), + 'Log file does not exist', + ); + }); + + it('fetches log content via WebDAV and strips leading /Sites/', async () => { + let seenPath: string | undefined; + const instance = { + webdav: { + async get(p: string) { + seenPath = p; + return new TextEncoder().encode('hello').buffer; + }, + }, + }; + + const content = await getJobLog( + instance as unknown as never, + { + log_file_path: '/Sites/LOGS/jobs/x.log', + is_log_file_existing: true, + } as unknown as never, + ); + + expect(seenPath).to.equal('LOGS/jobs/x.log'); + expect(content).to.equal('hello'); + }); + }); + + describe('executeJob', () => { + it('executes job without body when no params and no raw body', async () => { + let seenBody: unknown; + const instance = { + ocapi: { + async POST(_path: string, init: unknown) { + const typedInit = init as {body?: unknown}; + seenBody = typedInit.body; + return { + data: {id: 'e1', execution_status: 'running'}, + response: new Response('{}', {status: 201}), + }; + }, + } as unknown as FakeOcapi, + }; + + const res = await executeJob(instance as unknown as never, 'job-1'); + expect(res.id).to.equal('e1'); + expect(seenBody).to.equal(undefined); + }); + + it('executes job with parameters array when provided', async () => { + let seenBody: unknown; + const instance = { + ocapi: { + async POST(_path: string, init: unknown) { + const typedInit = init as {body?: unknown}; + seenBody = typedInit.body; + return { + data: {id: 'e1', execution_status: 'running'}, + response: new Response('{}', {status: 201}), + }; + }, + } as unknown as FakeOcapi, + }; + + await executeJob(instance as unknown as never, 'job-1', { + parameters: [{name: 'A', value: '1'}] as unknown as never, + }); + expect(seenBody).to.deep.equal({parameters: [{name: 'A', value: '1'}]}); + }); + + it('executes job with raw body when provided', async () => { + let seenBody: unknown; + const instance = { + ocapi: { + async POST(_path: string, init: unknown) { + const typedInit = init as {body?: unknown}; + seenBody = typedInit.body; + return { + data: {id: 'e1', execution_status: 'running'}, + response: new Response('{}', {status: 201}), + }; + }, + } as unknown as FakeOcapi, + }; + + await executeJob(instance as unknown as never, 'job-1', {body: {foo: 'bar'}}); + expect(seenBody).to.deep.equal({foo: 'bar'}); + }); + + it('throws JobAlreadyRunning when waitForRunning is false', async () => { + const instance = { + ocapi: { + async POST() { + return { + data: undefined, + error: undefined, + response: new Response('JobAlreadyRunningException', {status: 400}), + }; + }, + } as unknown as FakeOcapi, + }; + + await expectRejectsWith( + executeJob(instance as unknown as never, 'job-1', {waitForRunning: false}), + 'Job job-1 is already running', + ); + }); + + it('throws with fault message on API failure', async () => { + const instance = { + ocapi: { + async POST() { + return { + data: undefined, + error: {fault: {message: 'bad'}}, + response: new Response('{}', {status: 500}), + }; + }, + } as unknown as FakeOcapi, + }; + + await expectRejectsWith(executeJob(instance as unknown as never, 'job-1'), 'bad'); + }); + }); + + describe('JobExecutionError', () => { + it('is an Error with name JobExecutionError and carries execution', () => { + const err = new JobExecutionError('failed', {id: 'e1'} as unknown as never); + expect(err).to.be.instanceOf(Error); + expect(err.name).to.equal('JobExecutionError'); + expect(err.execution).to.deep.equal({id: 'e1'}); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts new file mode 100644 index 00000000..b3af750e --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts @@ -0,0 +1,444 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {WebDavClient} from '../../../src/clients/webdav.js'; +import {createOcapiClient} from '../../../src/clients/ocapi.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import { + siteArchiveImport, + siteArchiveExport, + siteArchiveExportToPath, +} from '../../../src/operations/jobs/site-archive.js'; + +const TEST_HOST = 'test.demandware.net'; +const WEBDAV_BASE = `https://${TEST_HOST}/on/demandware.servlet/webdav/Sites`; +const OCAPI_BASE = `https://${TEST_HOST}/s/-/dw/data/v25_6`; + +describe('operations/jobs/site-archive', () => { + const server = setupServer(); + let mockInstance: any; + let tempDir: string; + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + beforeEach(() => { + // Create temp directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-site-archive-')); + + // Create a real instance with mocked HTTP + const auth = new MockAuthStrategy(); + const webdav = new WebDavClient(TEST_HOST, auth); + const ocapi = createOcapiClient(TEST_HOST, auth); + + mockInstance = { + config: { + hostname: TEST_HOST, + }, + webdav, + ocapi, + }; + }); + + afterEach(() => { + server.resetHandlers(); + + // Clean up temp directory + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + after(() => { + server.close(); + }); + + describe('siteArchiveImport', () => { + it('should import from a local directory', async () => { + // Create a test directory structure + const siteDir = path.join(tempDir, 'site-data'); + fs.mkdirSync(path.join(siteDir, 'catalogs'), {recursive: true}); + fs.writeFileSync(path.join(siteDir, 'catalogs', 'catalog.xml'), ''); + + let uploadedZip: Buffer | null = null; + let jobExecuted = false; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + if (request.method === 'DELETE') { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + jobExecuted = true; + return HttpResponse.json({ + id: 'exec-1', + execution_status: 'finished', + exit_status: {code: 'OK', message: 'Success'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-1`, () => { + return HttpResponse.json({ + id: 'exec-1', + execution_status: 'finished', + exit_status: {code: 'OK', message: 'Success'}, + is_log_file_existing: false, + }); + }), + ); + + const result = await siteArchiveImport(mockInstance, siteDir, { + archiveName: 'test-import', + }); + + expect(result.execution.id).to.equal('exec-1'); + expect(result.execution.execution_status).to.equal('finished'); + expect(result.archiveFilename).to.include('test-import'); + expect(result.archiveKept).to.be.false; + expect(uploadedZip).to.not.be.null; + expect(uploadedZip!.length).to.be.greaterThan(0); + expect(jobExecuted).to.be.true; + }); + + it('should import from a zip file', async () => { + // Create a test zip file + const zipPath = path.join(tempDir, 'test.zip'); + fs.writeFileSync(zipPath, Buffer.from('PK\x03\x04')); // Minimal zip header + + let uploadedZip: Buffer | null = null; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + const url = new URL(request.url); + if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) { + uploadedZip = Buffer.from(await request.arrayBuffer()); + return new HttpResponse(null, {status: 201}); + } + if (request.method === 'DELETE') { + return new HttpResponse(null, {status: 204}); + } + return new HttpResponse(null, {status: 404}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-2', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-2`, () => { + return HttpResponse.json({ + id: 'exec-2', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + ); + + const result = await siteArchiveImport(mockInstance, zipPath); + + expect(result.execution.id).to.equal('exec-2'); + expect(uploadedZip).to.not.be.null; + }); + + it('should import from a Buffer', async () => { + const zipBuffer = Buffer.from('PK\x03\x04test-data'); + + server.use( + http.all(`${WEBDAV_BASE}/*`, async () => { + return new HttpResponse(null, {status: 201}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-3', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-3`, () => { + return HttpResponse.json({ + id: 'exec-3', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + ); + + const result = await siteArchiveImport(mockInstance, zipBuffer, { + archiveName: 'buffer-import', + }); + + expect(result.execution.id).to.equal('exec-3'); + expect(result.archiveFilename).to.include('buffer-import'); + }); + + it('should import from remote filename', async () => { + server.use( + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-4', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-4`, () => { + return HttpResponse.json({ + id: 'exec-4', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + ); + + const result = await siteArchiveImport(mockInstance, {remoteFilename: 'existing-archive.zip'}); + + expect(result.execution.id).to.equal('exec-4'); + expect(result.archiveFilename).to.equal('existing-archive.zip'); + }); + + it('should keep archive when keepArchive is true', async () => { + const zipPath = path.join(tempDir, 'test.zip'); + fs.writeFileSync(zipPath, Buffer.from('PK\x03\x04')); + + let deleteRequested = false; + + server.use( + http.all(`${WEBDAV_BASE}/*`, async ({request}) => { + if (request.method === 'DELETE') { + deleteRequested = true; + } + return new HttpResponse(null, {status: request.method === 'PUT' ? 201 : 204}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-5', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-5`, () => { + return HttpResponse.json({ + id: 'exec-5', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + ); + + const result = await siteArchiveImport(mockInstance, zipPath, { + keepArchive: true, + }); + + expect(result.archiveKept).to.be.true; + expect(deleteRequested).to.be.false; + }); + + it('should throw error when archiveName is missing for Buffer', async () => { + const zipBuffer = Buffer.from('PK\x03\x04test-data'); + + try { + await siteArchiveImport(mockInstance, zipBuffer); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('archiveName is required'); + } + }); + + it('should throw JobExecutionError when import fails', async () => { + const zipPath = path.join(tempDir, 'test.zip'); + fs.writeFileSync(zipPath, Buffer.from('PK\x03\x04')); + + server.use( + http.all(`${WEBDAV_BASE}/*`, () => { + return new HttpResponse(null, {status: 201}); + }), + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => { + return HttpResponse.json({ + id: 'exec-fail', + execution_status: 'finished', + exit_status: {code: 'ERROR', message: 'Import failed'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-fail`, () => { + return HttpResponse.json({ + id: 'exec-fail', + execution_status: 'finished', + exit_status: {code: 'ERROR', message: 'Import failed'}, + is_log_file_existing: false, + }); + }), + ); + + try { + await siteArchiveImport(mockInstance, zipPath); + expect.fail('Should have thrown JobExecutionError'); + } catch (error: any) { + expect(error.name).to.equal('JobExecutionError'); + // The error message includes the job ID + expect(error.message).to.include('failed'); + } + }); + }); + + describe('siteArchiveExport', () => { + it('should export to a local file', async () => { + const exportPath = path.join(tempDir, 'export.zip'); + + server.use( + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions`, () => { + return HttpResponse.json({ + id: 'export-1', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions/export-1`, () => { + return HttpResponse.json({ + id: 'export-1', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + http.get(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + // Return a minimal zip file + return new HttpResponse(Buffer.from('PK\x03\x04test-export-data'), { + status: 200, + headers: {'Content-Type': 'application/zip'}, + }); + }), + http.delete(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await siteArchiveExportToPath(mockInstance, {global_data: {meta_data: true}}, exportPath); + + expect(result.execution.id).to.equal('export-1'); + expect(result.localPath).to.equal(exportPath); + expect(fs.existsSync(exportPath)).to.be.true; + + const content = fs.readFileSync(exportPath); + expect(content.toString()).to.include('test-export-data'); + }); + + it('should export without downloading when localPath is not provided', async () => { + server.use( + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions`, () => { + return HttpResponse.json({ + id: 'export-2', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions/export-2`, () => { + return HttpResponse.json({ + id: 'export-2', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + http.get(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + return new HttpResponse(Buffer.from('PK\x03\x04test-data'), { + status: 200, + headers: {'Content-Type': 'application/zip'}, + }); + }), + http.delete(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await siteArchiveExport(mockInstance, {global_data: {meta_data: true}}); + + expect(result.execution.id).to.equal('export-2'); + expect(result.data).to.be.instanceOf(Buffer); + }); + + it('should throw JobExecutionError when export fails', async () => { + const exportPath = path.join(tempDir, 'export-fail.zip'); + + server.use( + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions`, () => { + return HttpResponse.json({ + id: 'export-fail', + execution_status: 'finished', + exit_status: {code: 'ERROR', message: 'Export failed'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions/export-fail`, () => { + return HttpResponse.json({ + id: 'export-fail', + execution_status: 'finished', + exit_status: {code: 'ERROR', message: 'Export failed'}, + is_log_file_existing: false, + }); + }), + ); + + try { + await siteArchiveExportToPath(mockInstance, {}, exportPath); + expect.fail('Should have thrown JobExecutionError'); + } catch (error: any) { + expect(error.name).to.equal('JobExecutionError'); + // The error message includes the job ID + expect(error.message).to.include('failed'); + } + }); + + it('should use default archive name when not provided', async () => { + server.use( + http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions`, () => { + return HttpResponse.json({ + id: 'export-3', + execution_status: 'finished', + exit_status: {code: 'OK'}, + }); + }), + http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-export/executions/export-3`, () => { + return HttpResponse.json({ + id: 'export-3', + execution_status: 'finished', + exit_status: {code: 'OK'}, + is_log_file_existing: false, + }); + }), + http.get(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + return new HttpResponse(Buffer.from('PK\x03\x04test-data'), { + status: 200, + headers: {'Content-Type': 'application/zip'}, + }); + }), + http.delete(`${WEBDAV_BASE}/Impex/src/instance/*`, () => { + return new HttpResponse(null, {status: 204}); + }), + ); + + const result = await siteArchiveExport(mockInstance, {global_data: {meta_data: true}}); + + expect(result.archiveFilename).to.match(/\d{8}T\d{9}Z_export\.zip/); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/bundle.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/bundle.test.ts new file mode 100644 index 00000000..44914ff7 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/mrt/bundle.test.ts @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import {createBundle, DEFAULT_SSR_PARAMETERS} from '../../../src/operations/mrt/bundle.js'; + +describe('operations/mrt/bundle', () => { + let tempDir: string; + + beforeEach(() => { + // Create temp directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'b2c-sdk-bundle-')); + }); + + afterEach(() => { + // Clean up temp directory + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } + }); + + describe('createBundle', () => { + it('should create a bundle from a build directory', async () => { + // Create a mock build directory + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'ssr.js'), 'console.log("ssr");'); + fs.mkdirSync(path.join(buildDir, 'static'), {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'static', 'index.html'), ''); + + const bundle = await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['static/**/*'], + buildDirectory: buildDir, + message: 'Test bundle', + }); + + expect(bundle.message).to.equal('Test bundle'); + expect(bundle.encoding).to.equal('base64'); + expect(bundle.data).to.be.a('string'); + expect(bundle.data.length).to.be.greaterThan(0); + expect(bundle.ssr_parameters).to.deep.equal(DEFAULT_SSR_PARAMETERS); + expect(bundle.ssr_only).to.be.an('array'); + expect(bundle.ssr_shared).to.be.an('array'); + // The function resolves globs to actual files + expect(bundle.ssr_only.some((f) => f.includes('ssr.js'))).to.be.true; + expect(bundle.ssr_shared.some((f) => f.includes('index.html'))).to.be.true; + }); + + it('should use custom SSR parameters', async () => { + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'ssr.js'), 'console.log("ssr");'); + + const customParams = { + SSRFunctionNodeVersion: '18.x', + CustomParam: 'value', + }; + + const bundle = await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['**/*.json'], + buildDirectory: buildDir, + ssrParameters: customParams, + }); + + expect(bundle.ssr_parameters).to.deep.equal(customParams); + }); + + it('should use default build directory when not specified', async () => { + // Create a build directory in tempDir + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'ssr.js'), 'console.log("ssr");'); + + // Change to tempDir + const originalCwd = process.cwd(); + try { + process.chdir(tempDir); + + const bundle = await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['**/*.json'], + }); + + expect(bundle.data).to.be.a('string'); + expect(bundle.data.length).to.be.greaterThan(0); + } finally { + process.chdir(originalCwd); + } + }); + + it('should generate default message when not provided', async () => { + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'ssr.js'), 'console.log("ssr");'); + + const bundle = await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['**/*.json'], + buildDirectory: buildDir, + }); + + expect(bundle.message).to.be.a('string'); + expect(bundle.message.length).to.be.greaterThan(0); + }); + + it('should require non-empty patterns', async () => { + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, {recursive: true}); + + try { + await createBundle({ + projectSlug: 'test-project', + ssrOnly: [], + ssrShared: [], + buildDirectory: buildDir, + }); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('ssrOnly and ssrShared patterns are required'); + } + }); + + it('should handle nested directories', async () => { + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(path.join(buildDir, 'static', 'css'), {recursive: true}); + fs.mkdirSync(path.join(buildDir, 'static', 'js'), {recursive: true}); + fs.writeFileSync(path.join(buildDir, 'static', 'css', 'style.css'), 'body {}'); + fs.writeFileSync(path.join(buildDir, 'static', 'js', 'app.js'), 'console.log("app");'); + fs.writeFileSync(path.join(buildDir, 'ssr.js'), 'console.log("ssr");'); + + const bundle = await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['static/**/*'], + buildDirectory: buildDir, + }); + + expect(bundle.data).to.be.a('string'); + expect(bundle.data.length).to.be.greaterThan(0); + }); + + it('should throw error when build directory does not exist', async () => { + const nonExistentDir = path.join(tempDir, 'nonexistent'); + + try { + await createBundle({ + projectSlug: 'test-project', + ssrOnly: ['ssr.js'], + ssrShared: ['static/**/*'], + buildDirectory: nonExistentDir, + }); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Build directory at path'); + expect(error.message).to.include('not found'); + } + }); + }); + + describe('DEFAULT_SSR_PARAMETERS', () => { + it('should have SSRFunctionNodeVersion set to 22.x', () => { + expect(DEFAULT_SSR_PARAMETERS).to.have.property('SSRFunctionNodeVersion'); + expect(DEFAULT_SSR_PARAMETERS.SSRFunctionNodeVersion).to.equal('22.x'); + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts b/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts new file mode 100644 index 00000000..48b2647c --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/mrt/env.test.ts @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {DEFAULT_MRT_ORIGIN} from '../../../src/clients/mrt.js'; +import {MockAuthStrategy} from '../../helpers/mock-auth.js'; +import {createEnv, getEnv, deleteEnv, waitForEnv} from '../../../src/operations/mrt/env.js'; + +const DEFAULT_BASE_URL = DEFAULT_MRT_ORIGIN; + +describe('operations/mrt/env', () => { + const server = setupServer(); + + before(() => { + server.listen({onUnhandledRequest: 'error'}); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + after(() => { + server.close(); + }); + + describe('createEnv', () => { + it('should create an environment with minimal options', async () => { + server.use( + http.post(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/`, async ({request}) => { + const body = (await request.json()) as any; + return HttpResponse.json({ + slug: body.slug, + name: body.name, + state: 'creating', + created_at: '2025-01-01T00:00:00Z', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const result = await createEnv( + { + projectSlug: 'my-project', + slug: 'staging', + name: 'Staging Environment', + }, + auth, + ); + + expect(result.slug).to.equal('staging'); + expect(result.name).to.equal('Staging Environment'); + expect(result.state).to.equal('creating'); + }); + + it('should create an environment with all options', async () => { + let receivedBody: any; + + server.use( + http.post(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/`, async ({request}) => { + receivedBody = await request.json(); + return HttpResponse.json({ + slug: receivedBody.slug, + name: receivedBody.name, + state: 'CREATING', + is_production: receivedBody.is_production, + ssr_region: receivedBody.ssr_region, + }); + }), + ); + + const auth = new MockAuthStrategy(); + const result = await createEnv( + { + projectSlug: 'my-project', + slug: 'production', + name: 'Production Environment', + region: 'us-east-1', + isProduction: true, + hostname: '*.example.com', + externalHostname: 'www.example.com', + externalDomain: 'example.com', + allowCookies: true, + enableSourceMaps: false, + logLevel: 'INFO', + whitelistedIps: '192.168.1.0/24', + proxyConfigs: [ + { + path: 'api', + host: 'api.example.com', + }, + ], + }, + auth, + ); + + expect(result.slug).to.equal('production'); + expect(result.is_production).to.be.true; + expect(receivedBody.ssr_region).to.equal('us-east-1'); + expect(receivedBody.hostname).to.equal('*.example.com'); + expect(receivedBody.ssr_external_hostname).to.equal('www.example.com'); + expect(receivedBody.ssr_proxy_configs).to.have.lengthOf(1); + }); + + it('should handle API errors', async () => { + server.use( + http.post(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/`, () => { + return HttpResponse.json({error: 'Environment already exists'}, {status: 400}); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await createEnv( + { + projectSlug: 'my-project', + slug: 'staging', + name: 'Staging', + }, + auth, + ); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to create environment'); + } + }); + }); + + describe('getEnv', () => { + it('should get a specific environment', async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + name: 'Staging Environment', + state: 'ready', + is_production: false, + }); + }), + ); + + const auth = new MockAuthStrategy(); + const result = await getEnv({projectSlug: 'my-project', slug: 'staging'}, auth); + + expect(result.slug).to.equal('staging'); + expect(result.name).to.equal('Staging Environment'); + expect(result.state).to.equal('ready'); + }); + + it('should handle 404 for non-existent environment', async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({error: 'Not found'}, {status: 404}); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await getEnv({projectSlug: 'my-project', slug: 'nonexistent'}, auth); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to get environment'); + } + }); + }); + + describe('deleteEnv', () => { + it('should delete an environment', async () => { + let deleteRequested = false; + + server.use( + http.delete(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + deleteRequested = true; + return new HttpResponse(null, {status: 204}); + }), + ); + + const auth = new MockAuthStrategy(); + await deleteEnv({projectSlug: 'my-project', slug: 'staging'}, auth); + + expect(deleteRequested).to.be.true; + }); + + it('should handle delete errors', async () => { + server.use( + http.delete(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({error: 'Cannot delete'}, {status: 400}); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await deleteEnv({projectSlug: 'my-project', slug: 'staging'}, auth); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('Failed to delete environment'); + } + }); + }); + + describe('waitForEnv', () => { + it('should return immediately when environment is ACTIVE', async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + state: 'ACTIVE', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const result = await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollInterval: 100, + }, + auth, + ); + + expect(result.state).to.equal('ACTIVE'); + }); + + it('should poll until environment becomes ACTIVE', async function () { + this.timeout(5000); + + let callCount = 0; + + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + callCount++; + if (callCount < 3) { + return HttpResponse.json({ + slug: 'staging', + state: 'CREATING', + }); + } + return HttpResponse.json({ + slug: 'staging', + state: 'ACTIVE', + }); + }), + ); + + const auth = new MockAuthStrategy(); + const result = await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollInterval: 100, + }, + auth, + ); + + expect(result.state).to.equal('ACTIVE'); + expect(callCount).to.be.greaterThanOrEqual(3); + }); + + it('should call onPoll callback', async function () { + this.timeout(5000); + + const pollUpdates: any[] = []; + + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + state: 'ACTIVE', + }); + }), + ); + + const auth = new MockAuthStrategy(); + await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollInterval: 100, + onPoll: (env) => { + pollUpdates.push(env); + }, + }, + auth, + ); + + expect(pollUpdates.length).to.be.greaterThan(0); + expect(pollUpdates[0].slug).to.equal('staging'); + }); + + it('should timeout after specified duration', async function () { + this.timeout(5000); + + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + state: 'CREATING', + }); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollInterval: 100, + timeout: 500, + }, + auth, + ); + expect.fail('Should have thrown timeout error'); + } catch (error: any) { + expect(error.message).to.include('Timeout'); + } + }); + + it('should throw error when environment enters CREATE_FAILED state', async () => { + server.use( + http.get(`${DEFAULT_BASE_URL}/api/projects/:projectSlug/target/:targetSlug/`, () => { + return HttpResponse.json({ + slug: 'staging', + state: 'CREATE_FAILED', + }); + }), + ); + + const auth = new MockAuthStrategy(); + + try { + await waitForEnv( + { + projectSlug: 'my-project', + slug: 'staging', + pollInterval: 100, + }, + auth, + ); + expect.fail('Should have thrown error'); + } catch (error: any) { + expect(error.message).to.include('creation failed'); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts b/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts new file mode 100644 index 00000000..764c7bcd --- /dev/null +++ b/packages/b2c-tooling-sdk/test/operations/sites/index.test.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ + +import {expect} from 'chai'; +import {listSites, getSite} from '../../../src/operations/sites/index.js'; +import {B2CInstance} from '../../../src/instance/index.js'; + +describe('operations/sites', () => { + let mockInstance: B2CInstance; + + beforeEach(() => { + mockInstance = new B2CInstance( + {hostname: 'test.demandware.net'}, + { + oauth: {clientId: 'test-client', clientSecret: 'test-secret'}, + }, + ); + }); + + describe('listSites', () => { + it('should return empty array (TODO implementation)', async () => { + const sites = await listSites(mockInstance); + expect(sites).to.be.an('array'); + expect(sites).to.have.lengthOf(0); + }); + }); + + describe('getSite', () => { + it('should return null (TODO implementation)', async () => { + const site = await getSite(mockInstance, 'RefArch'); + expect(site).to.be.null; + }); + }); +});