From 2d446fd8eaff81d8113bd45d37a698b5f607054a Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Wed, 13 Aug 2025 10:13:56 +0200 Subject: [PATCH 1/3] New feature: Adds OCAPI client Introduces a new OCAPI client with specialized modules for system objects, site preferences, and catalog operations. This change implements a facade pattern, delegating calls to specialized clients and handling authentication. It also adds validation and query building utilities to ensure correct API usage. --- .github/copilot-instructions.md | 30 +- src/clients/base/http-client.ts | 152 +++ src/clients/base/ocapi-auth-client.ts | 120 +++ src/clients/ocapi-client.ts | 682 +++--------- src/clients/ocapi/catalog-client.ts | 105 ++ src/clients/ocapi/site-preferences-client.ts | 108 ++ src/clients/ocapi/system-objects-client.ts | 158 +++ src/utils/query-builder.ts | 84 ++ src/utils/validator.ts | 162 +++ .../__mocks__/src/clients/base/http-client.js | 43 + tests/base-http-client.test.ts | 287 +++++ tests/catalog-client.test.ts | 299 ++++++ tests/ocapi-auth-client.test.ts | 252 +++++ tests/ocapi-client.test.ts | 995 +++--------------- tests/query-builder.test.ts | 281 +++++ tests/site-preferences-client.test.ts | 358 +++++++ tests/system-objects-client.test.ts | 339 ++++++ tests/validator.test.ts | 416 ++++++++ 18 files changed, 3507 insertions(+), 1364 deletions(-) create mode 100644 src/clients/base/http-client.ts create mode 100644 src/clients/base/ocapi-auth-client.ts create mode 100644 src/clients/ocapi/catalog-client.ts create mode 100644 src/clients/ocapi/site-preferences-client.ts create mode 100644 src/clients/ocapi/system-objects-client.ts create mode 100644 src/utils/query-builder.ts create mode 100644 src/utils/validator.ts create mode 100644 tests/__mocks__/src/clients/base/http-client.js create mode 100644 tests/base-http-client.test.ts create mode 100644 tests/catalog-client.test.ts create mode 100644 tests/ocapi-auth-client.test.ts create mode 100644 tests/query-builder.test.ts create mode 100644 tests/site-preferences-client.test.ts create mode 100644 tests/system-objects-client.test.ts create mode 100644 tests/validator.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 72bf1d7..714bd20 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -53,9 +53,16 @@ sfcc-dev-mcp/ │ │ ├── server.ts # Main MCP server implementation │ │ └── tool-definitions.ts # MCP tool schema definitions │ ├── clients/ # API clients for different services +│ │ ├── base/ # Base client classes and shared functionality +│ │ │ ├── http-client.ts # Base HTTP client with authentication +│ │ │ └── ocapi-auth-client.ts # OCAPI OAuth authentication client +│ │ ├── ocapi/ # Specialized OCAPI clients +│ │ │ ├── catalog-client.ts # OCAPI catalog operations +│ │ │ ├── site-preferences-client.ts # Site preferences management +│ │ │ └── system-objects-client.ts # System object definitions │ │ ├── log-client.ts # SFCC log analysis client │ │ ├── docs-client.ts # SFCC documentation client -│ │ ├── ocapi-client.ts # OCAPI client for system objects +│ │ ├── ocapi-client.ts # Main OCAPI client coordinator │ │ └── best-practices-client.ts # Best practices guide client │ ├── auth/ # Authentication and OAuth management │ │ └── oauth-token.ts # OAuth token management @@ -67,6 +74,8 @@ sfcc-dev-mcp/ │ │ ├── cache.ts # Caching layer for API responses │ │ ├── logger.ts # Structured logging system │ │ ├── utils.ts # Common utility functions +│ │ ├── validator.ts # Input validation utilities +│ │ ├── query-builder.ts # Query string building utilities │ │ └── path-resolver.ts # File path resolution utilities │ └── types/ # TypeScript type definitions │ └── types.ts # Comprehensive type definitions @@ -98,10 +107,21 @@ sfcc-dev-mcp/ - Provides error handling and response formatting #### **Client Architecture** -- **DocsClient** (`clients/docs-client.ts`): Processes SFCC documentation and provides search capabilities -- **LogClient** (`clients/log-client.ts`): Connects to SFCC instances for log analysis and monitoring -- **OCAPIClient** (`clients/ocapi-client.ts`): Interfaces with SFCC OCAPI for system object data -- **BestPracticesClient** (`clients/best-practices-client.ts`): Serves curated development guides and references + +##### **Base Client Infrastructure** (`clients/base/`) +- **BaseHttpClient** (`http-client.ts`): Abstract base class providing HTTP operations, authentication handling, and error recovery +- **OCAPIAuthClient** (`ocapi-auth-client.ts`): OCAPI-specific OAuth authentication with token management and automatic renewal + +##### **Specialized OCAPI Clients** (`clients/ocapi/`) +- **OCAPICatalogClient** (`catalog-client.ts`): Handles catalog operations, product searches, and category management +- **OCAPISitePreferencesClient** (`site-preferences-client.ts`): Manages site preference searches and configuration discovery +- **OCAPISystemObjectsClient** (`system-objects-client.ts`): Provides system object definitions, attribute schemas, and custom object exploration + +##### **Service Clients** (`clients/`) +- **DocsClient** (`docs-client.ts`): Processes SFCC documentation and provides search capabilities across all namespaces +- **LogClient** (`log-client.ts`): Connects to SFCC instances for real-time log analysis and monitoring +- **OCAPIClient** (`ocapi-client.ts`): Main OCAPI coordinator that orchestrates specialized clients and provides unified interface +- **BestPracticesClient** (`best-practices-client.ts`): Serves curated development guides, security recommendations, and hook references #### **Authentication & Security** (`auth/`) - **OAuth Token Management** (`oauth-token.ts`): Handles SFCC OAuth flows with automatic renewal for local development diff --git a/src/clients/base/http-client.ts b/src/clients/base/http-client.ts new file mode 100644 index 0000000..ac44bea --- /dev/null +++ b/src/clients/base/http-client.ts @@ -0,0 +1,152 @@ +/** + * Base HTTP Client for SFCC API requests + * + * This module provides a foundation for making authenticated HTTP requests to SFCC APIs. + * It handles common concerns like authentication, request/response formatting, and error handling. + */ + +import { Logger } from '../../utils/logger.js'; + +/** + * HTTP request options interface + */ +export interface HttpRequestOptions extends RequestInit { + headers?: Record; +} + +/** + * Base HTTP client for SFCC API communication + */ +export abstract class BaseHttpClient { + protected baseUrl: string; + protected logger: Logger; + + constructor(baseUrl: string, loggerContext: string) { + this.baseUrl = baseUrl; + this.logger = new Logger(loggerContext); + } + + /** + * Get authentication headers - must be implemented by subclasses + */ + protected abstract getAuthHeaders(): Promise>; + + /** + * Handle authentication errors - can be overridden by subclasses + */ + protected async handleAuthError(): Promise { + // Default implementation does nothing + // Subclasses can override to clear tokens, retry, etc. + } + + /** + * Make an authenticated HTTP request + */ + protected async makeRequest( + endpoint: string, + options: HttpRequestOptions = {}, + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + const method = options.method ?? 'GET'; + + this.logger.debug(`Making ${method} request to: ${endpoint}`); + + // Get authentication headers + const authHeaders = await this.getAuthHeaders(); + + const requestOptions: RequestInit = { + ...options, + headers: { + 'Content-Type': 'application/json', + ...authHeaders, + ...options.headers, + }, + }; + + try { + const response = await fetch(url, requestOptions); + + if (!response.ok) { + // Handle authentication errors + if (response.status === 401) { + this.logger.debug('Received 401, attempting to handle auth error'); + await this.handleAuthError(); + + // Retry with fresh authentication + const newAuthHeaders = await this.getAuthHeaders(); + requestOptions.headers = { + ...requestOptions.headers, + ...newAuthHeaders, + }; + + const retryResponse = await fetch(url, requestOptions); + if (!retryResponse.ok) { + const errorText = await retryResponse.text(); + throw new Error( + `Request failed after retry: ${retryResponse.status} ${retryResponse.statusText} - ${errorText}`, + ); + } + + this.logger.debug('Retry request successful'); + return retryResponse.json(); + } + + const errorText = await response.text(); + throw new Error(`Request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + this.logger.debug(`Request to ${endpoint} completed successfully`); + return response.json(); + } catch (error) { + this.logger.error(`Network error during request to ${endpoint}: ${error}`); + throw error; + } + } + + /** + * GET request + */ + protected async get(endpoint: string): Promise { + return this.makeRequest(endpoint, { method: 'GET' }); + } + + /** + * POST request + */ + protected async post(endpoint: string, data?: any): Promise { + const options: HttpRequestOptions = { method: 'POST' }; + if (data) { + options.body = JSON.stringify(data); + } + return this.makeRequest(endpoint, options); + } + + /** + * PUT request + */ + protected async put(endpoint: string, data?: any): Promise { + const options: HttpRequestOptions = { method: 'PUT' }; + if (data) { + options.body = JSON.stringify(data); + } + return this.makeRequest(endpoint, options); + } + + /** + * PATCH request + */ + protected async patch(endpoint: string, data?: any): Promise { + const options: HttpRequestOptions = { method: 'PATCH' }; + if (data) { + options.body = JSON.stringify(data); + } + return this.makeRequest(endpoint, options); + } + + /** + * DELETE request + */ + protected async delete(endpoint: string): Promise { + return this.makeRequest(endpoint, { method: 'DELETE' }); + } +} diff --git a/src/clients/base/ocapi-auth-client.ts b/src/clients/base/ocapi-auth-client.ts new file mode 100644 index 0000000..ac92e0a --- /dev/null +++ b/src/clients/base/ocapi-auth-client.ts @@ -0,0 +1,120 @@ +/** + * OCAPI Authentication Client + * + * This module handles OAuth 2.0 authentication specifically for SFCC OCAPI requests. + * It extends the base HTTP client with OCAPI-specific authentication logic. + */ + +import { OCAPIConfig, OAuthTokenResponse } from '../../types/types.js'; +import { TokenManager } from '../../auth/oauth-token.js'; +import { BaseHttpClient } from './http-client.js'; + +// OCAPI authentication constants +const OCAPI_AUTH_CONSTANTS = { + AUTH_URL: 'https://account.demandware.com/dwsso/oauth2/access_token', + GRANT_TYPE: 'client_credentials', + FORM_CONTENT_TYPE: 'application/x-www-form-urlencoded', +} as const; + +/** + * OCAPI Authentication Client + * Handles OAuth 2.0 Client Credentials flow for OCAPI access + */ +export class OCAPIAuthClient extends BaseHttpClient { + private config: OCAPIConfig; + private tokenManager: TokenManager; + + constructor(config: OCAPIConfig) { + super('', 'OCAPIAuthClient'); // Initialize BaseHttpClient with logger + this.config = config; + this.tokenManager = TokenManager.getInstance(); + } + + /** + * Get authentication headers for OCAPI requests + */ + protected async getAuthHeaders(): Promise> { + const accessToken = await this.getAccessToken(); + return { + 'Authorization': `Bearer ${accessToken}`, + }; + } + + /** + * Handle authentication errors by clearing the stored token + */ + protected async handleAuthError(): Promise { + this.logger.debug('Clearing token due to authentication error'); + this.tokenManager.clearToken(this.config.hostname, this.config.clientId); + } + + /** + * Get a valid OAuth access token + */ + private async getAccessToken(): Promise { + this.logger.debug('Attempting to get access token'); + + // Check if we have a valid token first + const existingToken = this.tokenManager.getValidToken(this.config.hostname, this.config.clientId); + if (existingToken) { + this.logger.debug('Using existing valid token'); + return existingToken; + } + + this.logger.debug('No valid token found, requesting new token'); + return this.requestNewToken(); + } + + /** + * Request a new OAuth token from SFCC + */ + private async requestNewToken(): Promise { + // Create Basic Auth header using client credentials + const credentials = `${this.config.clientId}:${this.config.clientSecret}`; + const encodedCredentials = Buffer.from(credentials).toString('base64'); + + try { + const response = await fetch(OCAPI_AUTH_CONSTANTS.AUTH_URL, { + method: 'POST', + headers: { + 'Authorization': `Basic ${encodedCredentials}`, + 'Content-Type': OCAPI_AUTH_CONSTANTS.FORM_CONTENT_TYPE, + }, + body: `grant_type=${OCAPI_AUTH_CONSTANTS.GRANT_TYPE}`, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OAuth authentication failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const tokenResponse: OAuthTokenResponse = await response.json(); + this.logger.debug('Successfully obtained new access token'); + + // Store the token for future use + this.tokenManager.storeToken(this.config.hostname, this.config.clientId, tokenResponse); + + return tokenResponse.access_token; + } catch (error) { + this.logger.error(`Failed to get access token: ${error}`); + throw new Error(`Failed to get access token: ${error}`); + } + } + + /** + * Get current token expiration for debugging + */ + getTokenExpiration(): Date | null { + return this.tokenManager.getTokenExpiration(this.config.hostname, this.config.clientId); + } + + /** + * Force refresh the token + */ + async refreshToken(): Promise { + this.logger.debug('Forcing token refresh'); + this.tokenManager.clearToken(this.config.hostname, this.config.clientId); + await this.getAccessToken(); + this.logger.debug('Token refresh completed'); + } +} diff --git a/src/clients/ocapi-client.ts b/src/clients/ocapi-client.ts index e645f61..328b060 100644 --- a/src/clients/ocapi-client.ts +++ b/src/clients/ocapi-client.ts @@ -1,178 +1,190 @@ /** * OCAPI Client for Salesforce Commerce Cloud * - * This module provides a client for making authenticated requests to SFCC's Open Commerce API (OCAPI) - * using OAuth 2.0 Client Credentials grant flow. It handles automatic token management and refresh. + * This module provides a unified interface for making authenticated requests to SFCC's Open Commerce API (OCAPI). + * It orchestrates specialized client modules for different domain areas while maintaining backward compatibility. */ -import { OCAPIConfig, OAuthTokenResponse } from '../types/types.js'; -import { TokenManager } from '../auth/oauth-token.js'; +import { OCAPIConfig } from '../types/types.js'; +import { OCAPISystemObjectsClient } from './ocapi/system-objects-client.js'; +import { OCAPISitePreferencesClient } from './ocapi/site-preferences-client.js'; +import { OCAPICatalogClient } from './ocapi/catalog-client.js'; +import { OCAPIAuthClient } from './base/ocapi-auth-client.js'; +import { Logger } from '../utils/logger.js'; + +// Create a logger instance for this module +const logger = new Logger('OCAPIClient'); + +/** + * Custom error class for OCAPI-specific errors + */ +export class OCAPIError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly endpoint?: string, + ) { + super(message); + this.name = 'OCAPIError'; + } +} /** - * OCAPI Client with OAuth 2.0 authentication + * Interface for common query parameters used across multiple endpoints + */ +interface BaseQueryParams { + start?: number; + count?: number; + select?: string; +} + +/** + * Interface for search request structure used in multiple search endpoints + */ +interface SearchRequest { + query?: { + text_query?: { + fields: string[]; + search_phrase: string; + }; + term_query?: { + fields: string[]; + operator: string; + values: any[]; + }; + filtered_query?: { + filter: any; + query: any; + }; + bool_query?: { + must?: any[]; + must_not?: any[]; + should?: any[]; + }; + match_all_query?: {}; + }; + sorts?: Array<{ + field: string; + sort_order?: 'asc' | 'desc'; + }>; + start?: number; + count?: number; + select?: string; +} + +/** + * OCAPI Client - Unified interface for SFCC OCAPI operations + * + * This class serves as a facade that orchestrates specialized client modules: + * - SystemObjectsClient: Handles system object definitions and attributes + * - SitePreferencesClient: Manages site preference operations + * - CatalogClient: Handles product and category operations + * - AuthClient: Manages OAuth authentication and token lifecycle */ export class OCAPIClient { - private config: OCAPIConfig; - private tokenManager: TokenManager; - private baseUrl: string; + // Specialized client modules + public readonly systemObjects: OCAPISystemObjectsClient; + public readonly sitePreferences: OCAPISitePreferencesClient; + public readonly catalog: OCAPICatalogClient; + private readonly authClient: OCAPIAuthClient; constructor(config: OCAPIConfig) { - this.config = { + logger.debug(`Initializing OCAPI client for hostname: ${config.hostname}`); + + const finalConfig = { version: 'v21_3', ...config, }; - this.tokenManager = TokenManager.getInstance(); - this.baseUrl = `https://${this.config.hostname}/s/-/dw/data/${this.config.version}`; - } - /** - * Get OAuth access token using Client Credentials grant - * Makes a request to the SFCC authorization server to obtain an access token - */ - private async getAccessToken(): Promise { - // Check if we have a valid token first - const existingToken = this.tokenManager.getValidToken(this.config.hostname, this.config.clientId); - if (existingToken) { - return existingToken; - } - - const authUrl = 'https://account.demandware.com/dwsso/oauth2/access_token'; - - // Create Basic Auth header using client credentials - const credentials = `${this.config.clientId}:${this.config.clientSecret}`; - const encodedCredentials = Buffer.from(credentials).toString('base64'); - - const response = await fetch(authUrl, { - method: 'POST', - headers: { - 'Authorization': `Basic ${encodedCredentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: 'grant_type=client_credentials', - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`OAuth authentication failed: ${response.status} ${response.statusText} - ${errorText}`); - } + // Initialize specialized clients + this.systemObjects = new OCAPISystemObjectsClient(finalConfig); + this.sitePreferences = new OCAPISitePreferencesClient(finalConfig); + this.catalog = new OCAPICatalogClient(finalConfig); + this.authClient = new OCAPIAuthClient(finalConfig); - const tokenResponse: OAuthTokenResponse = await response.json(); + logger.debug('OCAPI client initialized with specialized modules'); + } - // Store the token for future use - this.tokenManager.storeToken(this.config.hostname, this.config.clientId, tokenResponse); + // ============================================================================= + // System Objects API - Delegated to SystemObjectsClient + // ============================================================================= - return tokenResponse.access_token; + /** + * Get all system object definitions + */ + async getSystemObjectDefinitions(params?: BaseQueryParams): Promise { + return this.systemObjects.getSystemObjectDefinitions(params); } /** - * Make an authenticated request to OCAPI + * Get a specific system object definition by object type */ - private async makeRequest(endpoint: string, options: RequestInit = {}): Promise { - const accessToken = await this.getAccessToken(); - - const url = `${this.baseUrl}${endpoint}`; - - const requestOptions: RequestInit = { - ...options, - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - ...options.headers, - }, - }; - - const response = await fetch(url, requestOptions); - - if (!response.ok) { - // If unauthorized, clear the token and try once more - if (response.status === 401) { - this.tokenManager.clearToken(this.config.hostname, this.config.clientId); - - // Retry with a fresh token - const newAccessToken = await this.getAccessToken(); - requestOptions.headers = { - ...requestOptions.headers, - 'Authorization': `Bearer ${newAccessToken}`, - }; - - const retryResponse = await fetch(url, requestOptions); - if (!retryResponse.ok) { - const errorText = await retryResponse.text(); - throw new Error(`OCAPI request failed: ${retryResponse.status} ${retryResponse.statusText} - ${errorText}`); - } - - return retryResponse.json(); - } - - const errorText = await response.text(); - throw new Error(`OCAPI request failed: ${response.status} ${response.statusText} - ${errorText}`); - } - - return response.json(); + async getSystemObjectDefinition(objectType: string): Promise { + return this.systemObjects.getSystemObjectDefinition(objectType); } /** - * GET request to OCAPI + * Search for system object definitions using complex queries */ - async get(endpoint: string): Promise { - return this.makeRequest(endpoint, { method: 'GET' }); + async searchSystemObjectDefinitions(searchRequest: SearchRequest): Promise { + return this.systemObjects.searchSystemObjectDefinitions(searchRequest); } /** - * POST request to OCAPI + * Get attribute definitions for a specific system object type */ - async post(endpoint: string, data?: any): Promise { - const options: RequestInit = { - method: 'POST', - }; - - if (data) { - options.body = JSON.stringify(data); - } - - return this.makeRequest(endpoint, options); + async getSystemObjectAttributeDefinitions( + objectType: string, + options?: BaseQueryParams, + ): Promise { + return this.systemObjects.getSystemObjectAttributeDefinitions(objectType, options); } /** - * PUT request to OCAPI + * Search attribute definitions for a specific system object type using complex queries */ - async put(endpoint: string, data?: any): Promise { - const options: RequestInit = { - method: 'PUT', - }; - - if (data) { - options.body = JSON.stringify(data); - } - - return this.makeRequest(endpoint, options); + async searchSystemObjectAttributeDefinitions( + objectType: string, + searchRequest: SearchRequest, + ): Promise { + return this.systemObjects.searchSystemObjectAttributeDefinitions(objectType, searchRequest); } /** - * PATCH request to OCAPI + * Search attribute groups for a specific system object type */ - async patch(endpoint: string, data?: any): Promise { - const options: RequestInit = { - method: 'PATCH', - }; - - if (data) { - options.body = JSON.stringify(data); - } - - return this.makeRequest(endpoint, options); + async searchSystemObjectAttributeGroups( + objectType: string, + searchRequest: SearchRequest, + ): Promise { + return this.systemObjects.searchSystemObjectAttributeGroups(objectType, searchRequest); } + // ============================================================================= + // Site Preferences API - Delegated to SitePreferencesClient + // ============================================================================= + /** - * DELETE request to OCAPI + * Search site preferences across sites in the specified preference group and instance */ - async delete(endpoint: string): Promise { - return this.makeRequest(endpoint, { method: 'DELETE' }); + async searchSitePreferences( + groupId: string, + instanceType: string, + searchRequest: SearchRequest, + options?: { + maskPasswords?: boolean; + expand?: string; + }, + ): Promise { + return this.sitePreferences.searchSitePreferences(groupId, instanceType, searchRequest, options); } + // ============================================================================= + // Catalog API - Delegated to CatalogClient + // ============================================================================= + /** * Get products from the catalog - * Example usage of the OCAPI client */ async getProducts(params?: { ids?: string[]; @@ -181,34 +193,7 @@ export class OCAPIClient { currency?: string; locale?: string; }): Promise { - let endpoint = '/products'; - - if (params) { - const searchParams = new URLSearchParams(); - - if (params.ids) { - searchParams.append('ids', params.ids.join(',')); - } - if (params.expand) { - searchParams.append('expand', params.expand.join(',')); - } - if (params.inventory_ids) { - searchParams.append('inventory_ids', params.inventory_ids.join(',')); - } - if (params.currency) { - searchParams.append('currency', params.currency); - } - if (params.locale) { - searchParams.append('locale', params.locale); - } - - const queryString = searchParams.toString(); - if (queryString) { - endpoint += `?${queryString}`; - } - } - - return this.get(endpoint); + return this.catalog.getProducts(params); } /** @@ -219,28 +204,7 @@ export class OCAPIClient { levels?: number; locale?: string; }): Promise { - let endpoint = '/categories'; - - if (params) { - const searchParams = new URLSearchParams(); - - if (params.ids) { - searchParams.append('ids', params.ids.join(',')); - } - if (params.levels !== undefined) { - searchParams.append('levels', params.levels.toString()); - } - if (params.locale) { - searchParams.append('locale', params.locale); - } - - const queryString = searchParams.toString(); - if (queryString) { - endpoint += `?${queryString}`; - } - } - - return this.get(endpoint); + return this.catalog.getCategories(params); } /** @@ -256,372 +220,24 @@ export class OCAPIClient { currency?: string; locale?: string; }): Promise { - let endpoint = '/product_search'; - - const searchParams = new URLSearchParams(); - - if (params.q) { - searchParams.append('q', params.q); - } - if (params.refine) { - params.refine.forEach(refine => searchParams.append('refine', refine)); - } - if (params.sort) { - searchParams.append('sort', params.sort); - } - if (params.start !== undefined) { - searchParams.append('start', params.start.toString()); - } - if (params.count !== undefined) { - searchParams.append('count', params.count.toString()); - } - if (params.expand) { - searchParams.append('expand', params.expand.join(',')); - } - if (params.currency) { - searchParams.append('currency', params.currency); - } - if (params.locale) { - searchParams.append('locale', params.locale); - } - - const queryString = searchParams.toString(); - if (queryString) { - endpoint += `?${queryString}`; - } - - return this.get(endpoint); - } - - /** - * Get all system object definitions - * Returns a list of all system objects in SFCC with their metadata - * Useful for discovering what system objects exist and their custom attributes - */ - async getSystemObjectDefinitions(params?: { - start?: number; - count?: number; - select?: string; - }): Promise { - let endpoint = '/system_object_definitions'; - - if (params) { - const searchParams = new URLSearchParams(); - - if (params.start !== undefined) { - searchParams.append('start', params.start.toString()); - } - if (params.count !== undefined) { - searchParams.append('count', params.count.toString()); - } - if (params.select) { - searchParams.append('select', params.select); - } - - const queryString = searchParams.toString(); - if (queryString) { - endpoint += `?${queryString}`; - } - } - - return this.get(endpoint); - } - - /** - * Get a specific system object definition by object type - * Returns detailed information about a single system object including all attributes - * Useful for inspecting custom attributes added to standard SFCC objects like Product, Customer, etc. - */ - async getSystemObjectDefinition(objectType: string): Promise { - if (!objectType || objectType.trim().length === 0) { - throw new Error('Object type is required and cannot be empty'); - } - - const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}`; - return this.get(endpoint); - } - - /** - * Search for system object definitions using complex queries - * Allows for targeted searches instead of fetching all system objects - * Supports text queries and sorting on object_type, display_name, description, and read_only fields - */ - async searchSystemObjectDefinitions(searchRequest: { - query?: { - text_query?: { - fields: string[]; - search_phrase: string; - }; - term_query?: { - fields: string[]; - operator: string; - values: any[]; - }; - filtered_query?: { - filter: any; - query: any; - }; - bool_query?: { - must?: any[]; - must_not?: any[]; - should?: any[]; - }; - match_all_query?: {}; - }; - sorts?: Array<{ - field: string; - sort_order?: 'asc' | 'desc'; - }>; - start?: number; - count?: number; - select?: string; - }): Promise { - const endpoint = '/system_object_definition_search'; - return this.post(endpoint, searchRequest); + return this.catalog.searchProducts(params); } - /** - * Get attribute definitions for a specific system object type - * Returns detailed information about all attributes for a system object including custom attributes - * This provides more detailed attribute information than the basic system object definition - */ - async getSystemObjectAttributeDefinitions( - objectType: string, - options?: { - start?: number; - count?: number; - select?: string; - }, - ): Promise { - if (!objectType || objectType.trim().length === 0) { - throw new Error('Object type is required and cannot be empty'); - } - - const queryParams = new URLSearchParams(); - if (options?.start !== undefined) { - queryParams.append('start', options.start.toString()); - } - if (options?.count !== undefined) { - queryParams.append('count', options.count.toString()); - } - if (options?.select) { - queryParams.append('select', options.select); - } - - const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_definitions`; - const url = queryParams.toString() ? `${endpoint}?${queryParams.toString()}` : endpoint; - - return this.get(url); - } - - /** - * Search attribute definitions for a specific system object type using complex queries - * Allows searching for specific attributes by id, display_name, description, type, and other properties - * Supports text queries, term queries, filtered queries, and boolean queries - * - * Searchable attributes are grouped into buckets: - * - Main: id, display_name, description, key, mandatory, searchable, system, visible - * - Definition version: localizable, site_specific, value_type - * - Group: group - * - * Only attributes in the same bucket can be joined using OR operations - */ - async searchSystemObjectAttributeDefinitions( - objectType: string, - searchRequest: { - query?: { - text_query?: { - fields: string[]; - search_phrase: string; - }; - term_query?: { - fields: string[]; - operator: string; - values: any[]; - }; - filtered_query?: { - filter: any; - query: any; - }; - bool_query?: { - must?: any[]; - must_not?: any[]; - should?: any[]; - }; - match_all_query?: {}; - }; - sorts?: Array<{ - field: string; - sort_order?: 'asc' | 'desc'; - }>; - start?: number; - count?: number; - select?: string; - }, - ): Promise { - if (!objectType || objectType.trim().length === 0) { - throw new Error('Object type is required and cannot be empty'); - } - - const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_definition_search`; - return this.post(endpoint, searchRequest); - } - - /** - * Search site preferences across sites in the specified preference group and instance - * Allows searching for preferences by id, display_name, description, and value_type - * Supports text queries, term queries, filtered queries, and boolean queries - * - * Searchable fields: - * - id - String - * - display_name - Localized String - * - description - Localized String - * - value_type - one of { - * string, int, double, text, html, date, image, boolean, money, quantity, - * datetime, email, password, set_of_string, set_of_int, set_of_double, enum_of_string, enum_of_int - * } - * - * Note: value_type can only be joined with other attributes using a conjunction (AND) - * Only searchable attributes can be used in sorting - */ - async searchSitePreferences( - groupId: string, - instanceType: 'staging' | 'development' | 'sandbox' | 'production', - searchRequest: { - query?: { - text_query?: { - fields: string[]; - search_phrase: string; - }; - term_query?: { - fields: string[]; - operator: string; - values: any[]; - }; - filtered_query?: { - filter: any; - query: any; - }; - bool_query?: { - must?: any[]; - must_not?: any[]; - should?: any[]; - }; - match_all_query?: {}; - }; - sorts?: Array<{ - field: string; - sort_order?: 'asc' | 'desc'; - }>; - start?: number; - count?: number; - select?: string; - }, - options?: { - maskPasswords?: boolean; - expand?: string; - }, - ): Promise { - if (!groupId || groupId.trim().length === 0) { - throw new Error('Group ID is required and cannot be empty'); - } - - if (!instanceType || instanceType.trim().length === 0) { - throw new Error('Instance type is required and cannot be empty'); - } - - // Validate instance type - const validInstanceTypes = ['staging', 'development', 'sandbox', 'production']; - if (!validInstanceTypes.includes(instanceType)) { - throw new Error(`Invalid instance type. Must be one of: ${validInstanceTypes.join(', ')}`); - } - - let endpoint = `/site_preferences/preference_groups/${encodeURIComponent(groupId)}/${instanceType}/preference_search`; - - // Add query parameters if provided - const queryParams = new URLSearchParams(); - if (options?.maskPasswords !== undefined) { - queryParams.append('mask_passwords', options.maskPasswords.toString()); - } - if (options?.expand) { - queryParams.append('expand', options.expand); - } - - if (queryParams.toString()) { - endpoint += `?${queryParams.toString()}`; - } - - return this.post(endpoint, searchRequest); - } - - /** - * Search attribute groups for a specific system object type - * Allows searching for attribute groups by id, display_name, description, position, and internal flag - * Supports text queries, term queries, filtered queries, boolean queries, and match all queries - * - * Searchable and sortable fields: - * - id - String - * - display_name - Localized String - * - description - Localized String - * - position - Double - * - internal - Boolean - * - * This is particularly useful for discovering site preference groups when using the SitePreferences object type, - * as the group ID is required for the site preferences search API. - */ - async searchSystemObjectAttributeGroups( - objectType: string, - searchRequest: { - query?: { - text_query?: { - fields: string[]; - search_phrase: string; - }; - term_query?: { - fields: string[]; - operator: string; - values: any[]; - }; - filtered_query?: { - filter: any; - query: any; - }; - bool_query?: { - must?: any[]; - must_not?: any[]; - should?: any[]; - }; - match_all_query?: {}; - }; - sorts?: Array<{ - field: string; - sort_order?: 'asc' | 'desc'; - }>; - start?: number; - count?: number; - select?: string; - }, - ): Promise { - if (!objectType || objectType.trim().length === 0) { - throw new Error('Object type is required and cannot be empty'); - } - - const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_group_search`; - return this.post(endpoint, searchRequest); - } + // ============================================================================= + // Authentication & Token Management - Delegated to AuthClient + // ============================================================================= /** * Get current token expiration for debugging */ getTokenExpiration(): Date | null { - return this.tokenManager.getTokenExpiration(this.config.hostname, this.config.clientId); + return this.authClient.getTokenExpiration(); } /** * Force refresh the token (useful for testing) */ async refreshToken(): Promise { - this.tokenManager.clearToken(this.config.hostname, this.config.clientId); - await this.getAccessToken(); + return this.authClient.refreshToken(); } } diff --git a/src/clients/ocapi/catalog-client.ts b/src/clients/ocapi/catalog-client.ts new file mode 100644 index 0000000..c5bc5b9 --- /dev/null +++ b/src/clients/ocapi/catalog-client.ts @@ -0,0 +1,105 @@ +/** + * OCAPI Catalog Client + * + * This module handles all SFCC catalog related operations including + * products, categories, and product search functionality. + */ + +import { OCAPIConfig } from '../../types/types.js'; +import { OCAPIAuthClient } from '../base/ocapi-auth-client.js'; +import { QueryBuilder } from '../../utils/query-builder.js'; + +/** + * Product parameters interface + */ +interface ProductParams { + ids?: string[]; + expand?: string[]; + inventory_ids?: string[]; + currency?: string; + locale?: string; +} + +/** + * Category parameters interface + */ +interface CategoryParams { + ids?: string[]; + levels?: number; + locale?: string; +} + +/** + * Product search parameters interface + */ +interface ProductSearchParams { + q?: string; + refine?: string[]; + sort?: string; + start?: number; + count?: number; + expand?: string[]; + currency?: string; + locale?: string; +} + +/** + * OCAPI Catalog Client + * Specialized client for catalog operations (products, categories, search) + */ +export class OCAPICatalogClient extends OCAPIAuthClient { + constructor(config: OCAPIConfig) { + const version = config.version ?? 'v21_3'; + const baseUrl = `https://${config.hostname}/s/-/dw/data/${version}`; + + super(config); + // Override the baseUrl for this specialized client + (this as any).baseUrl = baseUrl; + } + + /** + * Get products from the catalog + */ + async getProducts(params?: ProductParams): Promise { + let endpoint = '/products'; + + if (params) { + const queryString = QueryBuilder.fromObject(params); + if (queryString) { + endpoint += `?${queryString}`; + } + } + + return this.get(endpoint); + } + + /** + * Get categories from the catalog + */ + async getCategories(params?: CategoryParams): Promise { + let endpoint = '/categories'; + + if (params) { + const queryString = QueryBuilder.fromObject(params); + if (queryString) { + endpoint += `?${queryString}`; + } + } + + return this.get(endpoint); + } + + /** + * Search products + */ + async searchProducts(params: ProductSearchParams): Promise { + let endpoint = '/product_search'; + + const queryString = QueryBuilder.fromObject(params); + if (queryString) { + endpoint += `?${queryString}`; + } + + return this.get(endpoint); + } +} diff --git a/src/clients/ocapi/site-preferences-client.ts b/src/clients/ocapi/site-preferences-client.ts new file mode 100644 index 0000000..045e774 --- /dev/null +++ b/src/clients/ocapi/site-preferences-client.ts @@ -0,0 +1,108 @@ +/** + * OCAPI Site Preferences Client + * + * This module handles all SFCC site preference related operations including + * searching preferences across different instance types and preference groups. + */ + +import { OCAPIConfig } from '../../types/types.js'; +import { OCAPIAuthClient } from '../base/ocapi-auth-client.js'; +import { QueryBuilder } from '../../utils/query-builder.js'; +import { Validator } from '../../utils/validator.js'; + +/** + * Interface for search request structure + */ +interface SearchRequest { + query?: { + text_query?: { + fields: string[]; + search_phrase: string; + }; + term_query?: { + fields: string[]; + operator: string; + values: any[]; + }; + filtered_query?: { + filter: any; + query: any; + }; + bool_query?: { + must?: any[]; + must_not?: any[]; + should?: any[]; + }; + match_all_query?: {}; + }; + sorts?: Array<{ + field: string; + sort_order?: 'asc' | 'desc'; + }>; + start?: number; + count?: number; + select?: string; +} + +/** + * Site preferences search options + */ +interface SitePreferencesOptions { + maskPasswords?: boolean; + expand?: string; +} + +/** + * OCAPI Site Preferences Client + * Specialized client for site preference operations + */ +export class OCAPISitePreferencesClient extends OCAPIAuthClient { + constructor(config: OCAPIConfig) { + const version = config.version ?? 'v21_3'; + const baseUrl = `https://${config.hostname}/s/-/dw/data/${version}`; + + super(config); + // Override the baseUrl for this specialized client + (this as any).baseUrl = baseUrl; + } + + /** + * Search site preferences across sites in the specified preference group and instance + * + * Allows searching for preferences by id, display_name, description, and value_type + * Supports text queries, term queries, filtered queries, and boolean queries + * + * Searchable fields: + * - id - String + * - display_name - Localized String + * - description - Localized String + * - value_type - one of { + * string, int, double, text, html, date, image, boolean, money, quantity, + * datetime, email, password, set_of_string, set_of_int, set_of_double, enum_of_string, enum_of_int + * } + * + * Note: value_type can only be joined with other attributes using a conjunction (AND) + * Only searchable attributes can be used in sorting + */ + async searchSitePreferences( + groupId: string, + instanceType: string, + searchRequest: SearchRequest, + options?: SitePreferencesOptions, + ): Promise { + Validator.validateRequired({ groupId, instanceType }, ['groupId', 'instanceType']); + const validatedInstanceType = Validator.validateInstanceType(instanceType); + Validator.validateSearchRequest(searchRequest); + + let endpoint = `/site_preferences/preference_groups/${encodeURIComponent(groupId)}/${validatedInstanceType}/preference_search`; + + if (options) { + const queryString = QueryBuilder.fromObject(options); + if (queryString) { + endpoint += `?${queryString}`; + } + } + + return this.post(endpoint, searchRequest); + } +} diff --git a/src/clients/ocapi/system-objects-client.ts b/src/clients/ocapi/system-objects-client.ts new file mode 100644 index 0000000..81fdf30 --- /dev/null +++ b/src/clients/ocapi/system-objects-client.ts @@ -0,0 +1,158 @@ +/** + * OCAPI System Objects Client + * + * This module handles all SFCC system object related operations including + * object definitions, attribute definitions, and attribute groups. + */ + +import { OCAPIConfig } from '../../types/types.js'; +import { OCAPIAuthClient } from '../base/ocapi-auth-client.js'; +import { QueryBuilder } from '../../utils/query-builder.js'; +import { Validator } from '../../utils/validator.js'; + +/** + * Interface for common query parameters + */ +interface BaseQueryParams { + start?: number; + count?: number; + select?: string; +} + +/** + * Interface for search request structure + */ +interface SearchRequest { + query?: { + text_query?: { + fields: string[]; + search_phrase: string; + }; + term_query?: { + fields: string[]; + operator: string; + values: any[]; + }; + filtered_query?: { + filter: any; + query: any; + }; + bool_query?: { + must?: any[]; + must_not?: any[]; + should?: any[]; + }; + match_all_query?: {}; + }; + sorts?: Array<{ + field: string; + sort_order?: 'asc' | 'desc'; + }>; + start?: number; + count?: number; + select?: string; +} + +/** + * OCAPI System Objects Client + * Specialized client for system object operations + */ +export class OCAPISystemObjectsClient extends OCAPIAuthClient { + constructor(config: OCAPIConfig) { + const version = config.version ?? 'v21_3'; + const baseUrl = `https://${config.hostname}/s/-/dw/data/${version}`; + + super(config); + // Override the baseUrl for this specialized client + (this as any).baseUrl = baseUrl; + } + + /** + * Get all system object definitions + */ + async getSystemObjectDefinitions(params?: BaseQueryParams): Promise { + let endpoint = '/system_object_definitions'; + + if (params) { + const queryString = QueryBuilder.fromObject(params); + if (queryString) { + endpoint += `?${queryString}`; + } + } + + return this.get(endpoint); + } + + /** + * Get a specific system object definition by object type + */ + async getSystemObjectDefinition(objectType: string): Promise { + Validator.validateRequired({ objectType }, ['objectType']); + Validator.validateObjectType(objectType); + + const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}`; + return this.get(endpoint); + } + + /** + * Search for system object definitions using complex queries + */ + async searchSystemObjectDefinitions(searchRequest: SearchRequest): Promise { + Validator.validateSearchRequest(searchRequest); + + const endpoint = '/system_object_definition_search'; + return this.post(endpoint, searchRequest); + } + + /** + * Get attribute definitions for a specific system object type + */ + async getSystemObjectAttributeDefinitions( + objectType: string, + options?: BaseQueryParams, + ): Promise { + Validator.validateRequired({ objectType }, ['objectType']); + Validator.validateObjectType(objectType); + + let endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_definitions`; + + if (options) { + const queryString = QueryBuilder.fromObject(options); + if (queryString) { + endpoint += `?${queryString}`; + } + } + + return this.get(endpoint); + } + + /** + * Search attribute definitions for a specific system object type + */ + async searchSystemObjectAttributeDefinitions( + objectType: string, + searchRequest: SearchRequest, + ): Promise { + Validator.validateRequired({ objectType }, ['objectType']); + Validator.validateObjectType(objectType); + Validator.validateSearchRequest(searchRequest); + + const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_definition_search`; + return this.post(endpoint, searchRequest); + } + + /** + * Search attribute groups for a specific system object type + */ + async searchSystemObjectAttributeGroups( + objectType: string, + searchRequest: SearchRequest, + ): Promise { + Validator.validateRequired({ objectType }, ['objectType']); + Validator.validateObjectType(objectType); + Validator.validateSearchRequest(searchRequest); + + const endpoint = `/system_object_definitions/${encodeURIComponent(objectType)}/attribute_group_search`; + return this.post(endpoint, searchRequest); + } +} diff --git a/src/utils/query-builder.ts b/src/utils/query-builder.ts new file mode 100644 index 0000000..fed50d7 --- /dev/null +++ b/src/utils/query-builder.ts @@ -0,0 +1,84 @@ +/** + * Query Builder Utility + * + * This module provides utilities for building query strings and handling URL parameters + * for SFCC API requests with proper encoding and array handling. + */ + +/** + * Query parameter builder for SFCC APIs + */ +export class QueryBuilder { + private params: URLSearchParams; + + constructor() { + this.params = new URLSearchParams(); + } + + /** + * Add a parameter to the query string + */ + add(key: string, value: string | number | boolean): QueryBuilder { + if (value !== undefined && value !== null) { + this.params.append(key, value.toString()); + } + return this; + } + + /** + * Add an array parameter with proper handling for different parameter types + */ + addArray(key: string, values: (string | number)[]): QueryBuilder { + if (!Array.isArray(values) || values.length === 0) { + return this; + } + + if (key === 'refine') { + // Special handling for OCAPI refine parameters (multiple entries) + values.forEach(value => this.params.append(key, value.toString())); + } else { + // Join arrays with comma for most parameters + this.params.append(key, values.join(',')); + } + + return this; + } + + /** + * Add multiple parameters from an object + */ + addFromObject(params: Record): QueryBuilder { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (Array.isArray(value)) { + this.addArray(key, value); + } else { + this.add(key, value); + } + } + }); + return this; + } + + /** + * Build the final query string + */ + build(): string { + return this.params.toString(); + } + + /** + * Reset the builder + */ + reset(): QueryBuilder { + this.params = new URLSearchParams(); + return this; + } + + /** + * Static method to build query string from object + */ + static fromObject(params: Record): string { + return new QueryBuilder().addFromObject(params).build(); + } +} diff --git a/src/utils/validator.ts b/src/utils/validator.ts new file mode 100644 index 0000000..4c61184 --- /dev/null +++ b/src/utils/validator.ts @@ -0,0 +1,162 @@ +/** + * Validation Utilities + * + * This module provides validation functions for SFCC API parameters and inputs. + * It includes common validation patterns used across different API clients. + */ + +/** + * Custom validation error class + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +/** + * Valid instance types for SFCC site preferences + */ +const VALID_INSTANCE_TYPES = ['staging', 'development', 'sandbox', 'production'] as const; +export type InstanceType = typeof VALID_INSTANCE_TYPES[number]; + +/** + * Validation utility class + */ +export class Validator { + /** + * Validate that required fields are present and not empty + */ + static validateRequired(params: Record, requiredFields: string[]): void { + const missingFields: string[] = []; + + for (const field of requiredFields) { + const value = params[field]; + if (value === undefined || value === null || (typeof value === 'string' && value.trim().length === 0)) { + missingFields.push(field); + } + } + + if (missingFields.length > 0) { + throw new ValidationError(`Required fields are missing or empty: ${missingFields.join(', ')}`); + } + } + + /** + * Validate instance type for site preferences + */ + static validateInstanceType(instanceType: string): InstanceType { + if (!VALID_INSTANCE_TYPES.includes(instanceType as InstanceType)) { + throw new ValidationError( + `Invalid instance type '${instanceType}'. Must be one of: ${VALID_INSTANCE_TYPES.join(', ')}`, + ); + } + return instanceType as InstanceType; + } + + /** + * Validate that a string is not empty + */ + static validateNotEmpty(value: string, fieldName: string): void { + if (!value || value.trim().length === 0) { + throw new ValidationError(`${fieldName} cannot be empty`); + } + } + + /** + * Validate that a value is a positive number + */ + static validatePositiveNumber(value: number, fieldName: string): void { + if (value < 0) { + throw new ValidationError(`${fieldName} must be a positive number`); + } + } + + /** + * Validate object type for system objects + */ + static validateObjectType(objectType: string): void { + Validator.validateNotEmpty(objectType, 'objectType'); + + // Basic validation - could be extended with specific object type patterns + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(objectType)) { + throw new ValidationError( + `Invalid object type '${objectType}'. Must start with a letter and contain only letters, numbers, and underscores.`, + ); + } + } + + /** + * Validate search request structure + */ + static validateSearchRequest(searchRequest: any): void { + if (!searchRequest || typeof searchRequest !== 'object') { + throw new ValidationError('Search request must be a valid object'); + } + + // Validate query structure if present + if (searchRequest.query) { + const query = searchRequest.query; + + // Check that at least one query type is specified + const queryTypes = ['text_query', 'term_query', 'filtered_query', 'bool_query', 'match_all_query']; + const hasValidQuery = queryTypes.some(type => query[type]); + + if (!hasValidQuery) { + throw new ValidationError( + `Search query must contain at least one of: ${queryTypes.join(', ')}`, + ); + } + + // Validate text_query structure + if (query.text_query) { + const textQuery = query.text_query; + if (!textQuery.fields || !Array.isArray(textQuery.fields) || textQuery.fields.length === 0) { + throw new ValidationError('text_query.fields must be a non-empty array'); + } + if (!textQuery.search_phrase || typeof textQuery.search_phrase !== 'string') { + throw new ValidationError('text_query.search_phrase must be a non-empty string'); + } + } + + // Validate term_query structure + if (query.term_query) { + const termQuery = query.term_query; + if (!termQuery.fields || !Array.isArray(termQuery.fields) || termQuery.fields.length === 0) { + throw new ValidationError('term_query.fields must be a non-empty array'); + } + if (!termQuery.operator || typeof termQuery.operator !== 'string') { + throw new ValidationError('term_query.operator must be a non-empty string'); + } + if (!termQuery.values || !Array.isArray(termQuery.values) || termQuery.values.length === 0) { + throw new ValidationError('term_query.values must be a non-empty array'); + } + } + } + + // Validate sorts structure if present + if (searchRequest.sorts) { + if (!Array.isArray(searchRequest.sorts)) { + throw new ValidationError('sorts must be an array'); + } + + searchRequest.sorts.forEach((sort: any, index: number) => { + if (!sort.field || typeof sort.field !== 'string') { + throw new ValidationError(`sorts[${index}].field must be a non-empty string`); + } + if (sort.sort_order && !['asc', 'desc'].includes(sort.sort_order)) { + throw new ValidationError(`sorts[${index}].sort_order must be either 'asc' or 'desc'`); + } + }); + } + + // Validate pagination parameters + if (searchRequest.start !== undefined) { + Validator.validatePositiveNumber(searchRequest.start, 'start'); + } + if (searchRequest.count !== undefined) { + Validator.validatePositiveNumber(searchRequest.count, 'count'); + } + } +} diff --git a/tests/__mocks__/src/clients/base/http-client.js b/tests/__mocks__/src/clients/base/http-client.js new file mode 100644 index 0000000..73d75d2 --- /dev/null +++ b/tests/__mocks__/src/clients/base/http-client.js @@ -0,0 +1,43 @@ +/** + * Mock for BaseHttpClient + */ + +export class BaseHttpClient { + constructor(baseUrl) { + this.baseUrl = baseUrl; + this.logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + } + + async makeRequest() { + return { data: 'mocked' }; + } + + async get() { + return { data: 'mocked' }; + } + + async post() { + return { data: 'mocked' }; + } + + async put() { + return { data: 'mocked' }; + } + + async patch() { + return { data: 'mocked' }; + } + + async delete() { + return { data: 'mocked' }; + } + + async handleAuthError() { + // Mock implementation + } +} diff --git a/tests/base-http-client.test.ts b/tests/base-http-client.test.ts new file mode 100644 index 0000000..71a7d71 --- /dev/null +++ b/tests/base-http-client.test.ts @@ -0,0 +1,287 @@ +/** + * Tests for BaseHttpClient + * Tests the foundation HTTP client functionality + */ + +import { BaseHttpClient } from '../src/clients/base/http-client.js'; +import { Logger } from '../src/utils/logger.js'; + +// Mock fetch globally +global.fetch = jest.fn(); + +// Mock Logger +jest.mock('../src/utils/logger.js'); + +// Concrete implementation for testing abstract class +class TestHttpClient extends BaseHttpClient { + private authHeaders: Record = {}; + private shouldFailAuth = false; + + constructor(baseUrl: string = 'https://test-api.example.com') { + super(baseUrl, 'TestHttpClient'); + } + + // Implementation of abstract method + protected async getAuthHeaders(): Promise> { + if (this.shouldFailAuth) { + throw new Error('Auth failed'); + } + return this.authHeaders; + } + + // Test helpers + setAuthHeaders(headers: Record) { + this.authHeaders = headers; + } + + setAuthFailure(shouldFail: boolean) { + this.shouldFailAuth = shouldFail; + } + + // Expose protected methods for testing + public async testMakeRequest(endpoint: string, options?: any): Promise { + return this.makeRequest(endpoint, options); + } + + public async testGet(endpoint: string): Promise { + return this.get(endpoint); + } + + public async testPost(endpoint: string, data?: any): Promise { + return this.post(endpoint, data); + } + + public async testPut(endpoint: string, data?: any): Promise { + return this.put(endpoint, data); + } + + public async testPatch(endpoint: string, data?: any): Promise { + return this.patch(endpoint, data); + } + + public async testDelete(endpoint: string): Promise { + return this.delete(endpoint); + } +} + +describe('BaseHttpClient', () => { + let client: TestHttpClient; + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockFetch = fetch as jest.MockedFunction; + client = new TestHttpClient(); + }); + + describe('constructor', () => { + it('should initialize with base URL and logger context', () => { + const customClient = new TestHttpClient('https://custom.api.com'); + expect(customClient).toBeInstanceOf(BaseHttpClient); + expect(Logger).toHaveBeenCalledWith('TestHttpClient'); + }); + }); + + describe('makeRequest', () => { + it('should make successful GET request with auth headers', async () => { + const mockResponse = { data: 'test-data' }; + client.setAuthHeaders({ 'Authorization': 'Bearer token123' }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await client.testMakeRequest('/test-endpoint'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test-endpoint', + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token123', + }, + }, + ); + expect(result).toEqual(mockResponse); + }); + + it('should handle 401 errors with retry logic', async () => { + const mockResponse = { data: 'success-after-retry' }; + client.setAuthHeaders({ 'Authorization': 'Bearer old-token' }); + + // First call returns 401 + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await client.testMakeRequest('/test-endpoint'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockResponse); + }); + + it('should throw error for non-401 HTTP errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Server error details', + } as Response); + + await expect(client.testMakeRequest('/test-endpoint')).rejects.toThrow( + 'Request failed: 500 Internal Server Error - Server error details', + ); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect(client.testMakeRequest('/test-endpoint')).rejects.toThrow( + 'Network error', + ); + }); + + it('should handle auth header failures', async () => { + client.setAuthFailure(true); + + await expect(client.testMakeRequest('/test-endpoint')).rejects.toThrow( + 'Auth failed', + ); + }); + + it('should merge custom headers with auth headers', async () => { + const mockResponse = { data: 'test' }; + client.setAuthHeaders({ 'Authorization': 'Bearer token' }); + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + await client.testMakeRequest('/test', { + headers: { 'Custom-Header': 'custom-value' }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test', + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer token', + 'Custom-Header': 'custom-value', + }, + }, + ); + }); + }); + + describe('HTTP method wrappers', () => { + beforeEach(() => { + client.setAuthHeaders({ 'Authorization': 'Bearer token' }); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + } as Response); + }); + + it('should make GET request', async () => { + await client.testGet('/test'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test', + expect.objectContaining({ method: 'GET' }), // GET method is explicitly set + ); + }); + + it('should make POST request with data', async () => { + const postData = { name: 'test' }; + await client.testPost('/test', postData); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(postData), + }), + ); + }); + + it('should make POST request without data', async () => { + await client.testPost('/test'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test', + expect.objectContaining({ + method: 'POST', + }), + ); + expect(mockFetch.mock.calls[0][1]).not.toHaveProperty('body'); + }); + + it('should make PUT request with data', async () => { + const putData = { id: 1, name: 'updated' }; + await client.testPut('/test/1', putData); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test/1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(putData), + }), + ); + }); + + it('should make PATCH request with data', async () => { + const patchData = { name: 'patched' }; + await client.testPatch('/test/1', patchData); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test/1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(patchData), + }), + ); + }); + + it('should make DELETE request', async () => { + await client.testDelete('/test/1'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://test-api.example.com/test/1', + expect.objectContaining({ + method: 'DELETE', + }), + ); + }); + }); + + describe('error handling during retry', () => { + it('should throw error if retry also fails', async () => { + client.setAuthHeaders({ 'Authorization': 'Bearer token' }); + + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response) + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Retry failed', + } as Response); + + await expect(client.testMakeRequest('/test')).rejects.toThrow( + 'Request failed after retry: 500 Internal Server Error - Retry failed', + ); + }); + }); +}); diff --git a/tests/catalog-client.test.ts b/tests/catalog-client.test.ts new file mode 100644 index 0000000..b1ce661 --- /dev/null +++ b/tests/catalog-client.test.ts @@ -0,0 +1,299 @@ +/** + * Tests for OCAPICatalogClient + * Tests catalog operations (products, categories, search) + */ + +import { OCAPICatalogClient } from '../src/clients/ocapi/catalog-client.js'; +import { OCAPIConfig } from '../src/types/types.js'; +import { QueryBuilder } from '../src/utils/query-builder.js'; + +// Mock dependencies +jest.mock('../src/clients/base/ocapi-auth-client.js'); +jest.mock('../src/utils/query-builder.js'); + +describe('OCAPICatalogClient', () => { + let client: OCAPICatalogClient; + let mockQueryBuilderFromObject: jest.MockedFunction; + + const mockConfig: OCAPIConfig = { + hostname: 'test-instance.demandware.net', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + version: 'v21_3', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock QueryBuilder + mockQueryBuilderFromObject = QueryBuilder.fromObject as jest.MockedFunction; + + client = new OCAPICatalogClient(mockConfig); + + // Mock the inherited methods by adding them as properties - avoid protected access + (client as any).get = jest.fn().mockResolvedValue({ data: 'mocked' }); + }); + + describe('constructor', () => { + it('should initialize with correct base URL', () => { + expect(client).toBeInstanceOf(OCAPICatalogClient); + }); + + it('should use default version when not provided', () => { + const configWithoutVersion = { + hostname: 'test.demandware.net', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + const clientWithDefaults = new OCAPICatalogClient(configWithoutVersion); + expect(clientWithDefaults).toBeInstanceOf(OCAPICatalogClient); + }); + }); + + describe('getProducts', () => { + it('should make GET request to products endpoint without parameters', async () => { + await client.getProducts(); + + expect((client as any).get).toHaveBeenCalledWith('/products'); + }); + + it('should include query parameters when provided', async () => { + const params = { + ids: ['product1', 'product2'], + expand: ['variations', 'images'], + inventory_ids: ['inventory1'], + currency: 'USD', + locale: 'en_US', + }; + mockQueryBuilderFromObject.mockReturnValue('ids=product1%2Cproduct2&expand=variations%2Cimages&inventory_ids=inventory1¤cy=USD&locale=en_US'); + + await client.getProducts(params); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(params); + expect((client as any).get).toHaveBeenCalledWith('/products?ids=product1%2Cproduct2&expand=variations%2Cimages&inventory_ids=inventory1¤cy=USD&locale=en_US'); + }); + + it('should handle single product ID', async () => { + const params = { ids: ['single-product'] }; + mockQueryBuilderFromObject.mockReturnValue('ids=single-product'); + + await client.getProducts(params); + + expect((client as any).get).toHaveBeenCalledWith('/products?ids=single-product'); + }); + + it('should handle expand parameters', async () => { + const params = { + ids: ['product1'], + expand: ['variations', 'images', 'availability', 'prices'], + }; + mockQueryBuilderFromObject.mockReturnValue('ids=product1&expand=variations%2Cimages%2Cavailability%2Cprices'); + + await client.getProducts(params); + + expect((client as any).get).toHaveBeenCalledWith('/products?ids=product1&expand=variations%2Cimages%2Cavailability%2Cprices'); + }); + + it('should handle inventory and localization parameters', async () => { + const params = { + ids: ['product1'], + inventory_ids: ['inventory1', 'inventory2'], + currency: 'EUR', + locale: 'de_DE', + }; + mockQueryBuilderFromObject.mockReturnValue('ids=product1&inventory_ids=inventory1%2Cinventory2¤cy=EUR&locale=de_DE'); + + await client.getProducts(params); + + expect((client as any).get).toHaveBeenCalledWith('/products?ids=product1&inventory_ids=inventory1%2Cinventory2¤cy=EUR&locale=de_DE'); + }); + + it('should not include query string when no parameters provided', async () => { + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.getProducts({}); + + expect((client as any).get).toHaveBeenCalledWith('/products'); + }); + }); + + describe('getCategories', () => { + it('should make GET request to categories endpoint without parameters', async () => { + await client.getCategories(); + + expect((client as any).get).toHaveBeenCalledWith('/categories'); + }); + + it('should include query parameters when provided', async () => { + const params = { + ids: ['category1', 'category2'], + levels: 2, + locale: 'en_US', + }; + mockQueryBuilderFromObject.mockReturnValue('ids=category1%2Ccategory2&levels=2&locale=en_US'); + + await client.getCategories(params); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(params); + expect((client as any).get).toHaveBeenCalledWith('/categories?ids=category1%2Ccategory2&levels=2&locale=en_US'); + }); + + it('should handle single category ID', async () => { + const params = { ids: ['root-category'] }; + mockQueryBuilderFromObject.mockReturnValue('ids=root-category'); + + await client.getCategories(params); + + expect((client as any).get).toHaveBeenCalledWith('/categories?ids=root-category'); + }); + + it('should handle levels parameter for hierarchy depth', async () => { + const params = { + ids: ['root'], + levels: 3, + }; + mockQueryBuilderFromObject.mockReturnValue('ids=root&levels=3'); + + await client.getCategories(params); + + expect((client as any).get).toHaveBeenCalledWith('/categories?ids=root&levels=3'); + }); + + it('should handle locale parameter', async () => { + const params = { + ids: ['category1'], + locale: 'fr_FR', + }; + mockQueryBuilderFromObject.mockReturnValue('ids=category1&locale=fr_FR'); + + await client.getCategories(params); + + expect((client as any).get).toHaveBeenCalledWith('/categories?ids=category1&locale=fr_FR'); + }); + + it('should not include query string when no parameters provided', async () => { + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.getCategories({}); + + expect((client as any).get).toHaveBeenCalledWith('/categories'); + }); + }); + + describe('searchProducts', () => { + it('should make GET request to product_search endpoint with basic search', async () => { + const params = { q: 'shirt' }; + mockQueryBuilderFromObject.mockReturnValue('q=shirt'); + + await client.searchProducts(params); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(params); + expect((client as any).get).toHaveBeenCalledWith('/product_search?q=shirt'); + }); + + it('should handle complex search parameters', async () => { + const params = { + q: 'mens shoes', + refine: ['category=footwear', 'brand=nike', 'color=black'], + sort: 'price-asc', + start: 0, + count: 25, + expand: ['images', 'variations', 'prices'], + currency: 'USD', + locale: 'en_US', + }; + mockQueryBuilderFromObject.mockReturnValue( + 'q=mens+shoes&refine=category%3Dfootwear&refine=brand%3Dnike&refine=color%3Dblack&sort=price-asc&start=0&count=25&expand=images%2Cvariations%2Cprices¤cy=USD&locale=en_US', + ); + + await client.searchProducts(params); + + expect((client as any).get).toHaveBeenCalledWith( + '/product_search?q=mens+shoes&refine=category%3Dfootwear&refine=brand%3Dnike&refine=color%3Dblack&sort=price-asc&start=0&count=25&expand=images%2Cvariations%2Cprices¤cy=USD&locale=en_US', + ); + }); + + it('should handle refine parameters for filtering', async () => { + const params = { + q: 'dress', + refine: ['category=womens-clothing', 'size=M', 'price=(100..200)'], + }; + mockQueryBuilderFromObject.mockReturnValue('q=dress&refine=category%3Dwomens-clothing&refine=size%3DM&refine=price%3D%28100..200%29'); + + await client.searchProducts(params); + + expect((client as any).get).toHaveBeenCalledWith('/product_search?q=dress&refine=category%3Dwomens-clothing&refine=size%3DM&refine=price%3D%28100..200%29'); + }); + + it('should always include query string for product search', async () => { + const params = {}; + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.searchProducts(params); + + expect((client as any).get).toHaveBeenCalledWith('/product_search'); + }); + }); + + describe('error handling', () => { + it('should propagate HTTP errors from base client for products', async () => { + const httpError = new Error('HTTP request failed'); + (client as any).get = jest.fn().mockRejectedValue(httpError); + + await expect(client.getProducts()).rejects.toThrow(httpError); + }); + + it('should propagate HTTP errors from base client for categories', async () => { + const httpError = new Error('HTTP request failed'); + (client as any).get = jest.fn().mockRejectedValue(httpError); + + await expect(client.getCategories()).rejects.toThrow(httpError); + }); + + it('should propagate HTTP errors from base client for product search', async () => { + const httpError = new Error('HTTP request failed'); + (client as any).get = jest.fn().mockRejectedValue(httpError); + + await expect(client.searchProducts({ q: 'test' })).rejects.toThrow(httpError); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete product retrieval workflow', async () => { + const params = { + ids: ['product1', 'product2', 'product3'], + expand: ['variations', 'images', 'availability', 'prices', 'promotions'], + inventory_ids: ['inventory_us', 'inventory_eu'], + currency: 'USD', + locale: 'en_US', + }; + mockQueryBuilderFromObject.mockReturnValue( + 'ids=product1%2Cproduct2%2Cproduct3&expand=variations%2Cimages%2Cavailability%2Cprices%2Cpromotions&inventory_ids=inventory_us%2Cinventory_eu¤cy=USD&locale=en_US', + ); + + await client.getProducts(params); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(params); + expect((client as any).get).toHaveBeenCalledWith( + '/products?ids=product1%2Cproduct2%2Cproduct3&expand=variations%2Cimages%2Cavailability%2Cprices%2Cpromotions&inventory_ids=inventory_us%2Cinventory_eu¤cy=USD&locale=en_US', + ); + }); + + it('should handle minimal requests correctly', async () => { + // Minimal product request + mockQueryBuilderFromObject.mockReturnValue(''); + await client.getProducts(); + expect((client as any).get).toHaveBeenCalledWith('/products'); + + // Minimal category request + await client.getCategories(); + expect((client as any).get).toHaveBeenCalledWith('/categories'); + + // Minimal search request + const minimalSearch = {}; + await client.searchProducts(minimalSearch); + expect((client as any).get).toHaveBeenCalledWith('/product_search'); + }); + }); +}); diff --git a/tests/ocapi-auth-client.test.ts b/tests/ocapi-auth-client.test.ts new file mode 100644 index 0000000..2f44077 --- /dev/null +++ b/tests/ocapi-auth-client.test.ts @@ -0,0 +1,252 @@ +/** + * Tests for OCAPIAuthClient + * Tests OAuth authentication functionality for OCAPI + */ + +import { OCAPIAuthClient } from '../src/clients/base/ocapi-auth-client.js'; +import { TokenManager } from '../src/auth/oauth-token.js'; +import { OCAPIConfig, OAuthTokenResponse } from '../src/types/types.js'; + +// Mock fetch globally +global.fetch = jest.fn(); + +// Mock TokenManager +jest.mock('../src/auth/oauth-token.js'); + +// Mock Logger +jest.mock('../src/utils/logger.js'); + +// Mock BaseHttpClient +jest.mock('../src/clients/base/http-client.js'); + +describe('OCAPIAuthClient', () => { + let client: OCAPIAuthClient; + let mockTokenManager: jest.Mocked; + let mockFetch: jest.MockedFunction; + + const mockConfig: OCAPIConfig = { + hostname: 'test-instance.demandware.net', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + version: 'v21_3', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockFetch = fetch as jest.MockedFunction; + + // Setup TokenManager mock + mockTokenManager = { + getValidToken: jest.fn(), + storeToken: jest.fn(), + clearToken: jest.fn(), + getTokenExpiration: jest.fn(), + isTokenValid: jest.fn(), + clearAllTokens: jest.fn(), + } as any; + + (TokenManager.getInstance as jest.Mock).mockReturnValue(mockTokenManager); + + client = new OCAPIAuthClient(mockConfig); + + // Manually set up the logger mock since BaseHttpClient mock isn't working as expected + (client as any).logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + }); + + describe('constructor', () => { + it('should initialize with config', () => { + expect(client).toBeInstanceOf(OCAPIAuthClient); + expect(TokenManager.getInstance).toHaveBeenCalled(); + }); + }); + + describe('getAuthHeaders', () => { + it('should return Bearer token in auth headers', async () => { + const mockToken = 'mock-access-token'; + mockTokenManager.getValidToken.mockReturnValue(mockToken); + + const headers = await (client as any).getAuthHeaders(); + + expect(headers).toEqual({ + 'Authorization': 'Bearer mock-access-token', + }); + expect(mockTokenManager.getValidToken).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + ); + }); + + it('should request new token when no valid token exists', async () => { + const newToken = 'new-access-token'; + const tokenResponse: OAuthTokenResponse = { + access_token: newToken, + token_type: 'bearer', + expires_in: 3600, + }; + + mockTokenManager.getValidToken.mockReturnValue(null); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => tokenResponse, + } as Response); + + const headers = await (client as any).getAuthHeaders(); + + expect(headers).toEqual({ + 'Authorization': 'Bearer new-access-token', + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://account.demandware.com/dwsso/oauth2/access_token', + { + method: 'POST', + headers: { + 'Authorization': `Basic ${Buffer.from(`${mockConfig.clientId}:${mockConfig.clientSecret}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'grant_type=client_credentials', + }, + ); + expect(mockTokenManager.storeToken).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + tokenResponse, + ); + }); + + it('should handle OAuth request failure', async () => { + mockTokenManager.getValidToken.mockReturnValue(null); + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Invalid credentials', + } as Response); + + await expect((client as any).getAuthHeaders()).rejects.toThrow( + 'Failed to get access token: Error: OAuth authentication failed: 401 Unauthorized - Invalid credentials', + ); + }); + + it('should handle network errors during token request', async () => { + mockTokenManager.getValidToken.mockReturnValue(null); + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect((client as any).getAuthHeaders()).rejects.toThrow( + 'Failed to get access token: Error: Network error', + ); + }); + }); + + describe('handleAuthError', () => { + it('should clear token when handling auth error', async () => { + await (client as any).handleAuthError(); + + expect(mockTokenManager.clearToken).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + ); + }); + }); + + describe('getTokenExpiration', () => { + it('should return token expiration from TokenManager', () => { + const mockExpiration = new Date('2025-12-31T23:59:59Z'); + mockTokenManager.getTokenExpiration.mockReturnValue(mockExpiration); + + const result = client.getTokenExpiration(); + + expect(result).toBe(mockExpiration); + expect(mockTokenManager.getTokenExpiration).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + ); + }); + + it('should return null when no token exists', () => { + mockTokenManager.getTokenExpiration.mockReturnValue(null); + + const result = client.getTokenExpiration(); + + expect(result).toBeNull(); + }); + }); + + describe('refreshToken', () => { + it('should clear token and request new one', async () => { + const newToken = 'refreshed-token'; + const tokenResponse: OAuthTokenResponse = { + access_token: newToken, + token_type: 'bearer', + expires_in: 3600, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: async () => tokenResponse, + } as Response); + + await client.refreshToken(); + + expect(mockTokenManager.clearToken).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + ); + expect(mockFetch).toHaveBeenCalled(); + }); + + it('should handle refresh token failure', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + text: async () => 'Invalid refresh', + } as Response); + + await expect(client.refreshToken()).rejects.toThrow( + 'Failed to get access token', + ); + }); + }); + + describe('token management flow', () => { + it('should use existing token when valid', async () => { + const existingToken = 'existing-valid-token'; + mockTokenManager.getValidToken.mockReturnValue(existingToken); + + const headers = await (client as any).getAuthHeaders(); + + expect(headers).toEqual({ + 'Authorization': 'Bearer existing-valid-token', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should store new token after successful request', async () => { + const tokenResponse: OAuthTokenResponse = { + access_token: 'new-token', + token_type: 'bearer', + expires_in: 7200, + }; + + mockTokenManager.getValidToken.mockReturnValue(null); + mockFetch.mockResolvedValue({ + ok: true, + json: async () => tokenResponse, + } as Response); + + await (client as any).getAuthHeaders(); + + expect(mockTokenManager.storeToken).toHaveBeenCalledWith( + mockConfig.hostname, + mockConfig.clientId, + tokenResponse, + ); + }); + }); +}); diff --git a/tests/ocapi-client.test.ts b/tests/ocapi-client.test.ts index d191063..62dcf0f 100644 --- a/tests/ocapi-client.test.ts +++ b/tests/ocapi-client.test.ts @@ -1,12 +1,23 @@ -import { OCAPIClient } from '../src/clients/ocapi-client'; -import { TokenManager } from '../src/auth/oauth-token'; -import { OCAPIConfig, OAuthTokenResponse } from '../src/types/types'; +/** + * Tests for the refactored OCAPIClient + * Tests the facade pattern that orchestrates specialized client modules + */ + +import { OCAPIClient } from '../src/clients/ocapi-client.js'; +import { TokenManager } from '../src/auth/oauth-token.js'; +import { OCAPIConfig } from '../src/types/types.js'; // Mock fetch globally global.fetch = jest.fn(); // Mock TokenManager -jest.mock('../src/auth/oauth-token'); +jest.mock('../src/auth/oauth-token.js'); + +// Mock the specialized clients +jest.mock('../src/clients/ocapi/system-objects-client.js'); +jest.mock('../src/clients/ocapi/site-preferences-client.js'); +jest.mock('../src/clients/ocapi/catalog-client.js'); +jest.mock('../src/clients/base/ocapi-auth-client.js'); describe('OCAPIClient', () => { let client: OCAPIClient; @@ -28,6 +39,7 @@ describe('OCAPIClient', () => { clearToken: jest.fn(), getTokenExpiration: jest.fn(), isTokenValid: jest.fn(), + clearAllTokens: jest.fn(), } as any; (TokenManager.getInstance as jest.Mock).mockReturnValue(mockTokenManager); @@ -39,7 +51,10 @@ describe('OCAPIClient', () => { describe('constructor', () => { it('should initialize with provided config', () => { expect(client).toBeInstanceOf(OCAPIClient); - expect(TokenManager.getInstance).toHaveBeenCalled(); + // Note: TokenManager.getInstance is called by the auth client, not directly by OCAPIClient + expect(client.systemObjects).toBeDefined(); + expect(client.sitePreferences).toBeDefined(); + expect(client.catalog).toBeDefined(); }); it('should use default version when not provided', () => { @@ -53,897 +68,225 @@ describe('OCAPIClient', () => { expect(clientWithDefaults).toBeInstanceOf(OCAPIClient); }); - it('should construct correct base URL', () => { - // We can't directly test the private baseUrl, but we can verify it through API calls - expect(client).toBeInstanceOf(OCAPIClient); + it('should initialize all specialized client modules', () => { + expect(client.systemObjects).toBeDefined(); + expect(client.sitePreferences).toBeDefined(); + expect(client.catalog).toBeDefined(); }); }); - describe('OAuth token management', () => { - it('should use existing valid token when available', async () => { - const existingToken = 'existing-valid-token'; - mockTokenManager.getValidToken.mockReturnValue(existingToken); - - // Mock successful API response - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => ({ data: 'test' }), - }); - - await client.get('/test-endpoint'); - - expect(mockTokenManager.getValidToken).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining('/test-endpoint'), - expect.objectContaining({ - headers: expect.objectContaining({ - 'Authorization': `Bearer ${existingToken}`, - }), - }), - ); - }); + describe('System Objects API delegation', () => { + it('should delegate getSystemObjectDefinitions to SystemObjectsClient', async () => { + const mockResponse = { data: 'system-objects' }; + jest.spyOn(client.systemObjects, 'getSystemObjectDefinitions').mockResolvedValue(mockResponse); - it('should obtain new token when no valid token exists', async () => { - const newToken = 'new-access-token'; - const tokenResponse: OAuthTokenResponse = { - access_token: newToken, - token_type: 'bearer', - expires_in: 3600, - }; + const result = await client.getSystemObjectDefinitions(); - mockTokenManager.getValidToken.mockReturnValue(null); - - // Mock OAuth token request - (fetch as jest.Mock) - .mockResolvedValueOnce({ - ok: true, - json: async () => tokenResponse, - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: 'test' }), - }); - - await client.get('/test-endpoint'); - - // Verify OAuth request - expect(fetch).toHaveBeenNthCalledWith(1, - 'https://account.demandware.com/dwsso/oauth2/access_token', - { - method: 'POST', - headers: { - 'Authorization': `Basic ${Buffer.from(`${mockConfig.clientId}:${mockConfig.clientSecret}`).toString('base64')}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: 'grant_type=client_credentials', - }, - ); - - // Verify token was stored - expect(mockTokenManager.storeToken).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - tokenResponse, - ); - - // Verify API request used new token - expect(fetch).toHaveBeenNthCalledWith(2, - expect.stringContaining('/test-endpoint'), - expect.objectContaining({ - headers: expect.objectContaining({ - 'Authorization': `Bearer ${newToken}`, - }), - }), - ); + expect(client.systemObjects.getSystemObjectDefinitions).toHaveBeenCalledWith(undefined); + expect(result).toBe(mockResponse); }); - it('should handle OAuth authentication failure', async () => { - mockTokenManager.getValidToken.mockReturnValue(null); + it('should delegate getSystemObjectDefinition with objectType to SystemObjectsClient', async () => { + const mockResponse = { data: 'product-definition' }; + const objectType = 'Product'; + jest.spyOn(client.systemObjects, 'getSystemObjectDefinition').mockResolvedValue(mockResponse); - (fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request', - text: async () => 'Invalid client credentials', - }); + const result = await client.getSystemObjectDefinition(objectType); - await expect(client.get('/test-endpoint')).rejects.toThrow( - 'OAuth authentication failed: 400 Bad Request - Invalid client credentials', - ); + expect(client.systemObjects.getSystemObjectDefinition).toHaveBeenCalledWith(objectType); + expect(result).toBe(mockResponse); }); - it('should retry with new token on 401 unauthorized', async () => { - const oldToken = 'expired-token'; - const newToken = 'fresh-token'; - const tokenResponse: OAuthTokenResponse = { - access_token: newToken, - token_type: 'bearer', - expires_in: 3600, - }; + it('should delegate searchSystemObjectDefinitions to SystemObjectsClient', async () => { + const mockResponse = { data: 'search-results' }; + const searchRequest = { query: { match_all_query: {} } }; + jest.spyOn(client.systemObjects, 'searchSystemObjectDefinitions').mockResolvedValue(mockResponse); - mockTokenManager.getValidToken - .mockReturnValueOnce(oldToken) // First call returns expired token - .mockReturnValueOnce(null); // Second call after clearToken returns null - - (fetch as jest.Mock) - .mockResolvedValueOnce({ // First API call with old token fails - ok: false, - status: 401, - statusText: 'Unauthorized', - }) - .mockResolvedValueOnce({ // OAuth token refresh succeeds - ok: true, - json: async () => tokenResponse, - }) - .mockResolvedValueOnce({ // Retry API call succeeds - ok: true, - json: async () => ({ data: 'success' }), - }); - - const result = await client.get('/test-endpoint'); - - expect(mockTokenManager.clearToken).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - ); - expect(result).toEqual({ data: 'success' }); - }); + const result = await client.searchSystemObjectDefinitions(searchRequest); - it('should throw error if retry also fails with 401', async () => { - const oldToken = 'expired-token'; - const newToken = 'new-token'; - - mockTokenManager.getValidToken - .mockReturnValueOnce(oldToken) // First call returns expired token - .mockReturnValueOnce(null); // Second call after clearToken returns null - - (fetch as jest.Mock) - .mockResolvedValueOnce({ // First call fails with 401 - ok: false, - status: 401, - statusText: 'Unauthorized', - }) - .mockResolvedValueOnce({ // OAuth token refresh succeeds - ok: true, - json: async () => ({ access_token: newToken, token_type: 'bearer', expires_in: 3600 }), - }) - .mockResolvedValueOnce({ // Retry also fails with 401 - ok: false, - status: 401, - statusText: 'Unauthorized', - text: async () => 'Still unauthorized', - }); - - await expect(client.get('/test-endpoint')).rejects.toThrow( - 'OCAPI request failed: 401 Unauthorized - Still unauthorized', - ); + expect(client.systemObjects.searchSystemObjectDefinitions).toHaveBeenCalledWith(searchRequest); + expect(result).toBe(mockResponse); }); - }); - describe('HTTP methods', () => { - beforeEach(() => { - mockTokenManager.getValidToken.mockReturnValue('valid-token'); - }); + it('should delegate getSystemObjectAttributeDefinitions to SystemObjectsClient', async () => { + const mockResponse = { data: 'attribute-definitions' }; + const objectType = 'Product'; + const options = { count: 10 }; + jest.spyOn(client.systemObjects, 'getSystemObjectAttributeDefinitions').mockResolvedValue(mockResponse); - describe('get()', () => { - it('should make GET request with correct headers', async () => { - const mockResponse = { id: '1', name: 'test' }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.get('/products/test-id'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products/test-id', - { - method: 'GET', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - }, - ); - expect(result).toEqual(mockResponse); - }); - }); + const result = await client.getSystemObjectAttributeDefinitions(objectType, options); - describe('post()', () => { - it('should make POST request with data', async () => { - const postData = { name: 'New Product' }; - const mockResponse = { id: '123', ...postData }; - - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.post('/products', postData); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products', - { - method: 'POST', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(postData), - }, - ); - expect(result).toEqual(mockResponse); - }); - - it('should make POST request without data', async () => { - const mockResponse = { success: true }; - - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.post('/actions/refresh'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/actions/refresh', - { - method: 'POST', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - }, - ); - expect(result).toEqual(mockResponse); - }); + expect(client.systemObjects.getSystemObjectAttributeDefinitions).toHaveBeenCalledWith(objectType, options); + expect(result).toBe(mockResponse); }); - describe('put()', () => { - it('should make PUT request with data', async () => { - const putData = { name: 'Updated Product' }; - const mockResponse = { id: '123', ...putData }; - - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.put('/products/123', putData); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products/123', - { - method: 'PUT', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(putData), - }, - ); - expect(result).toEqual(mockResponse); - }); - }); + it('should delegate searchSystemObjectAttributeDefinitions to SystemObjectsClient', async () => { + const mockResponse = { data: 'attribute-search-results' }; + const objectType = 'Product'; + const searchRequest = { query: { text_query: { fields: ['id'], search_phrase: 'custom' } } }; + jest.spyOn(client.systemObjects, 'searchSystemObjectAttributeDefinitions').mockResolvedValue(mockResponse); - describe('patch()', () => { - it('should make PATCH request with data', async () => { - const patchData = { price: 29.99 }; - const mockResponse = { id: '123', price: 29.99 }; - - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.patch('/products/123', patchData); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products/123', - { - method: 'PATCH', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(patchData), - }, - ); - expect(result).toEqual(mockResponse); - }); - }); + const result = await client.searchSystemObjectAttributeDefinitions(objectType, searchRequest); - describe('delete()', () => { - it('should make DELETE request', async () => { - const mockResponse = { success: true }; - - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.delete('/products/123'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products/123', - { - method: 'DELETE', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - }, - ); - expect(result).toEqual(mockResponse); - }); + expect(client.systemObjects.searchSystemObjectAttributeDefinitions) + .toHaveBeenCalledWith(objectType, searchRequest); + expect(result).toBe(mockResponse); }); - it('should handle non-401 HTTP errors', async () => { - (fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 404, - statusText: 'Not Found', - text: async () => 'Resource not found', - }); - - await expect(client.get('/products/nonexistent')).rejects.toThrow( - 'OCAPI request failed: 404 Not Found - Resource not found', - ); - }); - }); + it('should delegate searchSystemObjectAttributeGroups to SystemObjectsClient', async () => { + const mockResponse = { data: 'attribute-groups' }; + const objectType = 'SitePreferences'; + const searchRequest = { query: { match_all_query: {} } }; + jest.spyOn(client.systemObjects, 'searchSystemObjectAttributeGroups').mockResolvedValue(mockResponse); - describe('Product API methods', () => { - beforeEach(() => { - mockTokenManager.getValidToken.mockReturnValue('valid-token'); - }); + const result = await client.searchSystemObjectAttributeGroups(objectType, searchRequest); - describe('getProducts()', () => { - it('should get products without parameters', async () => { - const mockResponse = { data: [{ id: 'prod1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getProducts(); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should get products with all parameters', async () => { - const mockResponse = { data: [{ id: 'prod1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const params = { - ids: ['prod1', 'prod2'], - expand: ['prices', 'images'], - inventory_ids: ['inv1'], - currency: 'USD', - locale: 'en_US', - }; - - const result = await client.getProducts(params); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products?ids=prod1%2Cprod2&expand=prices%2Cimages&inventory_ids=inv1¤cy=USD&locale=en_US', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should get products with partial parameters', async () => { - const mockResponse = { data: [{ id: 'prod1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getProducts({ ids: ['prod1'], currency: 'EUR' }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/products?ids=prod1¤cy=EUR', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); + expect(client.systemObjects.searchSystemObjectAttributeGroups).toHaveBeenCalledWith(objectType, searchRequest); + expect(result).toBe(mockResponse); }); + }); - describe('getCategories()', () => { - it('should get categories without parameters', async () => { - const mockResponse = { data: [{ id: 'cat1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getCategories(); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/categories', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should get categories with parameters', async () => { - const mockResponse = { data: [{ id: 'cat1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const params = { - ids: ['cat1', 'cat2'], - levels: 2, - locale: 'en_US', - }; - - const result = await client.getCategories(params); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/categories?ids=cat1%2Ccat2&levels=2&locale=en_US', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should handle levels parameter as 0', async () => { - const mockResponse = { data: [{ id: 'cat1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.getCategories({ levels: 0 }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/categories?levels=0', - expect.any(Object), - ); - }); - }); + describe('Site Preferences API delegation', () => { + it('should delegate searchSitePreferences to SitePreferencesClient', async () => { + const mockResponse = { data: 'site-preferences' }; + const groupId = 'SiteGeneral'; + const instanceType = 'sandbox'; + const searchRequest = { query: { match_all_query: {} } }; + const options = { maskPasswords: true }; + jest.spyOn(client.sitePreferences, 'searchSitePreferences').mockResolvedValue(mockResponse); + + const result = await client.searchSitePreferences(groupId, instanceType, searchRequest, options); - describe('searchProducts()', () => { - it('should search products with query', async () => { - const mockResponse = { hits: [{ id: 'prod1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.searchProducts({ q: 'shoes' }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/product_search?q=shoes', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should search products with all parameters', async () => { - const mockResponse = { hits: [{ id: 'prod1' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const params = { - q: 'shoes', - refine: ['brand=Nike', 'color=red'], - sort: 'price-low-to-high', - start: 0, - count: 20, - expand: ['prices'], - currency: 'USD', - locale: 'en_US', - }; - - const result = await client.searchProducts(params); - - const expectedUrl = 'https://test-instance.demandware.net/s/-/dw/data/v21_3/product_search?q=shoes&refine=brand%3DNike&refine=color%3Dred&sort=price-low-to-high&start=0&count=20&expand=prices¤cy=USD&locale=en_US'; - expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object)); - expect(result).toEqual(mockResponse); - }); - - it('should handle start and count as 0', async () => { - const mockResponse = { hits: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.searchProducts({ start: 0, count: 0 }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/product_search?start=0&count=0', - expect.any(Object), - ); - }); + expect(client.sitePreferences.searchSitePreferences) + .toHaveBeenCalledWith(groupId, instanceType, searchRequest, options); + expect(result).toBe(mockResponse); }); }); - describe('System Object API methods', () => { - beforeEach(() => { - mockTokenManager.getValidToken.mockReturnValue('valid-token'); - }); + describe('Catalog API delegation', () => { + it('should delegate getProducts to CatalogClient', async () => { + const mockResponse = { data: 'products' }; + const params = { ids: ['product1', 'product2'], expand: ['variations'] }; + jest.spyOn(client.catalog, 'getProducts').mockResolvedValue(mockResponse); - describe('getSystemObjectDefinitions()', () => { - it('should get system object definitions without parameters', async () => { - const mockResponse = { data: [{ object_type: 'Product' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getSystemObjectDefinitions(); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should get system object definitions with parameters', async () => { - const mockResponse = { data: [{ object_type: 'Product' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const params = { - start: 10, - count: 50, - select: '(**)', - }; - - const result = await client.getSystemObjectDefinitions(params); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions?start=10&count=50&select=%28**%29', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should handle start and count as 0', async () => { - const mockResponse = { data: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.getSystemObjectDefinitions({ start: 0, count: 0 }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions?start=0&count=0', - expect.any(Object), - ); - }); - }); + const result = await client.getProducts(params); - describe('getSystemObjectDefinition()', () => { - it('should get specific system object definition', async () => { - const mockResponse = { object_type: 'Product', attributes: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getSystemObjectDefinition('Product'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Product', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should handle URL encoding for object type', async () => { - const mockResponse = { object_type: 'Custom Object' }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.getSystemObjectDefinition('Custom Object'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Custom%20Object', - expect.any(Object), - ); - }); - - it('should throw error for empty object type', async () => { - await expect(client.getSystemObjectDefinition('')).rejects.toThrow( - 'Object type is required and cannot be empty', - ); - - await expect(client.getSystemObjectDefinition(' ')).rejects.toThrow( - 'Object type is required and cannot be empty', - ); - }); + expect(client.catalog.getProducts).toHaveBeenCalledWith(params); + expect(result).toBe(mockResponse); }); - describe('searchSystemObjectDefinitions()', () => { - it('should search system object definitions with text query', async () => { - const mockResponse = { hits: [{ object_type: 'Product' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const searchRequest = { - query: { - text_query: { - fields: ['object_type', 'display_name'], - search_phrase: 'product', - }, - }, - start: 0, - count: 10, - }; - - const result = await client.searchSystemObjectDefinitions(searchRequest); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definition_search', - { - method: 'POST', - headers: { - 'Authorization': 'Bearer valid-token', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(searchRequest), - }, - ); - expect(result).toEqual(mockResponse); - }); - - it('should search with complex query and sorts', async () => { - const mockResponse = { hits: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const searchRequest = { - query: { - bool_query: { - must: [ - { term_query: { fields: ['object_type'], operator: 'is', values: ['Product'] } }, - ], - }, - }, - sorts: [ - { field: 'object_type', sort_order: 'asc' as const }, - ], - select: '(**)', - }; - - await client.searchSystemObjectDefinitions(searchRequest); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definition_search', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify(searchRequest), - }), - ); - }); - }); + it('should delegate getCategories to CatalogClient', async () => { + const mockResponse = { data: 'categories' }; + const params = { ids: ['cat1', 'cat2'], levels: 2 }; + jest.spyOn(client.catalog, 'getCategories').mockResolvedValue(mockResponse); + + const result = await client.getCategories(params); - describe('getSystemObjectAttributeDefinitions()', () => { - it('should get attribute definitions for object type', async () => { - const mockResponse = { data: [{ attribute_id: 'name' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const result = await client.getSystemObjectAttributeDefinitions('Product'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Product/attribute_definitions', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should get attribute definitions with options', async () => { - const mockResponse = { data: [{ attribute_id: 'name' }] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - const options = { - start: 5, - count: 25, - select: '(**)', - }; - - const result = await client.getSystemObjectAttributeDefinitions('Product', options); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Product/attribute_definitions?start=5&count=25&select=%28**%29', - expect.any(Object), - ); - expect(result).toEqual(mockResponse); - }); - - it('should handle URL encoding for object type with spaces', async () => { - const mockResponse = { data: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.getSystemObjectAttributeDefinitions('Custom Object Type'); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Custom%20Object%20Type/attribute_definitions', - expect.any(Object), - ); - }); - - it('should throw error for empty object type', async () => { - await expect(client.getSystemObjectAttributeDefinitions('')).rejects.toThrow( - 'Object type is required and cannot be empty', - ); - - await expect(client.getSystemObjectAttributeDefinitions(' ')).rejects.toThrow( - 'Object type is required and cannot be empty', - ); - }); - - it('should handle start and count as 0', async () => { - const mockResponse = { data: [] }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => mockResponse, - }); - - await client.getSystemObjectAttributeDefinitions('Product', { start: 0, count: 0 }); - - expect(fetch).toHaveBeenCalledWith( - 'https://test-instance.demandware.net/s/-/dw/data/v21_3/system_object_definitions/Product/attribute_definitions?start=0&count=0', - expect.any(Object), - ); - }); + expect(client.catalog.getCategories).toHaveBeenCalledWith(params); + expect(result).toBe(mockResponse); }); - }); - describe('Token management utility methods', () => { - it('should get token expiration', () => { - const mockDate = new Date('2023-12-01T12:00:00Z'); - mockTokenManager.getTokenExpiration.mockReturnValue(mockDate); + it('should delegate searchProducts to CatalogClient', async () => { + const mockResponse = { data: 'search-results' }; + const params = { q: 'shirt', count: 10, sort: 'name-asc' }; + jest.spyOn(client.catalog, 'searchProducts').mockResolvedValue(mockResponse); - const result = client.getTokenExpiration(); + const result = await client.searchProducts(params); - expect(mockTokenManager.getTokenExpiration).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - ); - expect(result).toBe(mockDate); + expect(client.catalog.searchProducts).toHaveBeenCalledWith(params); + expect(result).toBe(mockResponse); }); + }); - it('should return null when no token expiration', () => { - mockTokenManager.getTokenExpiration.mockReturnValue(null); + describe('Authentication & Token Management delegation', () => { + it('should delegate getTokenExpiration to AuthClient', () => { + const mockExpiration = new Date(); + + // Mock the authClient's getTokenExpiration method + const mockAuthClient = { + getTokenExpiration: jest.fn().mockReturnValue(mockExpiration), + }; + + // Access the private authClient property and mock it + (client as any).authClient = mockAuthClient; const result = client.getTokenExpiration(); - expect(result).toBeNull(); + expect(mockAuthClient.getTokenExpiration).toHaveBeenCalled(); + expect(result).toBe(mockExpiration); }); - it('should refresh token', async () => { - const newToken = 'refreshed-token'; - const tokenResponse: OAuthTokenResponse = { - access_token: newToken, - token_type: 'bearer', - expires_in: 3600, + it('should delegate refreshToken to AuthClient', async () => { + // Mock the authClient's refreshToken method + const mockAuthClient = { + refreshToken: jest.fn().mockResolvedValue(undefined), }; - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => tokenResponse, - }); + // Access the private authClient property and mock it + (client as any).authClient = mockAuthClient; await client.refreshToken(); - expect(mockTokenManager.clearToken).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - ); - expect(mockTokenManager.storeToken).toHaveBeenCalledWith( - mockConfig.hostname, - mockConfig.clientId, - tokenResponse, - ); + expect(mockAuthClient.refreshToken).toHaveBeenCalled(); }); }); - describe('Error handling edge cases', () => { - beforeEach(() => { - mockTokenManager.getValidToken.mockReturnValue('valid-token'); - }); - - it('should handle network errors', async () => { - (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - await expect(client.get('/test')).rejects.toThrow('Network error'); - }); + describe('Configuration handling', () => { + it('should merge config with defaults', () => { + const configWithoutVersion = { + hostname: 'test.demandware.net', + clientId: 'client-id', + clientSecret: 'client-secret', + }; - it('should handle malformed JSON responses', async () => { - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => { - throw new Error('Invalid JSON'); - }, - }); + const clientWithDefaults = new OCAPIClient(configWithoutVersion); - await expect(client.get('/test')).rejects.toThrow('Invalid JSON'); + // Verify that the client was created successfully (which means defaults were applied) + expect(clientWithDefaults).toBeInstanceOf(OCAPIClient); + expect(clientWithDefaults.systemObjects).toBeDefined(); + expect(clientWithDefaults.sitePreferences).toBeDefined(); + expect(clientWithDefaults.catalog).toBeDefined(); }); - it('should handle OAuth errors with malformed response', async () => { - mockTokenManager.getValidToken.mockReturnValue(null); + it('should preserve provided config values', () => { + const customConfig = { + hostname: 'custom.demandware.net', + clientId: 'custom-client-id', + clientSecret: 'custom-client-secret', + version: 'v22_1', + }; - (fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: async () => { - throw new Error('Failed to read response'); - }, - }); + const customClient = new OCAPIClient(customConfig); - await expect(client.get('/test')).rejects.toThrow('Failed to read response'); + // Verify that the client was created successfully with custom config + expect(customClient).toBeInstanceOf(OCAPIClient); + expect(customClient.systemObjects).toBeDefined(); + expect(customClient.sitePreferences).toBeDefined(); + expect(customClient.catalog).toBeDefined(); }); + }); - it('should handle empty response body', async () => { - (fetch as jest.Mock).mockResolvedValue({ - ok: true, - json: async () => null, - }); + describe('Error handling', () => { + it('should propagate errors from specialized clients', async () => { + const error = new Error('System objects error'); + jest.spyOn(client.systemObjects, 'getSystemObjectDefinitions').mockRejectedValue(error); - const result = await client.get('/test'); - expect(result).toBeNull(); + await expect(client.getSystemObjectDefinitions()).rejects.toThrow('System objects error'); }); - }); - describe('Configuration edge cases', () => { - it('should handle config with custom site ID', () => { - const configWithSite: OCAPIConfig = { - hostname: 'test.demandware.net', - clientId: 'client-id', - clientSecret: 'client-secret', - siteId: 'RefArch', - version: 'v22_1', - }; + it('should propagate errors from catalog client', async () => { + const error = new Error('Catalog error'); + jest.spyOn(client.catalog, 'getProducts').mockRejectedValue(error); - const clientWithSite = new OCAPIClient(configWithSite); - expect(clientWithSite).toBeInstanceOf(OCAPIClient); + await expect(client.getProducts()).rejects.toThrow('Catalog error'); }); - it('should handle minimal config', () => { - const minimalConfig: OCAPIConfig = { - hostname: 'minimal.demandware.net', - clientId: 'id', - clientSecret: 'secret', - }; + it('should propagate errors from site preferences client', async () => { + const error = new Error('Site preferences error'); + jest.spyOn(client.sitePreferences, 'searchSitePreferences').mockRejectedValue(error); - const minimalClient = new OCAPIClient(minimalConfig); - expect(minimalClient).toBeInstanceOf(OCAPIClient); + const searchRequest = { query: { match_all_query: {} } }; + await expect(client.searchSitePreferences('groupId', 'sandbox', searchRequest)).rejects.toThrow('Site preferences error'); }); }); }); diff --git a/tests/query-builder.test.ts b/tests/query-builder.test.ts new file mode 100644 index 0000000..e8e6e75 --- /dev/null +++ b/tests/query-builder.test.ts @@ -0,0 +1,281 @@ +/** + * Tests for QueryBuilder utility + * Tests URL parameter construction and array handling + */ + +import { QueryBuilder } from '../src/utils/query-builder.js'; + +describe('QueryBuilder', () => { + let builder: QueryBuilder; + + beforeEach(() => { + builder = new QueryBuilder(); + }); + + describe('constructor', () => { + it('should initialize with empty parameters', () => { + expect(builder).toBeInstanceOf(QueryBuilder); + }); + }); + + describe('add method', () => { + it('should add string parameter', () => { + const result = builder.add('key', 'value').build(); + expect(result).toBe('key=value'); + }); + + it('should add number parameter', () => { + const result = builder.add('count', 10).build(); + expect(result).toBe('count=10'); + }); + + it('should add boolean parameter', () => { + const result = builder.add('active', true).build(); + expect(result).toBe('active=true'); + }); + + it('should skip undefined values', () => { + const result = builder.add('key', undefined as any).build(); + expect(result).toBe(''); + }); + + it('should skip null values', () => { + const result = builder.add('key', null as any).build(); + expect(result).toBe(''); + }); + + it('should chain multiple adds', () => { + const result = builder + .add('first', 'value1') + .add('second', 'value2') + .build(); + expect(result).toBe('first=value1&second=value2'); + }); + }); + + describe('addArray method', () => { + it('should handle regular arrays with comma separation', () => { + const result = builder.addArray('ids', ['1', '2', '3']).build(); + expect(result).toBe('ids=1%2C2%2C3'); + }); + + it('should handle refine parameters with multiple entries', () => { + const result = builder.addArray('refine', ['category=shirts', 'color=blue']).build(); + expect(result).toBe('refine=category%3Dshirts&refine=color%3Dblue'); + }); + + it('should handle mixed string and number arrays', () => { + const result = builder.addArray('values', ['string', 123]).build(); + expect(result).toBe('values=string%2C123'); + }); + + it('should skip empty arrays', () => { + const result = builder.addArray('empty', []).build(); + expect(result).toBe(''); + }); + + it('should skip non-arrays', () => { + const result = builder.addArray('invalid', null as any).build(); + expect(result).toBe(''); + }); + + it('should chain with other methods', () => { + const result = builder + .add('single', 'value') + .addArray('multiple', ['a', 'b']) + .build(); + expect(result).toBe('single=value&multiple=a%2Cb'); + }); + }); + + describe('addFromObject method', () => { + it('should add simple object properties', () => { + const params = { + name: 'test', + count: 5, + active: true, + }; + const result = builder.addFromObject(params).build(); + expect(result).toBe('name=test&count=5&active=true'); + }); + + it('should handle arrays in object', () => { + const params = { + ids: ['1', '2'], + expand: ['details', 'variations'], + }; + const result = builder.addFromObject(params).build(); + expect(result).toBe('ids=1%2C2&expand=details%2Cvariations'); + }); + + it('should handle refine arrays specially', () => { + const params = { + q: 'shirt', + refine: ['category=clothing', 'size=large'], + }; + const result = builder.addFromObject(params).build(); + expect(result).toBe('q=shirt&refine=category%3Dclothing&refine=size%3Dlarge'); + }); + + it('should skip undefined and null values', () => { + const params = { + defined: 'value', + undefined, + null: null, + empty: '', + zero: 0, + }; + const result = builder.addFromObject(params).build(); + expect(result).toBe('defined=value&empty=&zero=0'); + }); + + it('should handle complex object with mixed types', () => { + const params = { + q: 'search term', + count: 20, + start: 0, + expand: ['images', 'prices'], + refine: ['brand=nike', 'color=red'], + active: true, + skip: undefined, + }; + const result = builder.addFromObject(params).build(); + expect(result).toBe( + 'q=search+term&count=20&start=0&expand=images%2Cprices&refine=brand%3Dnike&refine=color%3Dred&active=true', + ); + }); + + it('should chain with other methods', () => { + const result = builder + .add('manual', 'value') + .addFromObject({ auto: 'generated' }) + .add('final', 'last') + .build(); + expect(result).toBe('manual=value&auto=generated&final=last'); + }); + }); + + describe('build method', () => { + it('should return empty string for no parameters', () => { + const result = builder.build(); + expect(result).toBe(''); + }); + + it('should properly encode special characters', () => { + const result = builder.add('special', 'value with spaces & symbols!').build(); + expect(result).toBe('special=value+with+spaces+%26+symbols%21'); + }); + + it('should handle multiple calls to build', () => { + builder.add('key', 'value'); + const first = builder.build(); + const second = builder.build(); + expect(first).toBe(second); + expect(first).toBe('key=value'); + }); + }); + + describe('reset method', () => { + it('should clear all parameters', () => { + const result = builder + .add('first', 'value') + .add('second', 'value') + .reset() + .build(); + expect(result).toBe(''); + }); + + it('should return QueryBuilder instance for chaining', () => { + const result = builder.reset(); + expect(result).toBeInstanceOf(QueryBuilder); + expect(result).toBe(builder); + }); + + it('should allow rebuilding after reset', () => { + const result = builder + .add('old', 'value') + .reset() + .add('new', 'value') + .build(); + expect(result).toBe('new=value'); + }); + }); + + describe('static fromObject method', () => { + it('should create query string from object', () => { + const params = { + search: 'test', + count: 10, + active: true, + }; + const result = QueryBuilder.fromObject(params); + expect(result).toBe('search=test&count=10&active=true'); + }); + + it('should handle arrays correctly', () => { + const params = { + ids: ['1', '2', '3'], + refine: ['category=shirts', 'size=large'], + }; + const result = QueryBuilder.fromObject(params); + expect(result).toBe('ids=1%2C2%2C3&refine=category%3Dshirts&refine=size%3Dlarge'); + }); + + it('should handle empty object', () => { + const result = QueryBuilder.fromObject({}); + expect(result).toBe(''); + }); + + it('should handle complex OCAPI-style parameters', () => { + const params = { + q: 'mens shoes', + count: 25, + start: 50, + expand: ['images', 'variations', 'prices'], + refine: ['category=footwear', 'brand=nike', 'size=10'], + sort: 'price-asc', + currency: 'USD', + locale: 'en_US', + }; + const result = QueryBuilder.fromObject(params); + + expect(result).toContain('q=mens+shoes'); + expect(result).toContain('count=25'); + expect(result).toContain('start=50'); + expect(result).toContain('expand=images%2Cvariations%2Cprices'); + expect(result).toContain('refine=category%3Dfootwear'); + expect(result).toContain('refine=brand%3Dnike'); + expect(result).toContain('refine=size%3D10'); + expect(result).toContain('sort=price-asc'); + expect(result).toContain('currency=USD'); + expect(result).toContain('locale=en_US'); + }); + }); + + describe('edge cases', () => { + it('should handle empty strings', () => { + const result = builder.add('empty', '').build(); + expect(result).toBe('empty='); + }); + + it('should handle zero values', () => { + const result = builder.add('zero', 0).build(); + expect(result).toBe('zero=0'); + }); + + it('should handle false values', () => { + const result = builder.add('false', false).build(); + expect(result).toBe('false=false'); + }); + + it('should handle arrays with empty strings', () => { + const result = builder.addArray('mixed', ['value', '', 'another']).build(); + expect(result).toBe('mixed=value%2C%2Canother'); + }); + + it('should handle Unicode characters', () => { + const result = builder.add('unicode', 'café ñoño 中文').build(); + expect(result).toBe('unicode=caf%C3%A9+%C3%B1o%C3%B1o+%E4%B8%AD%E6%96%87'); + }); + }); +}); diff --git a/tests/site-preferences-client.test.ts b/tests/site-preferences-client.test.ts new file mode 100644 index 0000000..567ff33 --- /dev/null +++ b/tests/site-preferences-client.test.ts @@ -0,0 +1,358 @@ +/** + * Tests for OCAPISitePreferencesClient + * Tests site preferences operations + */ + +import { OCAPISitePreferencesClient } from '../src/clients/ocapi/site-preferences-client.js'; +import { OCAPIConfig } from '../src/types/types.js'; +import { QueryBuilder } from '../src/utils/query-builder.js'; +import { Validator } from '../src/utils/validator.js'; + +// Mock dependencies +jest.mock('../src/clients/base/ocapi-auth-client.js'); +jest.mock('../src/utils/query-builder.js'); +jest.mock('../src/utils/validator.js'); + +describe('OCAPISitePreferencesClient', () => { + let client: OCAPISitePreferencesClient; + let mockValidateRequired: jest.MockedFunction; + let mockValidateInstanceType: jest.MockedFunction; + let mockValidateSearchRequest: jest.MockedFunction; + let mockQueryBuilderFromObject: jest.MockedFunction; + + const mockConfig: OCAPIConfig = { + hostname: 'test-instance.demandware.net', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + version: 'v21_3', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Validator methods + mockValidateRequired = Validator.validateRequired as jest.MockedFunction; + mockValidateInstanceType = Validator.validateInstanceType as jest.MockedFunction< + typeof Validator.validateInstanceType + >; + mockValidateSearchRequest = Validator.validateSearchRequest as jest.MockedFunction< + typeof Validator.validateSearchRequest + >; + + // Reset mock implementations to default behavior + mockValidateRequired.mockImplementation(() => {}); + mockValidateInstanceType.mockImplementation(() => 'sandbox'); // Return a valid instance type + mockValidateSearchRequest.mockImplementation(() => {}); + + // Mock QueryBuilder + mockQueryBuilderFromObject = QueryBuilder.fromObject as jest.MockedFunction; + + client = new OCAPISitePreferencesClient(mockConfig); + + // Mock the inherited methods by adding them as properties - avoid protected access + (client as any).post = jest.fn().mockResolvedValue({ data: 'mocked' }); + }); + + describe('constructor', () => { + it('should initialize with correct base URL', () => { + expect(client).toBeInstanceOf(OCAPISitePreferencesClient); + }); + + it('should use default version when not provided', () => { + const configWithoutVersion = { + hostname: 'test.demandware.net', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + const clientWithDefaults = new OCAPISitePreferencesClient(configWithoutVersion); + expect(clientWithDefaults).toBeInstanceOf(OCAPISitePreferencesClient); + }); + }); + + describe('searchSitePreferences', () => { + const groupId = 'SiteGeneral'; + const instanceType = 'sandbox'; + const searchRequest = { + query: { match_all_query: {} }, + }; + + beforeEach(() => { + mockValidateInstanceType.mockReturnValue('sandbox'); + }); + + it('should validate all required parameters', async () => { + await client.searchSitePreferences(groupId, instanceType, searchRequest); + + expect(Validator.validateRequired).toHaveBeenCalledWith( + { groupId, instanceType }, + ['groupId', 'instanceType'], + ); + expect(Validator.validateInstanceType).toHaveBeenCalledWith(instanceType); + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + }); + + it('should make POST request to site preferences search endpoint', async () => { + await client.searchSitePreferences(groupId, instanceType, searchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search', + searchRequest, + ); + }); + + it('should encode group ID in URL', async () => { + const encodedGroupId = 'Site General Settings'; + + await client.searchSitePreferences(encodedGroupId, instanceType, searchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/Site%20General%20Settings/sandbox/preference_search', + searchRequest, + ); + }); + + it('should handle different instance types', async () => { + const testCases = [ + { type: 'staging', expected: 'staging' }, + { type: 'development', expected: 'development' }, + { type: 'production', expected: 'production' }, + ]; + + for (const testCase of testCases) { + mockValidateInstanceType.mockReturnValue(testCase.expected as any); + + await client.searchSitePreferences(groupId, testCase.type, searchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + `/site_preferences/preference_groups/${groupId}/${testCase.expected}/preference_search`, + searchRequest, + ); + } + }); + + it('should include options in query string when provided', async () => { + const options = { + maskPasswords: true, + expand: 'value', + }; + mockQueryBuilderFromObject.mockReturnValue('maskPasswords=true&expand=value'); + + await client.searchSitePreferences(groupId, instanceType, searchRequest, options); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(options); + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search?maskPasswords=true&expand=value', + searchRequest, + ); + }); + + it('should not include query string when options are empty', async () => { + const options = {}; + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.searchSitePreferences(groupId, instanceType, searchRequest, options); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search', + searchRequest, + ); + }); + + it('should handle complex search request', async () => { + const complexSearchRequest = { + query: { + text_query: { + fields: ['id', 'display_name', 'description'], + search_phrase: 'email', + }, + }, + sorts: [ + { field: 'display_name', sort_order: 'asc' as const }, + { field: 'id' }, + ], + start: 0, + count: 25, + select: '(**)', + }; + + await client.searchSitePreferences(groupId, instanceType, complexSearchRequest); + + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(complexSearchRequest); + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search', + complexSearchRequest, + ); + }); + + it('should handle term query for value types', async () => { + const termSearchRequest = { + query: { + term_query: { + fields: ['value_type'], + operator: 'one_of', + values: ['string', 'boolean', 'int'], + }, + }, + }; + + await client.searchSitePreferences(groupId, instanceType, termSearchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search', + termSearchRequest, + ); + }); + + it('should handle boolean query combinations', async () => { + const boolSearchRequest = { + query: { + bool_query: { + must: [ + { + text_query: { + fields: ['display_name'], + search_phrase: 'payment', + }, + }, + ], + must_not: [ + { + term_query: { + fields: ['value_type'], + operator: 'is', + values: ['password'], + }, + }, + ], + }, + }, + }; + + await client.searchSitePreferences(groupId, instanceType, boolSearchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/SiteGeneral/sandbox/preference_search', + boolSearchRequest, + ); + }); + }); + + describe('error handling', () => { + it('should propagate validation errors for required fields', async () => { + const validationError = new Error('Required field missing'); + mockValidateRequired.mockImplementation(() => { + throw validationError; + }); + + await expect( + client.searchSitePreferences('groupId', 'sandbox', { query: {} }), + ).rejects.toThrow(validationError); + }); + + it('should propagate instance type validation errors', async () => { + const instanceTypeError = new Error('Invalid instance type'); + mockValidateInstanceType.mockImplementation(() => { + throw instanceTypeError; + }); + + await expect( + client.searchSitePreferences('groupId', 'invalid', { query: {} }), + ).rejects.toThrow(instanceTypeError); + }); + + it('should propagate search request validation errors', async () => { + const searchValidationError = new Error('Invalid search request'); + mockValidateSearchRequest.mockImplementation(() => { + throw searchValidationError; + }); + + await expect( + client.searchSitePreferences('groupId', 'sandbox', { query: {} }), + ).rejects.toThrow(searchValidationError); + }); + + it('should propagate HTTP errors from base client', async () => { + const httpError = new Error('HTTP request failed'); + (client as any).post = jest.fn().mockRejectedValue(httpError); + + await expect( + client.searchSitePreferences('groupId', 'sandbox', { query: { match_all_query: {} } }), + ).rejects.toThrow(httpError); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete site preferences search workflow', async () => { + const groupId = 'CustomPreferences'; + const instanceType = 'development'; + const searchRequest = { + query: { + bool_query: { + must: [ + { + text_query: { + fields: ['id', 'display_name'], + search_phrase: 'api', + }, + }, + { + term_query: { + fields: ['value_type'], + operator: 'one_of', + values: ['string', 'text'], + }, + }, + ], + }, + }, + sorts: [ + { field: 'display_name', sort_order: 'asc' as const }, + ], + start: 0, + count: 50, + }; + const options = { + maskPasswords: false, + expand: 'value', + }; + + mockValidateInstanceType.mockReturnValue('development'); + mockQueryBuilderFromObject.mockReturnValue('maskPasswords=false&expand=value'); + + await client.searchSitePreferences(groupId, instanceType, searchRequest, options); + + // Verify all validations were called + expect(Validator.validateRequired).toHaveBeenCalledWith( + { groupId, instanceType }, + ['groupId', 'instanceType'], + ); + expect(Validator.validateInstanceType).toHaveBeenCalledWith(instanceType); + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + + // Verify query string building + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(options); + + // Verify the final API call + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/CustomPreferences/development/preference_search?maskPasswords=false&expand=value', + searchRequest, + ); + }); + + it('should handle minimal search request', async () => { + const minimalRequest = { + query: { match_all_query: {} }, + }; + + mockValidateInstanceType.mockReturnValue('sandbox'); + + await client.searchSitePreferences('Basic', 'sandbox', minimalRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/site_preferences/preference_groups/Basic/sandbox/preference_search', + minimalRequest, + ); + }); + }); +}); diff --git a/tests/system-objects-client.test.ts b/tests/system-objects-client.test.ts new file mode 100644 index 0000000..c2b0644 --- /dev/null +++ b/tests/system-objects-client.test.ts @@ -0,0 +1,339 @@ +/** + * Tests for OCAPISystemObjectsClient + * Tests system objects operations + */ + +import { OCAPISystemObjectsClient } from '../src/clients/ocapi/system-objects-client.js'; +import { OCAPIConfig } from '../src/types/types.js'; +import { QueryBuilder } from '../src/utils/query-builder.js'; +import { Validator } from '../src/utils/validator.js'; + +// Mock dependencies +jest.mock('../src/clients/base/ocapi-auth-client.js'); +jest.mock('../src/utils/query-builder.js'); +jest.mock('../src/utils/validator.js'); + +describe('OCAPISystemObjectsClient', () => { + let client: OCAPISystemObjectsClient; + let mockValidateRequired: jest.MockedFunction; + let mockValidateObjectType: jest.MockedFunction; + let mockValidateSearchRequest: jest.MockedFunction; + let mockQueryBuilderFromObject: jest.MockedFunction; + + const mockConfig: OCAPIConfig = { + hostname: 'test-instance.demandware.net', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + version: 'v21_3', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Validator methods + mockValidateRequired = Validator.validateRequired as jest.MockedFunction; + mockValidateObjectType = Validator.validateObjectType as jest.MockedFunction; + mockValidateSearchRequest = Validator.validateSearchRequest as jest.MockedFunction< + typeof Validator.validateSearchRequest + >; + + // Reset mock implementations to default behavior + mockValidateRequired.mockImplementation(() => {}); + mockValidateObjectType.mockImplementation(() => {}); + mockValidateSearchRequest.mockImplementation(() => {}); + + // Mock QueryBuilder + mockQueryBuilderFromObject = QueryBuilder.fromObject as jest.MockedFunction; + + client = new OCAPISystemObjectsClient(mockConfig); + + // Mock the inherited methods by adding them as properties - avoid protected access + (client as any).get = jest.fn().mockResolvedValue({ data: 'mocked' }); + (client as any).post = jest.fn().mockResolvedValue({ data: 'mocked' }); + }); + + describe('constructor', () => { + it('should initialize with correct base URL', () => { + expect(client).toBeInstanceOf(OCAPISystemObjectsClient); + }); + + it('should use default version when not provided', () => { + const configWithoutVersion = { + hostname: 'test.demandware.net', + clientId: 'client-id', + clientSecret: 'client-secret', + }; + + const clientWithDefaults = new OCAPISystemObjectsClient(configWithoutVersion); + expect(clientWithDefaults).toBeInstanceOf(OCAPISystemObjectsClient); + }); + }); + + describe('getSystemObjectDefinitions', () => { + it('should make GET request to system_object_definitions endpoint', async () => { + await client.getSystemObjectDefinitions(); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions'); + }); + + it('should include query parameters when provided', async () => { + const params = { start: 0, count: 10, select: '(**)' }; + mockQueryBuilderFromObject.mockReturnValue('start=0&count=10&select=%28%2A%2A%29'); + + await client.getSystemObjectDefinitions(params); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(params); + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions?start=0&count=10&select=%28%2A%2A%29'); + }); + + it('should not include query string when no parameters provided', async () => { + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.getSystemObjectDefinitions({}); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions'); + }); + }); + + describe('getSystemObjectDefinition', () => { + it('should validate required parameters', async () => { + const objectType = 'Product'; + + await client.getSystemObjectDefinition(objectType); + + expect(Validator.validateRequired).toHaveBeenCalledWith({ objectType }, ['objectType']); + expect(Validator.validateObjectType).toHaveBeenCalledWith(objectType); + }); + + it('should make GET request with encoded object type', async () => { + const objectType = 'Custom Object'; + + await client.getSystemObjectDefinition(objectType); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions/Custom%20Object'); + }); + + it('should handle special characters in object type', async () => { + const objectType = 'Product/Variant'; + + await client.getSystemObjectDefinition(objectType); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions/Product%2FVariant'); + }); + }); + + describe('searchSystemObjectDefinitions', () => { + it('should validate search request', async () => { + const searchRequest = { + query: { match_all_query: {} }, + }; + + await client.searchSystemObjectDefinitions(searchRequest); + + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + }); + + it('should make POST request to search endpoint', async () => { + const searchRequest = { + query: { + text_query: { + fields: ['object_type', 'display_name'], + search_phrase: 'product', + }, + }, + }; + + await client.searchSystemObjectDefinitions(searchRequest); + + expect((client as any).post).toHaveBeenCalledWith('/system_object_definition_search', searchRequest); + }); + }); + + describe('getSystemObjectAttributeDefinitions', () => { + it('should validate required parameters', async () => { + const objectType = 'Product'; + + await client.getSystemObjectAttributeDefinitions(objectType); + + expect(Validator.validateRequired).toHaveBeenCalledWith({ objectType }, ['objectType']); + expect(Validator.validateObjectType).toHaveBeenCalledWith(objectType); + }); + + it('should make GET request to attribute definitions endpoint', async () => { + const objectType = 'Product'; + + await client.getSystemObjectAttributeDefinitions(objectType); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions/Product/attribute_definitions'); + }); + + it('should include query parameters when provided', async () => { + const objectType = 'Product'; + const options = { count: 50, select: '(**)' }; + mockQueryBuilderFromObject.mockReturnValue('count=50&select=%28%2A%2A%29'); + + await client.getSystemObjectAttributeDefinitions(objectType, options); + + expect(QueryBuilder.fromObject).toHaveBeenCalledWith(options); + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions/Product/attribute_definitions?count=50&select=%28%2A%2A%29'); + }); + }); + + describe('searchSystemObjectAttributeDefinitions', () => { + it('should validate all required parameters', async () => { + const objectType = 'Product'; + const searchRequest = { + query: { + text_query: { + fields: ['id', 'display_name'], + search_phrase: 'custom', + }, + }, + }; + + await client.searchSystemObjectAttributeDefinitions(objectType, searchRequest); + + expect(Validator.validateRequired).toHaveBeenCalledWith({ objectType }, ['objectType']); + expect(Validator.validateObjectType).toHaveBeenCalledWith(objectType); + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + }); + + it('should make POST request to attribute definition search endpoint', async () => { + const objectType = 'Product'; + const searchRequest = { + query: { + term_query: { + fields: ['value_type'], + operator: 'is', + values: ['string'], + }, + }, + }; + + await client.searchSystemObjectAttributeDefinitions(objectType, searchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/system_object_definitions/Product/attribute_definition_search', + searchRequest, + ); + }); + }); + + describe('searchSystemObjectAttributeGroups', () => { + it('should validate all required parameters', async () => { + const objectType = 'SitePreferences'; + const searchRequest = { + query: { match_all_query: {} }, + }; + + await client.searchSystemObjectAttributeGroups(objectType, searchRequest); + + expect(Validator.validateRequired).toHaveBeenCalledWith({ objectType }, ['objectType']); + expect(Validator.validateObjectType).toHaveBeenCalledWith(objectType); + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + }); + + it('should make POST request to attribute group search endpoint', async () => { + const objectType = 'SitePreferences'; + const searchRequest = { + query: { + text_query: { + fields: ['id', 'display_name'], + search_phrase: 'general', + }, + }, + sorts: [{ field: 'position', sort_order: 'asc' as const }], + }; + + await client.searchSystemObjectAttributeGroups(objectType, searchRequest); + + expect((client as any).post).toHaveBeenCalledWith( + '/system_object_definitions/SitePreferences/attribute_group_search', + searchRequest, + ); + }); + }); + + describe('error handling', () => { + it('should propagate validation errors', async () => { + const validationError = new Error('Validation failed'); + mockValidateRequired.mockImplementation(() => { + throw validationError; + }); + + await expect(client.getSystemObjectDefinition('Product')).rejects.toThrow(validationError); + }); + + it('should propagate HTTP errors from base client', async () => { + const httpError = new Error('HTTP request failed'); + (client as any).get = jest.fn().mockRejectedValue(httpError); + + await expect(client.getSystemObjectDefinitions()).rejects.toThrow(httpError); + }); + + it('should propagate search validation errors', async () => { + const searchValidationError = new Error('Invalid search request'); + mockValidateSearchRequest.mockImplementation(() => { + throw searchValidationError; + }); + + const searchRequest = { query: {} }; + await expect(client.searchSystemObjectDefinitions(searchRequest)).rejects.toThrow(searchValidationError); + }); + }); + + describe('integration scenarios', () => { + it('should handle complex search with all options', async () => { + const objectType = 'Product'; + const searchRequest = { + query: { + bool_query: { + must: [ + { + text_query: { + fields: ['id'], + search_phrase: 'custom', + }, + }, + ], + must_not: [ + { + term_query: { + fields: ['system'], + operator: 'is', + values: [true], + }, + }, + ], + }, + }, + sorts: [ + { field: 'display_name', sort_order: 'asc' as const }, + { field: 'id' }, + ], + start: 10, + count: 25, + select: '(**)', + }; + + await client.searchSystemObjectAttributeDefinitions(objectType, searchRequest); + + expect(Validator.validateRequired).toHaveBeenCalled(); + expect(Validator.validateObjectType).toHaveBeenCalled(); + expect(Validator.validateSearchRequest).toHaveBeenCalledWith(searchRequest); + expect((client as any).post).toHaveBeenCalledWith( + '/system_object_definitions/Product/attribute_definition_search', + searchRequest, + ); + }); + + it('should handle empty query parameters gracefully', async () => { + const params = {}; + mockQueryBuilderFromObject.mockReturnValue(''); + + await client.getSystemObjectDefinitions(params); + + expect((client as any).get).toHaveBeenCalledWith('/system_object_definitions'); + }); + }); +}); diff --git a/tests/validator.test.ts b/tests/validator.test.ts new file mode 100644 index 0000000..e818635 --- /dev/null +++ b/tests/validator.test.ts @@ -0,0 +1,416 @@ +/** + * Tests for Validator utility + * Tests input validation functionality + */ + +import { Validator, ValidationError } from '../src/utils/validator.js'; + +describe('Validator', () => { + describe('ValidationError', () => { + it('should create validation error with message', () => { + const error = new ValidationError('Test error message'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('ValidationError'); + expect(error.message).toBe('Test error message'); + }); + }); + + describe('validateRequired', () => { + it('should pass when all required fields are present', () => { + const params = { + field1: 'value1', + field2: 'value2', + field3: 123, + }; + + expect(() => { + Validator.validateRequired(params, ['field1', 'field2']); + }).not.toThrow(); + }); + + it('should throw error when required field is missing', () => { + const params = { + field1: 'value1', + }; + + expect(() => { + Validator.validateRequired(params, ['field1', 'field2']); + }).toThrow(ValidationError); + expect(() => { + Validator.validateRequired(params, ['field1', 'field2']); + }).toThrow('Required fields are missing or empty: field2'); + }); + + it('should throw error when required field is empty string', () => { + const params = { + field1: 'value1', + field2: '', + }; + + expect(() => { + Validator.validateRequired(params, ['field1', 'field2']); + }).toThrow('Required fields are missing or empty: field2'); + }); + + it('should throw error when required field is whitespace only', () => { + const params = { + field1: 'value1', + field2: ' ', + }; + + expect(() => { + Validator.validateRequired(params, ['field1', 'field2']); + }).toThrow('Required fields are missing or empty: field2'); + }); + + it('should throw error for multiple missing fields', () => { + const params = { + field1: 'value1', + }; + + expect(() => { + Validator.validateRequired(params, ['field2', 'field3', 'field4']); + }).toThrow('Required fields are missing or empty: field2, field3, field4'); + }); + + it('should handle non-string values correctly', () => { + const params = { + number: 0, + boolean: false, + object: {}, + array: [], + }; + + expect(() => { + Validator.validateRequired(params, ['number', 'boolean', 'object', 'array']); + }).not.toThrow(); + }); + }); + + describe('validateInstanceType', () => { + it('should accept valid instance types', () => { + expect(Validator.validateInstanceType('staging')).toBe('staging'); + expect(Validator.validateInstanceType('development')).toBe('development'); + expect(Validator.validateInstanceType('sandbox')).toBe('sandbox'); + expect(Validator.validateInstanceType('production')).toBe('production'); + }); + + it('should throw error for invalid instance type', () => { + expect(() => { + Validator.validateInstanceType('invalid'); + }).toThrow(ValidationError); + expect(() => { + Validator.validateInstanceType('invalid'); + }).toThrow('Invalid instance type \'invalid\'. Must be one of: staging, development, sandbox, production'); + }); + + it('should throw error for empty string', () => { + expect(() => { + Validator.validateInstanceType(''); + }).toThrow('Invalid instance type \'\'. Must be one of: staging, development, sandbox, production'); + }); + + it('should throw error for case-sensitive mismatch', () => { + expect(() => { + Validator.validateInstanceType('STAGING'); + }).toThrow('Invalid instance type \'STAGING\'. Must be one of: staging, development, sandbox, production'); + }); + }); + + describe('validateNotEmpty', () => { + it('should pass for non-empty string', () => { + expect(() => { + Validator.validateNotEmpty('valid value', 'testField'); + }).not.toThrow(); + }); + + it('should throw error for empty string', () => { + expect(() => { + Validator.validateNotEmpty('', 'testField'); + }).toThrow(ValidationError); + expect(() => { + Validator.validateNotEmpty('', 'testField'); + }).toThrow('testField cannot be empty'); + }); + + it('should throw error for whitespace-only string', () => { + expect(() => { + Validator.validateNotEmpty(' ', 'testField'); + }).toThrow('testField cannot be empty'); + }); + }); + + describe('validatePositiveNumber', () => { + it('should pass for positive numbers', () => { + expect(() => { + Validator.validatePositiveNumber(1, 'testField'); + Validator.validatePositiveNumber(100, 'testField'); + Validator.validatePositiveNumber(0.5, 'testField'); + }).not.toThrow(); + }); + + it('should pass for zero', () => { + expect(() => { + Validator.validatePositiveNumber(0, 'testField'); + }).not.toThrow(); + }); + + it('should throw error for negative numbers', () => { + expect(() => { + Validator.validatePositiveNumber(-1, 'testField'); + }).toThrow(ValidationError); + expect(() => { + Validator.validatePositiveNumber(-1, 'testField'); + }).toThrow('testField must be a positive number'); + }); + }); + + describe('validateObjectType', () => { + it('should pass for valid object types', () => { + expect(() => { + Validator.validateObjectType('Product'); + Validator.validateObjectType('Customer'); + Validator.validateObjectType('SitePreferences'); + Validator.validateObjectType('Custom_Object'); + Validator.validateObjectType('MyObject123'); + }).not.toThrow(); + }); + + it('should throw error for empty object type', () => { + expect(() => { + Validator.validateObjectType(''); + }).toThrow('objectType cannot be empty'); + }); + + it('should throw error for object type starting with number', () => { + expect(() => { + Validator.validateObjectType('123Object'); + }).toThrow('Invalid object type \'123Object\'. Must start with a letter and contain only letters, numbers, and underscores.'); + }); + + it('should throw error for object type with special characters', () => { + expect(() => { + Validator.validateObjectType('Product-Type'); + }).toThrow('Invalid object type \'Product-Type\'. Must start with a letter and contain only letters, numbers, and underscores.'); + }); + + it('should throw error for object type with spaces', () => { + expect(() => { + Validator.validateObjectType('Product Type'); + }).toThrow('Invalid object type \'Product Type\'. Must start with a letter and contain only letters, numbers, and underscores.'); + }); + }); + + describe('validateSearchRequest', () => { + it('should pass for valid search request with text_query', () => { + const searchRequest = { + query: { + text_query: { + fields: ['id', 'display_name'], + search_phrase: 'test', + }, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).not.toThrow(); + }); + + it('should pass for valid search request with term_query', () => { + const searchRequest = { + query: { + term_query: { + fields: ['value_type'], + operator: 'is', + values: ['string'], + }, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).not.toThrow(); + }); + + it('should pass for valid search request with match_all_query', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).not.toThrow(); + }); + + it('should pass for search request with sorts', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + sorts: [ + { field: 'id', sort_order: 'asc' }, + { field: 'display_name' }, + ], + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).not.toThrow(); + }); + + it('should pass for search request with pagination', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + start: 0, + count: 25, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).not.toThrow(); + }); + + it('should throw error for non-object search request', () => { + expect(() => { + Validator.validateSearchRequest('invalid'); + }).toThrow('Search request must be a valid object'); + }); + + it('should throw error for null search request', () => { + expect(() => { + Validator.validateSearchRequest(null); + }).toThrow('Search request must be a valid object'); + }); + + it('should throw error for search request with no valid query types', () => { + const searchRequest = { + query: { + invalid_query: {}, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('Search query must contain at least one of: text_query, term_query, filtered_query, bool_query, match_all_query'); + }); + + it('should throw error for text_query with empty fields', () => { + const searchRequest = { + query: { + text_query: { + fields: [], + search_phrase: 'test', + }, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('text_query.fields must be a non-empty array'); + }); + + it('should throw error for text_query with missing search_phrase', () => { + const searchRequest = { + query: { + text_query: { + fields: ['id'], + }, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('text_query.search_phrase must be a non-empty string'); + }); + + it('should throw error for term_query with invalid structure', () => { + const searchRequest = { + query: { + term_query: { + fields: [], + operator: 'is', + values: ['test'], + }, + }, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('term_query.fields must be a non-empty array'); + }); + + it('should throw error for invalid sorts structure', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + sorts: 'invalid', + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('sorts must be an array'); + }); + + it('should throw error for sort with missing field', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + sorts: [ + { sort_order: 'asc' }, + ], + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('sorts[0].field must be a non-empty string'); + }); + + it('should throw error for sort with invalid sort_order', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + sorts: [ + { field: 'id', sort_order: 'invalid' }, + ], + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('sorts[0].sort_order must be either \'asc\' or \'desc\''); + }); + + it('should throw error for negative start value', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + start: -1, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('start must be a positive number'); + }); + + it('should throw error for negative count value', () => { + const searchRequest = { + query: { + match_all_query: {}, + }, + count: -5, + }; + + expect(() => { + Validator.validateSearchRequest(searchRequest); + }).toThrow('count must be a positive number'); + }); + }); +}); From 51a84442661c0f7ab87b2904cac0435b2ccf4935 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Wed, 13 Aug 2025 10:34:30 +0200 Subject: [PATCH 2/3] Maintenance: Moves OAuth token management Moves the OAuth token management functionality from a generic `auth` directory to a `base` directory within the `clients` directory. This change improves the project's structure by aligning the token management more closely with the HTTP client infrastructure, as it's integral to the base client authentication process. Updates relevant imports and references to reflect the new location. --- .github/copilot-instructions.md | 14 ++++---------- src/{auth => clients/base}/oauth-token.ts | 2 +- src/clients/base/ocapi-auth-client.ts | 2 +- src/index.ts | 2 +- tests/oauth-token.test.ts | 2 +- tests/ocapi-auth-client.test.ts | 4 ++-- tests/ocapi-client.test.ts | 4 ++-- 7 files changed, 12 insertions(+), 18 deletions(-) rename src/{auth => clients/base}/oauth-token.ts (97%) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 714bd20..b65af6c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -55,7 +55,8 @@ sfcc-dev-mcp/ │ ├── clients/ # API clients for different services │ │ ├── base/ # Base client classes and shared functionality │ │ │ ├── http-client.ts # Base HTTP client with authentication -│ │ │ └── ocapi-auth-client.ts # OCAPI OAuth authentication client +│ │ │ ├── ocapi-auth-client.ts # OCAPI OAuth authentication client +│ │ │ └── oauth-token.ts # OAuth token management for OCAPI │ │ ├── ocapi/ # Specialized OCAPI clients │ │ │ ├── catalog-client.ts # OCAPI catalog operations │ │ │ ├── site-preferences-client.ts # Site preferences management @@ -64,8 +65,6 @@ sfcc-dev-mcp/ │ │ ├── docs-client.ts # SFCC documentation client │ │ ├── ocapi-client.ts # Main OCAPI client coordinator │ │ └── best-practices-client.ts # Best practices guide client -│ ├── auth/ # Authentication and OAuth management -│ │ └── oauth-token.ts # OAuth token management │ ├── config/ # Configuration management │ │ ├── config.ts # Configuration loading and validation │ │ ├── configuration-factory.ts # Config factory for different modes @@ -111,6 +110,7 @@ sfcc-dev-mcp/ ##### **Base Client Infrastructure** (`clients/base/`) - **BaseHttpClient** (`http-client.ts`): Abstract base class providing HTTP operations, authentication handling, and error recovery - **OCAPIAuthClient** (`ocapi-auth-client.ts`): OCAPI-specific OAuth authentication with token management and automatic renewal +- **TokenManager** (`oauth-token.ts`): Singleton OAuth token manager for SFCC OCAPI authentication with automatic expiration handling ##### **Specialized OCAPI Clients** (`clients/ocapi/`) - **OCAPICatalogClient** (`catalog-client.ts`): Handles catalog operations, product searches, and category management @@ -123,12 +123,6 @@ sfcc-dev-mcp/ - **OCAPIClient** (`ocapi-client.ts`): Main OCAPI coordinator that orchestrates specialized clients and provides unified interface - **BestPracticesClient** (`best-practices-client.ts`): Serves curated development guides, security recommendations, and hook references -#### **Authentication & Security** (`auth/`) -- **OAuth Token Management** (`oauth-token.ts`): Handles SFCC OAuth flows with automatic renewal for local development -- **Credential Security**: Secure local storage and handling of SFCC instance credentials -- **Local Protection**: Prevents accidental credential exposure and limits network access to localhost -- **Rate Limiting**: Implements reasonable rate limiting to avoid overwhelming developer sandbox instances - #### **Configuration Management** (`config/`) - **Configuration Factory** (`configuration-factory.ts`): Creates configurations for different modes - **Config Loader** (`config.ts`): Handles dw.json and environment variable loading @@ -254,7 +248,7 @@ When working on this project: - **Adding New Tools**: Define schema in `core/tool-definitions.ts`, implement handler in appropriate client in `clients/` - **Updating Documentation**: Modify files in `docs/` and run conversion scripts -- **Enhancing Authentication**: Update `auth/oauth-token.ts` and client authentication logic +- **Enhancing Authentication**: Update `clients/base/oauth-token.ts` and client authentication logic - **Improving Caching**: Enhance `utils/cache.ts` for better performance and data freshness - **Adding Configuration Options**: Update `config/` modules for new configuration capabilities - **Adding Tests**: Create comprehensive test coverage in the `tests/` directory diff --git a/src/auth/oauth-token.ts b/src/clients/base/oauth-token.ts similarity index 97% rename from src/auth/oauth-token.ts rename to src/clients/base/oauth-token.ts index 8400b25..efd54ad 100644 --- a/src/auth/oauth-token.ts +++ b/src/clients/base/oauth-token.ts @@ -5,7 +5,7 @@ * for SFCC OCAPI requests. It handles automatic token refresh when tokens expire. */ -import { OAuthToken, OAuthTokenResponse } from '../types/types.js'; +import { OAuthToken, OAuthTokenResponse } from '../../types/types.js'; /** * Singleton class for managing OAuth tokens diff --git a/src/clients/base/ocapi-auth-client.ts b/src/clients/base/ocapi-auth-client.ts index ac92e0a..d9e2ab5 100644 --- a/src/clients/base/ocapi-auth-client.ts +++ b/src/clients/base/ocapi-auth-client.ts @@ -6,7 +6,7 @@ */ import { OCAPIConfig, OAuthTokenResponse } from '../../types/types.js'; -import { TokenManager } from '../../auth/oauth-token.js'; +import { TokenManager } from './oauth-token.js'; import { BaseHttpClient } from './http-client.js'; // OCAPI authentication constants diff --git a/src/index.ts b/src/index.ts index bc9cb2f..bd1abdf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ export { SFCCDevServer } from './core/server.js'; export { SFCCLogClient } from './clients/log-client.js'; export { SFCCDocumentationClient } from './clients/docs-client.js'; export { OCAPIClient } from './clients/ocapi-client.js'; -export { TokenManager } from './auth/oauth-token.js'; +export { TokenManager } from './clients/base/oauth-token.js'; export type { SFCCConfig, LogLevel, LogFileInfo, LogSummary, OCAPIConfig, OAuthToken, OAuthTokenResponse } from './types/types.js'; // For direct execution, delegate to main.ts diff --git a/tests/oauth-token.test.ts b/tests/oauth-token.test.ts index b1c4e4b..798177f 100644 --- a/tests/oauth-token.test.ts +++ b/tests/oauth-token.test.ts @@ -1,4 +1,4 @@ -import { TokenManager } from '../src/auth/oauth-token'; +import { TokenManager } from '../src/clients/base/oauth-token.js'; import { OAuthTokenResponse } from '../src/types/types'; describe('TokenManager', () => { diff --git a/tests/ocapi-auth-client.test.ts b/tests/ocapi-auth-client.test.ts index 2f44077..0bdd016 100644 --- a/tests/ocapi-auth-client.test.ts +++ b/tests/ocapi-auth-client.test.ts @@ -4,14 +4,14 @@ */ import { OCAPIAuthClient } from '../src/clients/base/ocapi-auth-client.js'; -import { TokenManager } from '../src/auth/oauth-token.js'; +import { TokenManager } from '../src/clients/base/oauth-token.js'; import { OCAPIConfig, OAuthTokenResponse } from '../src/types/types.js'; // Mock fetch globally global.fetch = jest.fn(); // Mock TokenManager -jest.mock('../src/auth/oauth-token.js'); +jest.mock('../src/clients/base/oauth-token.js'); // Mock Logger jest.mock('../src/utils/logger.js'); diff --git a/tests/ocapi-client.test.ts b/tests/ocapi-client.test.ts index 62dcf0f..4521f99 100644 --- a/tests/ocapi-client.test.ts +++ b/tests/ocapi-client.test.ts @@ -4,14 +4,14 @@ */ import { OCAPIClient } from '../src/clients/ocapi-client.js'; -import { TokenManager } from '../src/auth/oauth-token.js'; +import { TokenManager } from '../src/clients/base/oauth-token.js'; import { OCAPIConfig } from '../src/types/types.js'; // Mock fetch globally global.fetch = jest.fn(); // Mock TokenManager -jest.mock('../src/auth/oauth-token.js'); +jest.mock('../src/clients/base/oauth-token.js'); // Mock the specialized clients jest.mock('../src/clients/ocapi/system-objects-client.js'); From c71daf9cc5626ef077aa37e9be77304862e6dd46 Mon Sep 17 00:00:00 2001 From: Thomas Theunen Date: Wed, 13 Aug 2025 10:51:16 +0200 Subject: [PATCH 3/3] Maintenance: Uses direct property assignment Refactors code to directly assign the baseUrl property, instead of using a type assertion and assignment to 'any'. This improves code clarity and avoids potential type-related issues. --- src/clients/ocapi/catalog-client.ts | 2 +- src/clients/ocapi/site-preferences-client.ts | 2 +- src/clients/ocapi/system-objects-client.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/clients/ocapi/catalog-client.ts b/src/clients/ocapi/catalog-client.ts index c5bc5b9..5f3a52a 100644 --- a/src/clients/ocapi/catalog-client.ts +++ b/src/clients/ocapi/catalog-client.ts @@ -54,7 +54,7 @@ export class OCAPICatalogClient extends OCAPIAuthClient { super(config); // Override the baseUrl for this specialized client - (this as any).baseUrl = baseUrl; + this.baseUrl = baseUrl; } /** diff --git a/src/clients/ocapi/site-preferences-client.ts b/src/clients/ocapi/site-preferences-client.ts index 045e774..d9f9572 100644 --- a/src/clients/ocapi/site-preferences-client.ts +++ b/src/clients/ocapi/site-preferences-client.ts @@ -63,7 +63,7 @@ export class OCAPISitePreferencesClient extends OCAPIAuthClient { super(config); // Override the baseUrl for this specialized client - (this as any).baseUrl = baseUrl; + this.baseUrl = baseUrl; } /** diff --git a/src/clients/ocapi/system-objects-client.ts b/src/clients/ocapi/system-objects-client.ts index 81fdf30..fe60b7d 100644 --- a/src/clients/ocapi/system-objects-client.ts +++ b/src/clients/ocapi/system-objects-client.ts @@ -64,7 +64,7 @@ export class OCAPISystemObjectsClient extends OCAPIAuthClient { super(config); // Override the baseUrl for this specialized client - (this as any).baseUrl = baseUrl; + this.baseUrl = baseUrl; } /**