diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 72bf1d7..b65af6c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -53,12 +53,18 @@ 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 +│ │ │ └── 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 +│ │ │ └── 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 │ ├── config/ # Configuration management │ │ ├── config.ts # Configuration loading and validation │ │ ├── configuration-factory.ts # Config factory for different modes @@ -67,6 +73,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,16 +106,22 @@ 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 -#### **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 +##### **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 +- **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 #### **Configuration Management** (`config/`) - **Configuration Factory** (`configuration-factory.ts`): Creates configurations for different modes @@ -234,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/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/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 new file mode 100644 index 0000000..d9e2ab5 --- /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 './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..5f3a52a --- /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.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..d9f9572 --- /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.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..fe60b7d --- /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.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/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/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/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 new file mode 100644 index 0000000..0bdd016 --- /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/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/clients/base/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..4521f99 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/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'); +jest.mock('../src/clients/base/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'); + }); + }); +});