diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 994fd9df..b592dfa2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -85,6 +85,7 @@ const guideSidebar = [ {text: 'SLAS Commands', link: '/cli/slas'}, {text: 'Custom APIs', link: '/cli/custom-apis'}, {text: 'SCAPI Schemas', link: '/cli/scapi-schemas'}, + {text: 'Granular Replications', link: '/cli/replications'}, {text: 'Setup Commands', link: '/cli/setup'}, {text: 'Scaffold Commands', link: '/cli/scaffold'}, {text: 'Docs Commands', link: '/cli/docs'}, diff --git a/docs/cli/replications.md b/docs/cli/replications.md new file mode 100644 index 00000000..a1ab3b37 --- /dev/null +++ b/docs/cli/replications.md @@ -0,0 +1,380 @@ +--- +description: Commands for publishing individual items from staging to production using Granular Replications API. +--- + +# Granular Replications Commands + +Commands for publishing individual items (products, price tables, content assets) from staging to production environments. Provides fine-grained control over what gets replicated, rather than full-site replication. + +## Global Granular Replications Flags + +These flags are available on all Granular Replications commands: + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--tenant-id` | `SFCC_TENANT_ID` | (Required) Organization/tenant ID | +| `--short-code` | `SFCC_SHORTCODE` | SCAPI short code | + +## Authentication + +Granular Replications commands require an Account Manager API Client with OAuth credentials. + +### Required Scopes + +The following scopes are automatically requested by the CLI: + +| Scope | Description | +|-------|-------------| +| `sfcc.granular-replications.rw` | Read and write access to Granular Replications API | +| `SALESFORCE_COMMERCE_API:` | Tenant-specific access scope | + +### Configuration + +```bash +# Set credentials via environment variables +export SFCC_CLIENT_ID=my-client +export SFCC_CLIENT_SECRET=my-secret +export SFCC_TENANT_ID=zzxy_stg +export SFCC_SHORTCODE=kv7kzm78 + +# Or provide via flags +b2c scapi replications list --client-id xxx --client-secret xxx --tenant-id zzxy_stg +``` + +For complete setup instructions, see the [Authentication Guide](/guide/authentication#scapi-authentication). + +### Prerequisites + +**Enterprise Feature:** Granular replications is an enterprise feature that requires: +- Permanent staging and production infrastructure +- Feature flag enabled for your organization +- Contact your Salesforce account team if you receive a 403 error + +**Staging Only:** All granular replication operations must be executed from **staging instances**. Publishing from production instances will return a 422 error. + +**API Limits:** Maximum 50 items can be queued per publish operation. + +--- + +## b2c scapi replications list + +List granular replication processes with optional pagination and filtering. + +### Usage + +```bash +b2c scapi replications list --tenant-id +``` + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--limit`, `-l` | Maximum number of results | `20` | +| `--offset`, `-o` | Result offset for pagination | `0` | +| `--columns`, `-c` | Columns to display (comma-separated) | | +| `--extended`, `-x` | Show all columns | `false` | +| `--json` | Output results as JSON | `false` | + +### Available Columns + +Default columns: `id`, `status`, `entityType`, `entityId`, `startTime` + +Extended columns (shown with `--extended`): `endTime`, `initiatedBy` + +### Examples + +```bash +# List recent replication processes +b2c scapi replications list --tenant-id zzxy_stg + +# Show more results +b2c scapi replications list --tenant-id zzxy_stg --limit 50 + +# Show all columns +b2c scapi replications list --tenant-id zzxy_stg --extended + +# Custom columns +b2c scapi replications list --tenant-id zzxy_stg --columns id,status,entityType + +# Output as JSON +b2c scapi replications list --tenant-id zzxy_stg --json +``` + +### Output + +Default table output: + +``` +ID Status Entity Type Entity ID Started +───────────────────────────────────────────────────────────────────────────────────── +xmRhi7394HymoeRkfwAAAZeg3W completed Product PROD-123 2025-02-01... +ymSij8405IznpfSlgxBBBAfh4X in_progress Price Table usd-list-prices 2025-02-01... + +Total: 2 processes +``` + +--- + +## b2c scapi replications get + +Get details of a specific replication process by ID. + +### Usage + +```bash +b2c scapi replications get --tenant-id +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `process-id` | Publish process ID | Yes | + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Get process details +b2c scapi replications get xmRhi7394HymoeRkfwAAAZeg3WiM --tenant-id zzxy_stg + +# Output as JSON +b2c scapi replications get xmRhi7394HymoeRkfwAAAZeg3WiM --tenant-id zzxy_stg --json +``` + +### Output + +Default formatted output: + +``` +Replication Process Details +──────────────────────────────────────────────────────── +ID: xmRhi7394HymoeRkfwAAAZeg3WiM +Status: completed +Started: 2025-02-01T10:30:00Z +Completed: 2025-02-01T10:35:00Z +Initiated By: admin@example.com + +Entity: Product +Product ID: PROD-123 +``` + +For content assets, also shows: +``` +Entity: Content Asset +Content ID: hero-banner +Type: private +Site ID: RefArch +``` + +--- + +## b2c scapi replications publish + +Queue an item for granular replication (publish to production). + +### Usage + +```bash +# Publish a product +b2c scapi replications publish --tenant-id --product-id + +# Publish a price table +b2c scapi replications publish --tenant-id --price-table-id + +# Publish private content asset +b2c scapi replications publish --tenant-id \ + --content-id --content-type private --site-id + +# Publish shared content asset +b2c scapi replications publish --tenant-id \ + --content-id --content-type shared --library-id +``` + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Required | +|------|-------------|----------| +| `--tenant-id` | Organization/tenant ID | Yes | +| `--product-id` | Product ID (SKU) to publish | * | +| `--price-table-id` | Price table ID to publish | * | +| `--content-id` | Content asset ID to publish | * | +| `--content-type` | Content asset library type (`private` or `shared`) | If using `--content-id` | +| `--site-id` | Site ID (required for private content assets) | If `--content-type` is `private` | +| `--library-id` | Library ID (required for shared content assets) | If `--content-type` is `shared` | +| `--json` | Output results as JSON | No | + +\* Must specify exactly one of: `--product-id`, `--price-table-id`, or `--content-id` + +### Entity Types + +**Products:** Publishes a single product by SKU from staging to production. + +**Price Tables:** Publishes a price table (continuously valid period) from staging to production. + +**Content Assets (Private):** Publishes content from a site-specific private library. Requires `--site-id`. + +**Content Assets (Shared):** Publishes content from a shared library. Requires `--library-id`. + +### Examples + +```bash +# Publish a product +b2c scapi replications publish --tenant-id zzxy_stg --product-id PROD-123 + +# Publish a price table +b2c scapi replications publish --tenant-id zzxy_stg --price-table-id usd-list-prices + +# Publish content asset from private library +b2c scapi replications publish --tenant-id zzxy_stg \ + --content-id hero-banner --content-type private --site-id RefArch + +# Publish content asset from shared library +b2c scapi replications publish --tenant-id zzxy_stg \ + --content-id footer-links --content-type shared --library-id SharedLibrary + +# Output as JSON +b2c scapi replications publish --tenant-id zzxy_stg --product-id PROD-123 --json +``` + +### Output + +Returns the process ID for tracking: + +``` +Item queued for publishing. Process ID: xmRhi7394HymoeRkfwAAAZeg3WiM +``` + +--- + +## b2c scapi replications wait + +Wait for a granular replication process to complete by polling its status. + +### Usage + +```bash +b2c scapi replications wait --tenant-id +``` + +### Arguments + +| Argument | Description | Required | +|----------|-------------|----------| +| `process-id` | Publish process ID returned by `publish` command | Yes | + +### Flags + +In addition to [global flags](./index#global-flags): + +| Flag | Description | Default | +|------|-------------|---------| +| `--tenant-id` | (Required) Organization/tenant ID | | +| `--timeout`, `-t` | Timeout in seconds | `300` | +| `--interval`, `-i` | Poll interval in seconds | `5` | +| `--json` | Output results as JSON | `false` | + +### Examples + +```bash +# Wait with default settings (5 min timeout, 5 sec interval) +b2c scapi replications wait xmRhi7394HymoeRkfwAAAZeg3WiM --tenant-id zzxy_stg + +# Custom timeout and interval +b2c scapi replications wait xmRhi7394HymoeRkfwAAAZeg3WiM \ + --tenant-id zzxy_stg --timeout 600 --interval 10 + +# Output as JSON +b2c scapi replications wait xmRhi7394HymoeRkfwAAAZeg3WiM \ + --tenant-id zzxy_stg --json +``` + +### Output + +Shows status updates during polling: + +``` +Status: in_progress +Status: in_progress +Status: completed +Process completed successfully +``` + +Returns the final process details when completed or failed. + +--- + +## Common Workflows + +### Complete Product Publishing Workflow + +```bash +# 1. Queue product for publishing +PROCESS_ID=$(b2c scapi replications publish \ + --tenant-id zzxy_stg \ + --product-id PROD-123 \ + --json | jq -r '.id') + +# 2. Wait for completion +b2c scapi replications wait $PROCESS_ID --tenant-id zzxy_stg + +# 3. Verify on production instance +# Log into production and confirm PROD-123 was replicated +``` + +### Publishing Multiple Content Assets + +```bash +# Publish hero banner (private library) +b2c scapi replications publish --tenant-id zzxy_stg \ + --content-id hero-banner --content-type private --site-id RefArch + +# Publish footer links (shared library) +b2c scapi replications publish --tenant-id zzxy_stg \ + --content-id footer-links --content-type shared --library-id SharedLibrary + +# List all processes +b2c scapi replications list --tenant-id zzxy_stg +``` + +### Error Handling + +Common errors you may encounter: + +**403 Forbidden - Feature Not Enabled** +``` +Error: Feature not enabled for this organization +``` +**Solution:** Contact Salesforce support to enable granular replications for your organization. + +**422 Unprocessable Entity - Not on Staging** +``` +Error: Granular replication can only be initiated from staging instances +``` +**Solution:** Ensure you're running commands from a staging instance, not production. + +**409 Conflict - Replication Running** +``` +Error: Cannot queue items while full replication is running +``` +**Solution:** Wait for full site replication to complete before queuing items. + +**404 Not Found - Invalid Entity** +``` +Error: Product not found +``` +**Solution:** Verify the entity ID exists on your staging instance. diff --git a/packages/b2c-cli/src/commands/scapi/granular-replications-command.ts b/packages/b2c-cli/src/commands/scapi/granular-replications-command.ts new file mode 100644 index 00000000..4eec6e3a --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/granular-replications-command.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Command} from '@oclif/core'; +import {OAuthCommand} from '@salesforce/b2c-tooling-sdk/cli'; +import { + createGranularReplicationsClient, + type GranularReplicationsClient, + toOrganizationId, +} from '@salesforce/b2c-tooling-sdk'; +import {t} from '../../i18n/index.js'; + +/** + * Base command class for Granular Replications API commands. + * + * Provides lazy-initialized client with proper OAuth strategy and configuration. + */ +export abstract class GranularReplicationsCommand extends OAuthCommand { + private _granularReplicationsClient?: GranularReplicationsClient; + + /** + * Gets or creates a Granular Replications API client. + * + * Requires: + * - shortCode configuration (via b2c config:set --short-code ) + * - OAuth credentials + * - organizationId (extracted from tenant-id) + */ + protected get granularReplicationsClient(): GranularReplicationsClient { + if (!this._granularReplicationsClient) { + const shortCode = this.resolvedConfig.values.shortCode; + const organizationId = this.getOrganizationId(); + + if (!shortCode) { + this.error('shortCode configuration is required. Run: b2c config:set --short-code '); + } + + this._granularReplicationsClient = createGranularReplicationsClient( + {shortCode, organizationId}, + this.getOAuthStrategy(), + ); + } + return this._granularReplicationsClient; + } + + /** + * Get the organization ID from resolved config. + * @throws Error if tenant ID is not provided through any source + */ + protected getOrganizationId(): string { + const {tenantId} = this.resolvedConfig.values; + if (!tenantId) { + this.error( + t( + 'error.tenantIdRequired', + 'tenant-id is required. Provide via --tenant-id flag, SFCC_TENANT_ID env var, or tenant-id in dw.json.', + ), + ); + } + return toOrganizationId(tenantId); + } +} diff --git a/packages/b2c-cli/src/commands/scapi/replications/get.ts b/packages/b2c-cli/src/commands/scapi/replications/get.ts new file mode 100644 index 00000000..d4ee2c54 --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/replications/get.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, ux} from '@oclif/core'; +import cliui from 'cliui'; +import {GranularReplicationsCommand} from '../granular-replications-command.js'; +import {getApiErrorMessage, type PublishProcessResponse} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class ReplicationsGet extends GranularReplicationsCommand { + static args = { + 'process-id': Args.string({ + description: 'Publish process ID', + required: true, + }), + }; + + static description = withDocs( + t('commands.replications.get.description', 'Get granular replication process details'), + '/cli/replications.html#get', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> xmRhi7394HymoeRkfwAAAZeg3WiM', + ]; + + async run(): Promise { + this.requireOAuthCredentials(); + + const processId = this.args['process-id']; + const organizationId = this.getOrganizationId(); + + const result = await this.granularReplicationsClient.GET( + '/organizations/{organizationId}/granular-processes/{id}', + { + params: { + path: {organizationId, id: processId}, + }, + }, + ); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.replications.get.error', 'Failed to get replication process: {{message}}', {message})); + } + + if (this.jsonEnabled()) return result.data; + + this.printProcessDetails(result.data); + return result.data; + } + + private printProcessDetails(processData: PublishProcessResponse): void { + const ui = cliui({width: process.stdout.columns || 80}); + + ui.div({text: 'Replication Process Details', padding: [1, 0, 0, 0]}); + ui.div('─'.repeat(process.stdout.columns || 80)); + ui.div({text: `ID: ${processData.id}`, padding: [1, 0, 0, 0]}); + ui.div({text: `Status: ${processData.status}`}); + ui.div({text: `Started: ${processData.startTime}`}); + if (processData.endTime) ui.div({text: `Completed: ${processData.endTime}`}); + ui.div({text: `Initiated By: ${processData.initiatedBy}`}); + + if (processData.productItem) { + ui.div({text: '\nEntity: Product', padding: [1, 0, 0, 0]}); + ui.div({text: `Product ID: ${processData.productItem.productId}`}); + } else if (processData.priceTableItem) { + ui.div({text: '\nEntity: Price Table', padding: [1, 0, 0, 0]}); + ui.div({text: `Price Table ID: ${processData.priceTableItem.priceTableId}`}); + } else if (processData.contentAssetItem) { + ui.div({text: '\nEntity: Content Asset', padding: [1, 0, 0, 0]}); + ui.div({text: `Content ID: ${processData.contentAssetItem.contentId}`}); + ui.div({text: `Type: ${processData.contentAssetItem.type}`}); + if (processData.contentAssetItem.type === 'private') { + ui.div({text: `Site ID: ${processData.contentAssetItem.siteId}`}); + } + if (processData.contentAssetItem.type === 'shared') { + ui.div({text: `Library ID: ${processData.contentAssetItem.libraryId}`}); + } + } + + ux.stdout(ui.toString()); + } +} diff --git a/packages/b2c-cli/src/commands/scapi/replications/list.ts b/packages/b2c-cli/src/commands/scapi/replications/list.ts new file mode 100644 index 00000000..d618ac3b --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/replications/list.ts @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {GranularReplicationsCommand} from '../granular-replications-command.js'; +import {TableRenderer, type ColumnDef} from '@salesforce/b2c-tooling-sdk/cli'; +import { + getApiErrorMessage, + type PublishProcessListResponse, + type PublishProcessResponse, +} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +const COLUMNS: Record> = { + id: {header: 'ID', get: (p) => p.id || '-'}, + status: {header: 'Status', get: (p) => p.status || '-'}, + startTime: {header: 'Started', get: (p) => p.startTime || '-'}, + endTime: {header: 'Completed', get: (p) => p.endTime || '-'}, + initiatedBy: {header: 'Initiated By', get: (p) => p.initiatedBy || '-'}, + entityType: { + header: 'Entity Type', + get(p) { + if (p.productItem) return 'Product'; + if (p.priceTableItem) return 'Price Table'; + if (p.contentAssetItem) return 'Content Asset'; + return '-'; + }, + }, + entityId: { + header: 'Entity ID', + get(p) { + if (p.productItem) return p.productItem.productId; + if (p.priceTableItem) return p.priceTableItem.priceTableId; + if (p.contentAssetItem) return p.contentAssetItem.contentId; + return '-'; + }, + }, +}; + +const DEFAULT_COLUMNS = ['id', 'status', 'entityType', 'entityId', 'startTime']; + +export default class ReplicationsList extends GranularReplicationsCommand { + static description = withDocs( + t('commands.replications.list.description', 'List granular replication processes'), + '/cli/replications.html#list', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --limit 50', + '<%= config.bin %> <%= command.id %> --extended', + ]; + + static flags = { + limit: Flags.integer({ + char: 'l', + description: 'Maximum number of results', + default: 20, + }), + offset: Flags.integer({ + char: 'o', + description: 'Result offset for pagination', + default: 0, + }), + columns: Flags.string({ + char: 'c', + description: 'Columns to display (comma-separated)', + }), + extended: Flags.boolean({ + char: 'x', + description: 'Show all columns', + default: false, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const {limit, offset} = this.flags; + const organizationId = this.getOrganizationId(); + + const result = await this.granularReplicationsClient.GET('/organizations/{organizationId}/granular-processes', { + params: { + path: {organizationId}, + query: {limit, offset}, + }, + }); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.replications.list.error', 'Failed to list replication processes: {{message}}', {message})); + } + + if (this.jsonEnabled()) return result.data; + + const processes = result.data.data || []; + const columns = this.getSelectedColumns(); + const tableRenderer = new TableRenderer(COLUMNS); + tableRenderer.render(processes, columns); + + this.log(t('commands.replications.list.total', '\nTotal: {{total}} processes', {total: result.data.total})); + + return result.data; + } + + /** + * Determines which columns to display based on flags. + */ + private getSelectedColumns(): string[] { + const columnsFlag = this.flags.columns; + const extended = this.flags.extended; + + if (columnsFlag) { + // User specified explicit columns + return columnsFlag.split(',').map((c) => c.trim()); + } + + if (extended) { + // Show all columns + return Object.keys(COLUMNS); + } + + // Show default columns + return DEFAULT_COLUMNS; + } +} diff --git a/packages/b2c-cli/src/commands/scapi/replications/publish.ts b/packages/b2c-cli/src/commands/scapi/replications/publish.ts new file mode 100644 index 00000000..95b445df --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/replications/publish.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Flags} from '@oclif/core'; +import {GranularReplicationsCommand} from '../granular-replications-command.js'; +import {getApiErrorMessage, type PublishIdResponse} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class ReplicationsPublish extends GranularReplicationsCommand { + static description = withDocs( + t('commands.replications.publish.description', 'Queue an item for granular replication (publish to production)'), + '/cli/replications.html#publish', + ); + + static enableJsonFlag = true; + + static examples = [ + '# Publish a product', + '<%= config.bin %> <%= command.id %> --product-id PROD-123', + '', + '# Publish a price table', + '<%= config.bin %> <%= command.id %> --price-table-id usd-list-prices', + '', + '# Publish content asset from private library', + '<%= config.bin %> <%= command.id %> --content-id hero-banner --content-type private --site-id RefArch', + '', + '# Publish content asset from shared library', + '<%= config.bin %> <%= command.id %> --content-id footer-links --content-type shared --library-id SharedLibrary', + ]; + + static flags = { + 'product-id': Flags.string({ + description: 'Product ID (SKU) to publish', + exclusive: ['price-table-id', 'content-id'], + }), + 'price-table-id': Flags.string({ + description: 'Price table ID to publish', + exclusive: ['product-id', 'content-id'], + }), + 'content-id': Flags.string({ + description: 'Content asset ID to publish', + exclusive: ['product-id', 'price-table-id'], + dependsOn: ['content-type'], + }), + 'content-type': Flags.string({ + description: 'Content asset library type', + options: ['private', 'shared'], + dependsOn: ['content-id'], + }), + 'site-id': Flags.string({ + description: 'Site ID (required for private content assets)', + }), + 'library-id': Flags.string({ + description: 'Library ID (required for shared content assets)', + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const { + 'product-id': productId, + 'price-table-id': priceTableId, + 'content-id': contentId, + 'content-type': contentType, + 'site-id': siteId, + 'library-id': libraryId, + } = this.flags; + + // Validate entity type provided + if (!productId && !priceTableId && !contentId) { + this.error( + t('commands.replications.publish.no-entity', 'Must specify --product-id, --price-table-id, or --content-id'), + ); + } + + // Build request body based on entity type + type PublishBody = + | {contentAsset: {contentId: string; type: 'private'; siteId: string}} + | {contentAsset: {contentId: string; type: 'shared'; libraryId: string}} + | {priceTable: {priceTableId: string}} + | {product: {productId: string}}; + + let body: PublishBody | undefined; + + if (productId) { + body = {product: {productId}}; + } else if (priceTableId) { + body = {priceTable: {priceTableId}}; + } else if (contentId) { + if (contentType === 'private') { + if (!siteId) { + this.error(t('commands.replications.publish.site-required', '--site-id required for private content assets')); + } + body = {contentAsset: {contentId, type: 'private', siteId}}; + } else if (contentType === 'shared') { + if (!libraryId) { + this.error( + t('commands.replications.publish.library-required', '--library-id required for shared content assets'), + ); + } + body = {contentAsset: {contentId, type: 'shared', libraryId}}; + } + } + + if (!body) { + this.error( + t('commands.replications.publish.no-entity', 'Must specify --product-id, --price-table-id, or --content-id'), + ); + } + + const organizationId = this.getOrganizationId(); + + const result = await this.granularReplicationsClient.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId}}, + body, + }); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error( + t('commands.replications.publish.error', 'Failed to queue item for publishing: {{message}}', {message}), + ); + } + + if (!this.jsonEnabled()) { + this.log( + t('commands.replications.publish.success', 'Item queued for publishing. Process ID: {{id}}', { + id: result.data.id, + }), + ); + } + + return result.data; + } +} diff --git a/packages/b2c-cli/src/commands/scapi/replications/wait.ts b/packages/b2c-cli/src/commands/scapi/replications/wait.ts new file mode 100644 index 00000000..7a57b643 --- /dev/null +++ b/packages/b2c-cli/src/commands/scapi/replications/wait.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {Args, Flags} from '@oclif/core'; +import {GranularReplicationsCommand} from '../granular-replications-command.js'; +import {getApiErrorMessage, type PublishProcessResponse} from '@salesforce/b2c-tooling-sdk'; +import {t, withDocs} from '../../../i18n/index.js'; + +export default class ReplicationsWait extends GranularReplicationsCommand { + static args = { + 'process-id': Args.string({ + description: 'Publish process ID', + required: true, + }), + }; + + static description = withDocs( + t('commands.replications.wait.description', 'Wait for a granular replication process to complete'), + '/cli/replications.html#wait', + ); + + static enableJsonFlag = true; + + static examples = [ + '<%= config.bin %> <%= command.id %> ', + '<%= config.bin %> <%= command.id %> xmRhi7394HymoeRkfwAAAZeg3WiM --timeout 600', + '<%= config.bin %> <%= command.id %> xmRhi7394HymoeRkfwAAAZeg3WiM --interval 10', + ]; + + static flags = { + timeout: Flags.integer({ + char: 't', + description: 'Timeout in seconds (default: 300)', + default: 300, + }), + interval: Flags.integer({ + char: 'i', + description: 'Poll interval in seconds (default: 5)', + default: 5, + }), + }; + + async run(): Promise { + this.requireOAuthCredentials(); + + const processId = this.args['process-id']; + const {timeout, interval} = this.flags; + const organizationId = this.getOrganizationId(); + + const startTime = Date.now(); + const timeoutMs = timeout * 1000; + const intervalMs = interval * 1000; + + while (Date.now() - startTime < timeoutMs) { + // eslint-disable-next-line no-await-in-loop + const result = await this.granularReplicationsClient.GET( + '/organizations/{organizationId}/granular-processes/{id}', + { + params: {path: {organizationId, id: processId}}, + }, + ); + + if (!result.data) { + const message = getApiErrorMessage(result.error, result.response); + this.error(t('commands.replications.wait.error', 'Failed to get process status: {{message}}', {message})); + } + + const status = result.data.status; + + if (!this.jsonEnabled()) { + this.log(t('commands.replications.wait.checking', 'Status: {{status}}', {status})); + } + + if (status === 'completed') { + if (!this.jsonEnabled()) { + this.log(t('commands.replications.wait.completed', 'Process completed successfully')); + } + return result.data; + } + + if (status === 'failed') { + if (!this.jsonEnabled()) { + this.log(t('commands.replications.wait.failed', 'Process failed')); + } + return result.data; + } + + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + + this.error(t('commands.replications.wait.timeout', 'Timeout waiting for process to complete')); + } +} diff --git a/packages/b2c-cli/src/i18n/locales/en.ts b/packages/b2c-cli/src/i18n/locales/en.ts index fe4a578e..5a687a46 100644 --- a/packages/b2c-cli/src/i18n/locales/en.ts +++ b/packages/b2c-cli/src/i18n/locales/en.ts @@ -149,6 +149,33 @@ export const en = { ideNotes: 'See IDE documentation for skill configuration:', }, }, + replications: { + list: { + description: 'List granular replication processes', + error: 'Failed to list replication processes: {{message}}', + total: '\nTotal: {{total}} processes', + }, + get: { + description: 'Get granular replication process details', + error: 'Failed to get replication process: {{message}}', + }, + publish: { + description: 'Queue an item for granular replication (publish to production)', + 'no-entity': 'Must specify --product-id, --price-table-id, or --content-id', + 'site-required': '--site-id required for private content assets', + 'library-required': '--library-id required for shared content assets', + success: 'Item queued for publishing. Process ID: {{id}}', + error: 'Failed to queue item for publishing: {{message}}', + }, + wait: { + description: 'Wait for a granular replication process to complete', + checking: 'Status: {{status}}', + completed: 'Process completed successfully', + failed: 'Process failed', + timeout: 'Timeout waiting for process to complete', + error: 'Failed to get process status: {{message}}', + }, + }, scaffold: { list: { description: 'List available project scaffolds', diff --git a/packages/b2c-cli/test/commands/scapi/replications/get.test.ts b/packages/b2c-cli/test/commands/scapi/replications/get.test.ts new file mode 100644 index 00000000..feb7b419 --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/replications/get.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ReplicationsGet from '../../../../src/commands/scapi/replications/get.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; +import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; + +describe('scapi replications get', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + describe('run', () => { + let config: Config; + + beforeEach(async () => { + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns process details in JSON mode', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'proc-123'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-123', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + endTime: '2025-01-01T00:05:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + expect(result.id).to.equal('proc-123'); + expect(result.status).to.equal('completed'); + expect(result.productItem?.productId).to.equal('PROD-1'); + }); + + it('displays process details in non-JSON mode', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'proc-456'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-456', + status: 'in_progress', + startTime: '2025-01-01T01:00:00Z', + initiatedBy: 'admin@example.com', + priceTableItem: {priceTableId: 'usd-list-prices'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = (await runSilent(() => command.run())) as {id: string; status: string}; + expect(result.id).to.equal('proc-456'); + expect(result.status).to.equal('in_progress'); + }); + + it('displays private content asset details', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'proc-789'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-789', + status: 'completed', + startTime: '2025-01-01T02:00:00Z', + endTime: '2025-01-01T02:03:00Z', + initiatedBy: 'user@example.com', + contentAssetItem: {contentId: 'hero-banner', type: 'private', siteId: 'RefArch'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = (await runSilent(() => command.run())) as any; + expect(result.contentAssetItem?.contentId).to.equal('hero-banner'); + expect(result.contentAssetItem?.type).to.equal('private'); + expect(result.contentAssetItem?.siteId).to.equal('RefArch'); + }); + + it('displays shared content asset details', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'proc-abc'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-abc', + status: 'completed', + startTime: '2025-01-01T03:00:00Z', + endTime: '2025-01-01T03:02:00Z', + initiatedBy: 'user@example.com', + contentAssetItem: {contentId: 'footer-links', type: 'shared', libraryId: 'SharedLibrary'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = (await runSilent(() => command.run())) as any; + expect(result.contentAssetItem?.contentId).to.equal('footer-links'); + expect(result.contentAssetItem?.type).to.equal('shared'); + expect(result.contentAssetItem?.libraryId).to.equal('SharedLibrary'); + }); + + it('handles 404 for nonexistent process', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'invalid'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Not Found', detail: 'Process not found'}), { + status: 404, + headers: {'content-type': 'application/json'}, + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('handles API errors', async () => { + const command: any = new ReplicationsGet([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {'process-id': 'proc-123'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Forbidden', detail: 'Feature not enabled'}), { + status: 403, + headers: {'content-type': 'application/json'}, + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/replications/list.test.ts b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts new file mode 100644 index 00000000..ca1f4333 --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/replications/list.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ReplicationsList from '../../../../src/commands/scapi/replications/list.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; +import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; + +describe('scapi replications list', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + describe('run', () => { + let config: Config; + + beforeEach(async () => { + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('returns processes in JSON mode', async () => { + const command: any = new ReplicationsList([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + total: 2, + data: [ + { + id: 'proc-1', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }, + { + id: 'proc-2', + status: 'in_progress', + startTime: '2025-01-01T01:00:00Z', + initiatedBy: 'user@example.com', + priceTableItem: {priceTableId: 'table-1'}, + }, + ], + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + expect(result.total).to.equal(2); + expect(result.data).to.have.lengthOf(2); + expect(result.data[0].id).to.equal('proc-1'); + }); + + it('displays processes in non-JSON mode', async () => { + const command: any = new ReplicationsList([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + total: 1, + data: [ + { + id: 'proc-1', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }, + ], + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = (await runSilent(() => command.run())) as {total: number}; + expect(result.total).to.equal(1); + }); + + it('handles empty result set', async () => { + const command: any = new ReplicationsList([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({total: 0, data: []}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(result.total).to.equal(0); + expect(result.data).to.have.lengthOf(0); + }); + + it('handles API errors', async () => { + const command: any = new ReplicationsList([], config); + stubParse(command, {'tenant-id': 'zzxy_prd'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Forbidden', detail: 'Feature not enabled'}), { + status: 403, + headers: {'content-type': 'application/json'}, + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts b/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts new file mode 100644 index 00000000..ef56c37d --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/replications/publish.test.ts @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ReplicationsPublish from '../../../../src/commands/scapi/replications/publish.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; +import {createIsolatedEnvHooks} from '../../../helpers/test-setup.js'; + +describe('scapi replications publish', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + describe('run', () => { + let config: Config; + + beforeEach(async () => { + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('publishes product in JSON mode', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'product-id': 'PROD-123'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({id: 'proc-123'}), { + status: 201, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(result.id).to.equal('proc-123'); + }); + + it('publishes price table', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'price-table-id': 'usd-list-prices'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({id: 'proc-456'}), { + status: 201, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(result.id).to.equal('proc-456'); + }); + + it('publishes private content asset', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse( + command, + {'tenant-id': 'zzxy_prd', 'content-id': 'hero-banner', 'content-type': 'private', 'site-id': 'RefArch'}, + {}, + ); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({id: 'proc-789'}), { + status: 201, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(result.id).to.equal('proc-789'); + }); + + it('publishes shared content asset', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse( + command, + { + 'tenant-id': 'zzxy_prd', + 'content-id': 'footer-links', + 'content-type': 'shared', + 'library-id': 'SharedLibrary', + }, + {}, + ); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({id: 'proc-abc'}), { + status: 201, + headers: {'content-type': 'application/json'}, + }), + ); + + const result = await command.run(); + expect(result.id).to.equal('proc-abc'); + }); + + it('displays success message in non-JSON mode', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'product-id': 'PROD-123'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + const logStub = sinon.stub(command, 'log'); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({id: 'proc-xyz'}), { + status: 201, + headers: {'content-type': 'application/json'}, + }), + ); + + await command.run(); + expect(logStub.calledOnce).to.equal(true); + expect(logStub.firstCall.args[0]).to.include('proc-xyz'); + }); + + it('requires site-id for private content assets', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'content-id': 'hero', 'content-type': 'private'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('site-id'); + } + }); + + it('requires library-id for shared content assets', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'content-id': 'footer', 'content-type': 'shared'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + expect(errorStub.firstCall.args[0]).to.include('library-id'); + } + }); + + it('handles 422 error when not on staging', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'product-id': 'PROD-123'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + title: 'Unprocessable Entity', + detail: 'Granular replication can only be initiated from staging instances', + }), + {status: 422, headers: {'content-type': 'application/json'}}, + ), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('handles 409 conflict during replication', async () => { + const command: any = new ReplicationsPublish([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', 'product-id': 'PROD-123'}, {}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon + .stub(globalThis, 'fetch') + .resolves( + new Response( + JSON.stringify({title: 'Conflict', detail: 'Cannot queue items while full replication is running'}), + {status: 409, headers: {'content-type': 'application/json'}}, + ), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + }); +}); diff --git a/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts b/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts new file mode 100644 index 00000000..4f92118f --- /dev/null +++ b/packages/b2c-cli/test/commands/scapi/replications/wait.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import sinon from 'sinon'; +import {Config} from '@oclif/core'; +import ReplicationsWait from '../../../../src/commands/scapi/replications/wait.js'; +import {stubParse} from '../../../helpers/stub-parse.js'; +import {createIsolatedEnvHooks, runSilent} from '../../../helpers/test-setup.js'; + +describe('scapi replications wait', () => { + const hooks = createIsolatedEnvHooks(); + + beforeEach(hooks.beforeEach); + + afterEach(hooks.afterEach); + + describe('run', () => { + let config: Config; + + beforeEach(async () => { + config = await Config.load(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('waits for process to complete', async () => { + const command: any = new ReplicationsWait([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 10, interval: 1}, {'process-id': 'proc-123'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + let callCount = 0; + sinon.stub(globalThis, 'fetch').callsFake(async () => { + callCount++; + if (callCount === 1) { + return new Response( + JSON.stringify({ + id: 'proc-123', + status: 'in_progress', + startTime: '2025-01-01T00:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ); + } + return new Response( + JSON.stringify({ + id: 'proc-123', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + endTime: '2025-01-01T00:05:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ); + }); + + const result = await command.run(); + expect(result.id).to.equal('proc-123'); + expect(result.status).to.equal('completed'); + expect(callCount).to.be.greaterThan(1); + }); + + it('returns failed status', async () => { + const command: any = new ReplicationsWait([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 10, interval: 1}, {'process-id': 'proc-456'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-456', + status: 'failed', + startTime: '2025-01-01T01:00:00Z', + endTime: '2025-01-01T01:02:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-2'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + const result = await command.run(); + expect(result.status).to.equal('failed'); + }); + + it('logs status updates in non-JSON mode', async () => { + const command: any = new ReplicationsWait([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 10, interval: 1}, {'process-id': 'proc-789'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + const logStub = sinon.stub(command, 'log'); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + + sinon.stub(globalThis, 'fetch').resolves( + new Response( + JSON.stringify({ + id: 'proc-789', + status: 'completed', + startTime: '2025-01-01T02:00:00Z', + endTime: '2025-01-01T02:05:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-3'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ); + + await runSilent(() => command.run()); + expect(logStub.called).to.equal(true); + }); + + it('times out if process does not complete', async () => { + const command: any = new ReplicationsWait([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 0.1, interval: 0.05}, {'process-id': 'proc-timeout'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(false); + sinon.stub(command, 'log').returns(void 0); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Timeout error')); + + sinon.stub(globalThis, 'fetch').callsFake(async () => { + return new Response( + JSON.stringify({ + id: 'proc-timeout', + status: 'in_progress', + startTime: '2025-01-01T03:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-4'}, + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ); + }); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + + it('handles API errors during polling', async () => { + const command: any = new ReplicationsWait([], config); + stubParse(command, {'tenant-id': 'zzxy_prd', timeout: 10, interval: 1}, {'process-id': 'proc-error'}); + await command.init(); + + sinon.stub(command, 'requireOAuthCredentials').returns(void 0); + sinon.stub(command, 'jsonEnabled').returns(true); + sinon.stub(command, 'resolvedConfig').get(() => ({values: {shortCode: 'kv7kzm78', tenantId: 'zzxy_prd'}})); + sinon.stub(command, 'getOAuthStrategy').returns({getAuthorizationHeader: async () => 'Bearer test'}); + const errorStub = sinon.stub(command, 'error').throws(new Error('Expected error')); + + sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({title: 'Not Found', detail: 'Process not found'}), { + status: 404, + headers: {'content-type': 'application/json'}, + }), + ); + + try { + await command.run(); + expect.fail('Should have thrown'); + } catch { + expect(errorStub.calledOnce).to.equal(true); + } + }); + }); +}); diff --git a/packages/b2c-tooling-sdk/package.json b/packages/b2c-tooling-sdk/package.json index 6806d9c1..a920c1bd 100644 --- a/packages/b2c-tooling-sdk/package.json +++ b/packages/b2c-tooling-sdk/package.json @@ -309,7 +309,7 @@ "data" ], "scripts": { - "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts", + "generate:types": "openapi-typescript specs/data-api.json -o src/clients/ocapi.generated.ts && openapi-typescript specs/slas-admin-v1.yaml -o src/clients/slas-admin.generated.ts && openapi-typescript specs/ods-api-v1.json -o src/clients/ods.generated.ts && openapi-typescript specs/mrt-api-v1.json -o src/clients/mrt.generated.ts && openapi-typescript specs/mrt-b2c.json -o src/clients/mrt-b2c.generated.ts && openapi-typescript specs/custom-apis-v1.yaml -o src/clients/custom-apis.generated.ts && openapi-typescript specs/scapi-schemas-v1.yaml -o src/clients/scapi-schemas.generated.ts && openapi-typescript specs/cdn-zones-v1.yaml -o src/clients/cdn-zones.generated.ts && openapi-typescript specs/am-users-api-v1.yaml -o src/clients/am-users-api.generated.ts && openapi-typescript specs/am-roles-api-v1.yaml -o src/clients/am-roles-api.generated.ts && openapi-typescript specs/am-apiclients-api-v1.yaml -o src/clients/am-apiclients-api.generated.ts && openapi-typescript specs/granular-replications-v1.yaml -o src/clients/granular-replications.generated.ts", "build": "pnpm run generate:types && pnpm run build:esm && pnpm run build:cjs", "build:esm": "tsc -p tsconfig.esm.json", "build:cjs": "tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", diff --git a/packages/b2c-tooling-sdk/specs/granular-replications-v1.yaml b/packages/b2c-tooling-sdk/specs/granular-replications-v1.yaml new file mode 100644 index 00000000..e09436de --- /dev/null +++ b/packages/b2c-tooling-sdk/specs/granular-replications-v1.yaml @@ -0,0 +1,657 @@ +openapi: 3.0.0 +info: + title: Granular Replications API + version: '1.0' + description: |- + API for publishing individual items (products, price tables, content assets) from staging to production. + + ## Publish Process Status + 1. `pending`: Publish operation to run. + 2. `in_progress`: Publish operation is running. + 3. `completed`: Publish operation completed and the item was successfully published. + 4. `failed`: The publish operation completed, but an error was detected. + +servers: + - url: https://{shortCode}.api.commercecloud.salesforce.com/operation/replications/v1 + variables: + shortCode: + default: '123456gfp' + +paths: + /organizations/{organizationId}/granular-processes: + parameters: + - $ref: '#/components/parameters/organizationId' + get: + summary: Retrieve a list of all publish processes with pagination support. + operationId: listPublishProcesses + security: + - AmOAuth2: + - sfcc.granular-replications + - sfcc.granular-replications.rw + parameters: + - name: limit + in: query + schema: + type: integer + default: 20 + minimum: 1 + maximum: 200 + - name: offset + in: query + schema: + type: integer + default: 0 + minimum: 0 + responses: + '200': + description: Retrieved list of publish processes successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/PublishProcessListResponse' + '400': + $ref: '#/components/responses/400BadRequest_GetProcesses' + '403': + $ref: '#/components/responses/403Forbidden' + '422': + $ref: '#/components/responses/422UnprocessableContent' + post: + summary: Queue item for publishing + description: |- + Queues items, such as products, price tables, and content assets, for the upcoming publish operation. You can have multiple items in the queue, but you must send a separate request for each item. + + Note: The published price table is always the table with the continuously valid period. + + ## Publishing Limit + This endpoint is limited to 50 items per publish operation. The HTTP status code 409 (Conflict) is returned if the limit is exceeded. + operationId: publishItems + security: + - AmOAuth2: + - sfcc.granular-replications.rw + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PublishItemRequest' + examples: + productExample: + $ref: '#/components/examples/PostRequestProduct' + priceTableExample: + $ref: '#/components/examples/PostRequestPriceTable' + contentAssetExampleSharedLib: + $ref: '#/components/examples/PostRequestContentAssetSharedLib' + contentAssetExamplePrivateLib: + $ref: '#/components/examples/PostRequestContentAssetPrivateLib' + responses: + '201': + description: Item successfully queued for publishing + content: + application/json: + schema: + $ref: '#/components/schemas/PublishIdResponse' + examples: + success: + $ref: '#/components/examples/PublicationSuccessResponse' + '400': + $ref: '#/components/responses/400BadRequest_CreateProcess' + '403': + $ref: '#/components/responses/403Forbidden' + '404': + $ref: '#/components/responses/404NotFound_CreateProcess' + '409': + $ref: '#/components/responses/409Conflict_CreateProcess' + '422': + $ref: '#/components/responses/422UnprocessableContent_CreateProcess' + /organizations/{organizationId}/granular-processes/{id}: + parameters: + - $ref: '#/components/parameters/organizationId' + - $ref: '#/components/parameters/id' + get: + summary: Retrieve details of a specific publish process using its ID. + operationId: getPublishProcess + security: + - AmOAuth2: + - sfcc.granular-replications + - sfcc.granular-replications.rw + responses: + '200': + description: Publish process details retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/PublishProcessResponse' + examples: + productProcess: + $ref: '#/components/examples/ProductProcessResponse' + priceTableProcess: + $ref: '#/components/examples/PriceTableProcessResponse' + contentAssetProcess: + $ref: '#/components/examples/ContentAssetProcessResponse' + '403': + $ref: '#/components/responses/403Forbidden' + '404': + $ref: '#/components/responses/404NotFound_GetProcess' + +components: + securitySchemes: + AmOAuth2: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://account.demandware.com/dwsso/oauth2/access_token + scopes: + c.example: custom scopes must start with c. used for custom APIs [MAX length of scope 49 characters] + sfcc.example: standard salesforce commerce apis scopes must start with "sfcc.". Without suffix ".rw" scopes grants READONLY access. [MAX length of scope 49 characters] + sfcc.example.rw: salesforce commerce apis scopes must start with "sfcc.". The suffix ".rw" scopes grants READ and WRITE access. [MAX length of scope 49 characters] + + schemas: + OrganizationId: + description: An identifier for the organization the request is being made by + example: f_ecom_zzxy_prd + type: string + minLength: 1 + maxLength: 32 + + Total: + default: 0 + minimum: 0 + format: int32 + description: The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are paginated. + type: integer + example: 10 + + Offset: + default: 0 + minimum: 0 + format: int32 + description: The offset for the search results (pagination). + type: integer + example: 0 + + ResultBase: + description: Base type for results + type: object + properties: + total: + $ref: '#/components/schemas/Total' + limit: + type: integer + default: 20 + required: + - total + - limit + + PublishProcessListResponse: + description: Paginated list of publish processes + type: object + allOf: + - $ref: '#/components/schemas/ResultBase' + properties: + data: + type: array + items: + $ref: '#/components/schemas/PublishProcessResponse' + offset: + $ref: '#/components/schemas/Offset' + required: + - data + - offset + + id: + description: Publish process ID of the published item + type: string + maxLength: 28 + example: xmRhi7394HymoeRkfwAAAZeg3WiM + + ProductId: + minLength: 1 + maxLength: 100 + type: string + description: The id (SKU) of the product. + example: apple-ipod-classic + + ProductItem: + description: Details of the published product (only available if a product was published) + type: object + required: + - productId + properties: + productId: + $ref: '#/components/schemas/ProductId' + + PriceTableItem: + description: Details of the published price table (only available if a price table was published) + type: object + required: + - priceTableId + properties: + priceTableId: + type: string + description: ID of the price table + maxLength: 256 + example: usd-list-prices + + SiteId: + type: string + description: The site ID + maxLength: 256 + example: RefArch + + ContentAssetItem: + oneOf: + - $ref: '#/components/schemas/ContentAssetItemPrivate' + - $ref: '#/components/schemas/ContentAssetItemShared' + + ContentAssetItemPrivate: + description: Details of the published content asset from a private library + type: object + required: + - contentId + - type + - siteId + properties: + contentId: + type: string + description: ID of the content asset + maxLength: 256 + example: homepage-hero-banner + type: + type: string + enum: + - private + description: The type of library (private) from which the content asset originates. + example: private + siteId: + $ref: '#/components/schemas/SiteId' + additionalProperties: false + + ContentAssetItemShared: + description: Details of the published content asset from a shared library + type: object + required: + - contentId + - type + - libraryId + properties: + contentId: + type: string + description: ID of the content asset + maxLength: 256 + example: homepage-hero-banner + type: + type: string + enum: + - shared + description: The type of library (shared) from which the content asset originates. + example: shared + libraryId: + type: string + description: ID of the shared library + maxLength: 256 + example: sharedLibrary + additionalProperties: false + + PublishProcessResponse: + description: Publish process details + type: object + required: + - id + - status + - startTime + - initiatedBy + properties: + id: + $ref: '#/components/schemas/id' + status: + type: string + enum: + - pending + - in_progress + - completed + - failed + description: Status of the publish process + example: completed + startTime: + type: string + format: date-time + pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$ + description: Timestamp at which the publish process was started + example: '2024-03-15T10:30:00Z' + endTime: + type: string + format: date-time + pattern: ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$ + description: Timestamp at which the publish process was completed (only available if the status is "completed" or "failed") + example: '2024-03-15T10:30:45Z' + initiatedBy: + type: string + description: User or ID of the client application that initiated the publish process + maxLength: 256 + example: user@example.com + productItem: + $ref: '#/components/schemas/ProductItem' + priceTableItem: + $ref: '#/components/schemas/PriceTableItem' + contentAssetItem: + $ref: '#/components/schemas/ContentAssetItem' + + ProductPublishRequest: + type: object + required: + - product + properties: + product: + $ref: '#/components/schemas/ProductItem' + additionalProperties: false + + PriceTablePublishRequest: + type: object + required: + - priceTable + properties: + priceTable: + $ref: '#/components/schemas/PriceTableItem' + additionalProperties: false + + ContentAssetPublishRequest: + type: object + required: + - contentAsset + properties: + contentAsset: + oneOf: + - $ref: '#/components/schemas/ContentAssetItemPrivate' + - $ref: '#/components/schemas/ContentAssetItemShared' + + PublishItemRequest: + type: object + oneOf: + - $ref: '#/components/schemas/ProductPublishRequest' + - $ref: '#/components/schemas/PriceTablePublishRequest' + - $ref: '#/components/schemas/ContentAssetPublishRequest' + additionalProperties: false + + PublishIdResponse: + description: Item successfully queued for publishing + type: object + required: + - id + properties: + id: + $ref: '#/components/schemas/id' + + ErrorResponse: + description: Standard error response following RFC 7807 + type: object + properties: + type: + description: A URI reference that identifies the problem type + type: string + maxLength: 2048 + example: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/invalid-request-body + title: + description: A short, human-readable summary of the problem type + type: string + example: NotEnoughMoney + detail: + description: A human-readable explanation specific to this occurrence of the problem. + type: string + example: Your current balance is 30, but that costs 50 + instance: + description: A URI reference that identifies the specific occurrence of the problem + type: string + maxLength: 2048 + example: /account/12345/msgs/abc + required: + - title + - type + + parameters: + organizationId: + description: An identifier for the organization the request is being made by + name: organizationId + in: path + required: true + example: f_ecom_zzxy_prd + schema: + $ref: '#/components/schemas/OrganizationId' + + id: + description: Publish process ID + name: id + in: path + required: true + schema: + $ref: '#/components/schemas/id' + + responses: + 400BadRequest_GetProcesses: + description: Invalid query parameter + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidQueryParameter: + $ref: '#/components/examples/400BadRequest_GetProcesses_InvalidQueryParameter' + + 403Forbidden: + description: |- + Forbidden. Your access token is valid, but you don't have the required permissions to access the resource, + or the Granular Replication feature is disabled. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + featureNotEnabled: + $ref: '#/components/examples/403Forbidden_FeatureNotEnabled' + + 422UnprocessableContent: + description: If the request was not sent to a staging instance + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notOnStaging: + $ref: '#/components/examples/422UnprocessableContent_NotOnStagingInstance' + + 400BadRequest_CreateProcess: + description: Invalid request body + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalidRequestBody: + $ref: '#/components/examples/400BadRequest_CreateProcess_InvalidRequestBody' + + 404NotFound_CreateProcess: + description: The item was not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + itemNotFound: + $ref: '#/components/examples/404NotFound_ItemNotFound' + + 409Conflict_CreateProcess: + description: Conflict - item already published, limit exceeded, or replication running + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + publishedTwice: + $ref: '#/components/examples/409Conflict_CreateProcess_PublishedTwice' + limitExceeded: + $ref: '#/components/examples/409Conflict_CreateProcess_TooManyQueued' + replicationRunning: + $ref: '#/components/examples/409Conflict_CreateProcess_ReplicationRunning' + + 422UnprocessableContent_CreateProcess: + description: The item could not get processed + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + feedBasedPriceBookNotSupported: + $ref: '#/components/examples/422UnprocessableContent_FeedBasedPriceBookNotSupported' + notOnStaging: + $ref: '#/components/examples/422UnprocessableContent_NotOnStagingInstance' + + 404NotFound_GetProcess: + description: The publish process for given ID was not found + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + processNotFound: + $ref: '#/components/examples/404NotFound_ProcessNotFound' + + examples: + PostRequestProduct: + summary: Publish a product + description: Publish a product by ID + value: + product: + productId: PROD-12345 + + PostRequestPriceTable: + summary: Publish a price table + description: Publish a price table by ID + value: + priceTable: + priceTableId: usd-list-prices + + PostRequestContentAssetSharedLib: + summary: Publish a content asset from a shared library + description: Publish a content asset from a shared library + value: + contentAsset: + contentId: homepage-hero-banner + type: shared + libraryId: mySharedLibrary + + PostRequestContentAssetPrivateLib: + summary: Publish a content asset from a site's private library + description: Publish a content asset from a site's private library + value: + contentAsset: + contentId: homepage-hero-banner + type: private + siteId: mySiteID + + PublicationSuccessResponse: + summary: Successfully queued response + value: + id: xmRhi7394HymoeRkfwAAAZeg3WiM + + 400BadRequest_CreateProcess_InvalidRequestBody: + summary: Invalid request body error + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/invalid-request-body + title: Invalid request body + detail: Invalid request body. Must provide valid properties for either a product, price table, or content asset. + + 404NotFound_ItemNotFound: + summary: Item not found + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/item-not-found + title: Item not found + detail: The item specified was not found on the staging instance. + + 404NotFound_ProcessNotFound: + summary: Process not found + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/process-not-found + title: Process not found + detail: The publish process with the specified ID was not found. + + 409Conflict_CreateProcess_PublishedTwice: + summary: Item already published in this operation + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/published-twice + title: Item already published + detail: The item has already been queued for publishing in this operation. + + 409Conflict_CreateProcess_TooManyQueued: + summary: Publishing limit exceeded + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/too-many-queued + title: Publishing limit exceeded + detail: Maximum of 50 items can be queued per publish operation. + + 409Conflict_CreateProcess_ReplicationRunning: + summary: Replication in progress + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/replication-running + title: Replication in progress + detail: Granular Replication is not possible during a running replication. + + 422UnprocessableContent_FeedBasedPriceBookNotSupported: + summary: Feed based price book is not supported + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/feed-based-price-book + title: Feed based price book is not supported + detail: The price table belongs to a feed based price book. Feed based price books are not supported. + + 422UnprocessableContent_NotOnStagingInstance: + summary: Request was not sent to a staging instance + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/not-on-staging + title: Not on staging instance + detail: This operation must be performed from a staging instance. + + 400BadRequest_GetProcesses_InvalidQueryParameter: + summary: Invalid query parameter + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/invalid-query-parameter + title: Invalid query parameter + detail: Invalid query parameter. + + 403Forbidden_FeatureNotEnabled: + summary: Granular Replication feature is not enabled + value: + type: https://api.commercecloud.salesforce.com/documentation/error/v1/errors/granular-replication-feature-not-enabled + title: Granular Replication feature is not enabled + detail: The Granular Replication feature is not enabled in your staging and production environments. + + ProductProcessResponse: + summary: Product publish process + value: + id: xmRhi7394HymoeRkfwAAAZeg3WiM + status: completed + startTime: '2024-03-15T10:30:00Z' + endTime: '2024-03-15T10:30:45Z' + initiatedBy: user@example.com + productItem: + productId: PROD-12345 + + PriceTableProcessResponse: + summary: "Price table publish process; \nNote: The published price table is always the one with the continuously valid period." + value: + id: xmRhi7394HymoeRkfwAAAZeg3WiN + status: completed + startTime: '2024-03-15T10:35:00Z' + endTime: '2024-03-15T10:35:30Z' + initiatedBy: api-client-xyz + priceTableItem: + priceTableId: usd-list-prices + + ContentAssetProcessResponse: + summary: Content asset publish process + value: + id: xmRhi7394HymoeRkfwAAAZeg3WiO + status: in_progress + startTime: '2024-03-15T10:40:00Z' + initiatedBy: api-client-xyz + contentAssetItem: + contentId: homepage-hero-banner + type: shared + libraryId: sharedLibrary diff --git a/packages/b2c-tooling-sdk/src/clients/granular-replications.generated.ts b/packages/b2c-tooling-sdk/src/clients/granular-replications.generated.ts new file mode 100644 index 00000000..32b83ccb --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/granular-replications.generated.ts @@ -0,0 +1,417 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/organizations/{organizationId}/granular-processes": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + /** Retrieve a list of all publish processes with pagination support. */ + get: operations["listPublishProcesses"]; + put?: never; + /** + * Queue item for publishing + * @description Queues items, such as products, price tables, and content assets, for the upcoming publish operation. You can have multiple items in the queue, but you must send a separate request for each item. + * + * Note: The published price table is always the table with the continuously valid period. + * + * ## Publishing Limit + * This endpoint is limited to 50 items per publish operation. The HTTP status code 409 (Conflict) is returned if the limit is exceeded. + */ + post: operations["publishItems"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organizations/{organizationId}/granular-processes/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + /** @description Publish process ID */ + id: components["parameters"]["id"]; + }; + cookie?: never; + }; + /** Retrieve details of a specific publish process using its ID. */ + get: operations["getPublishProcess"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + OrganizationId: string; + /** + * Format: int32 + * @description The total number of hits that match the search's criteria. This can be greater than the number of results returned as search results are paginated. + * @default 0 + * @example 10 + */ + Total: number; + /** + * Format: int32 + * @description The offset for the search results (pagination). + * @default 0 + * @example 0 + */ + Offset: number; + /** @description Base type for results */ + ResultBase: { + total: components["schemas"]["Total"]; + /** @default 20 */ + limit: number; + }; + /** @description Paginated list of publish processes */ + PublishProcessListResponse: { + data: components["schemas"]["PublishProcessResponse"][]; + offset: components["schemas"]["Offset"]; + } & components["schemas"]["ResultBase"]; + /** + * @description Publish process ID of the published item + * @example xmRhi7394HymoeRkfwAAAZeg3WiM + */ + id: string; + /** + * @description The id (SKU) of the product. + * @example apple-ipod-classic + */ + ProductId: string; + /** @description Details of the published product (only available if a product was published) */ + ProductItem: { + productId: components["schemas"]["ProductId"]; + }; + /** @description Details of the published price table (only available if a price table was published) */ + PriceTableItem: { + /** + * @description ID of the price table + * @example usd-list-prices + */ + priceTableId: string; + }; + /** + * @description The site ID + * @example RefArch + */ + SiteId: string; + ContentAssetItem: components["schemas"]["ContentAssetItemPrivate"] | components["schemas"]["ContentAssetItemShared"]; + /** @description Details of the published content asset from a private library */ + ContentAssetItemPrivate: { + /** + * @description ID of the content asset + * @example homepage-hero-banner + */ + contentId: string; + /** + * @description The type of library (private) from which the content asset originates. + * @example private + * @enum {string} + */ + type: "private"; + siteId: components["schemas"]["SiteId"]; + }; + /** @description Details of the published content asset from a shared library */ + ContentAssetItemShared: { + /** + * @description ID of the content asset + * @example homepage-hero-banner + */ + contentId: string; + /** + * @description The type of library (shared) from which the content asset originates. + * @example shared + * @enum {string} + */ + type: "shared"; + /** + * @description ID of the shared library + * @example sharedLibrary + */ + libraryId: string; + }; + /** @description Publish process details */ + PublishProcessResponse: { + id: components["schemas"]["id"]; + /** + * @description Status of the publish process + * @example completed + * @enum {string} + */ + status: "pending" | "in_progress" | "completed" | "failed"; + /** + * Format: date-time + * @description Timestamp at which the publish process was started + * @example 2024-03-15T10:30:00Z + */ + startTime: string; + /** + * Format: date-time + * @description Timestamp at which the publish process was completed (only available if the status is "completed" or "failed") + * @example 2024-03-15T10:30:45Z + */ + endTime?: string; + /** + * @description User or ID of the client application that initiated the publish process + * @example user@example.com + */ + initiatedBy: string; + productItem?: components["schemas"]["ProductItem"]; + priceTableItem?: components["schemas"]["PriceTableItem"]; + contentAssetItem?: components["schemas"]["ContentAssetItem"]; + }; + ProductPublishRequest: { + product: components["schemas"]["ProductItem"]; + }; + PriceTablePublishRequest: { + priceTable: components["schemas"]["PriceTableItem"]; + }; + ContentAssetPublishRequest: { + contentAsset: components["schemas"]["ContentAssetItemPrivate"] | components["schemas"]["ContentAssetItemShared"]; + }; + PublishItemRequest: components["schemas"]["ProductPublishRequest"] | components["schemas"]["PriceTablePublishRequest"] | components["schemas"]["ContentAssetPublishRequest"]; + /** @description Item successfully queued for publishing */ + PublishIdResponse: { + id: components["schemas"]["id"]; + }; + /** @description Standard error response following RFC 7807 */ + ErrorResponse: { + /** + * @description A URI reference that identifies the problem type + * @example https://api.commercecloud.salesforce.com/documentation/error/v1/errors/invalid-request-body + */ + type: string; + /** + * @description A short, human-readable summary of the problem type + * @example NotEnoughMoney + */ + title: string; + /** + * @description A human-readable explanation specific to this occurrence of the problem. + * @example Your current balance is 30, but that costs 50 + */ + detail?: string; + /** + * @description A URI reference that identifies the specific occurrence of the problem + * @example /account/12345/msgs/abc + */ + instance?: string; + }; + }; + responses: { + /** @description Invalid query parameter */ + "400BadRequest_GetProcesses": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** + * @description Forbidden. Your access token is valid, but you don't have the required permissions to access the resource, + * or the Granular Replication feature is disabled. + */ + "403Forbidden": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description If the request was not sent to a staging instance */ + "422UnprocessableContent": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Invalid request body */ + "400BadRequest_CreateProcess": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The item was not found */ + "404NotFound_CreateProcess": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Conflict - item already published, limit exceeded, or replication running */ + "409Conflict_CreateProcess": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The item could not get processed */ + "422UnprocessableContent_CreateProcess": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The publish process for given ID was not found */ + "404NotFound_GetProcess": { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + parameters: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["schemas"]["OrganizationId"]; + /** @description Publish process ID */ + id: components["schemas"]["id"]; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + listPublishProcesses: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Retrieved list of publish processes successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishProcessListResponse"]; + }; + }; + 400: components["responses"]["400BadRequest_GetProcesses"]; + 403: components["responses"]["403Forbidden"]; + 422: components["responses"]["422UnprocessableContent"]; + }; + }; + publishItems: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PublishItemRequest"]; + }; + }; + responses: { + /** @description Item successfully queued for publishing */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishIdResponse"]; + }; + }; + 400: components["responses"]["400BadRequest_CreateProcess"]; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound_CreateProcess"]; + 409: components["responses"]["409Conflict_CreateProcess"]; + 422: components["responses"]["422UnprocessableContent_CreateProcess"]; + }; + }; + getPublishProcess: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description An identifier for the organization the request is being made by + * @example f_ecom_zzxy_prd + */ + organizationId: components["parameters"]["organizationId"]; + /** @description Publish process ID */ + id: components["parameters"]["id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Publish process details retrieved successfully. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublishProcessResponse"]; + }; + }; + 403: components["responses"]["403Forbidden"]; + 404: components["responses"]["404NotFound_GetProcess"]; + }; + }; +} diff --git a/packages/b2c-tooling-sdk/src/clients/granular-replications.ts b/packages/b2c-tooling-sdk/src/clients/granular-replications.ts new file mode 100644 index 00000000..3982059b --- /dev/null +++ b/packages/b2c-tooling-sdk/src/clients/granular-replications.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import createClient, {type Client} from 'openapi-fetch'; +import type {AuthStrategy} from '../auth/types.js'; +import type {paths, components} from './granular-replications.generated.js'; +import {createAuthMiddleware, createLoggingMiddleware, createRateLimitMiddleware} from './middleware.js'; +import {globalMiddlewareRegistry, type MiddlewareRegistry} from './middleware-registry.js'; +import {OAuthStrategy} from '../auth/oauth.js'; + +export type {paths, components}; +export type GranularReplicationsClient = Client; +export type GranularReplicationsResponse = T extends {content: {'application/json': infer R}} ? R : never; +export type GranularReplicationsError = components['schemas']['ErrorResponse']; + +// Entity type exports for CLI +export type ProductItem = components['schemas']['ProductItem']; +export type PriceTableItem = components['schemas']['PriceTableItem']; +export type ContentAssetItemPrivate = components['schemas']['ContentAssetItemPrivate']; +export type ContentAssetItemShared = components['schemas']['ContentAssetItemShared']; +export type PublishProcessResponse = components['schemas']['PublishProcessResponse']; +export type PublishProcessListResponse = components['schemas']['PublishProcessListResponse']; +export type PublishIdResponse = components['schemas']['PublishIdResponse']; + +export interface GranularReplicationsClientConfig { + shortCode: string; + organizationId: string; + scopes?: string[]; + middlewareRegistry?: MiddlewareRegistry; +} + +/** + * Creates a Granular Replications API client for publishing individual items. + * + * The Granular Replications API enables programmatic publishing of individual items + * (products, price tables, content assets) from staging to production environments. + * + * @param config - Client configuration with shortCode and organizationId + * @param auth - OAuth authentication strategy + * @returns Typed Granular Replications API client + * + * @example + * ```typescript + * import {createGranularReplicationsClient, OAuthStrategy} from '@salesforce/b2c-tooling-sdk'; + * + * const auth = new OAuthStrategy({ + * clientId: 'your-client-id', + * clientSecret: 'your-client-secret', + * tokenEndpoint: 'https://account.demandware.com/dwsso/oauth2/access_token' + * }); + * + * const client = createGranularReplicationsClient({ + * shortCode: 'kv7kzm78', + * organizationId: 'f_ecom_zzxy_prd' + * }, auth); + * + * // Queue a product for publishing + * const result = await client.POST('/organizations/{organizationId}/granular-processes', { + * params: {path: {organizationId: 'f_ecom_zzxy_prd'}}, + * body: {product: {productId: 'PROD-123'}} + * }); + * + * // List all publish processes + * const processes = await client.GET('/organizations/{organizationId}/granular-processes', { + * params: { + * path: {organizationId: 'f_ecom_zzxy_prd'}, + * query: {limit: 20, offset: 0} + * } + * }); + * ``` + * + * @see https://developer.salesforce.com/docs/commerce/commerce-api/references/replications + */ +export function createGranularReplicationsClient( + config: GranularReplicationsClientConfig, + auth: AuthStrategy, +): GranularReplicationsClient { + const registry = config.middlewareRegistry ?? globalMiddlewareRegistry; + + const client = createClient({ + baseUrl: `https://${config.shortCode}.api.commercecloud.salesforce.com/operation/replications/v1`, + }); + + // OAuth scope handling + const requiredScopes = config.scopes ?? ['sfcc.granular-replications.rw']; + const scopedAuth = auth instanceof OAuthStrategy ? auth.withAdditionalScopes(requiredScopes) : auth; + + client.use(createAuthMiddleware(scopedAuth)); + + for (const middleware of registry.getMiddleware('granular-replications')) { + client.use(middleware); + } + + client.use(createRateLimitMiddleware({prefix: 'GRANULAR-REPLICATIONS'})); + client.use(createLoggingMiddleware('GRANULAR-REPLICATIONS')); + + return client; +} diff --git a/packages/b2c-tooling-sdk/src/clients/index.ts b/packages/b2c-tooling-sdk/src/clients/index.ts index 8d7c0f45..a79f543a 100644 --- a/packages/b2c-tooling-sdk/src/clients/index.ts +++ b/packages/b2c-tooling-sdk/src/clients/index.ts @@ -307,6 +307,23 @@ export type { CipQueryResult, } from './cip.js'; +export {createGranularReplicationsClient} from './granular-replications.js'; +export type { + GranularReplicationsClient, + GranularReplicationsClientConfig, + GranularReplicationsError, + GranularReplicationsResponse, + ProductItem, + PriceTableItem, + ContentAssetItemPrivate, + ContentAssetItemShared, + PublishProcessResponse, + PublishProcessListResponse, + PublishIdResponse, + paths as GranularReplicationsPaths, + components as GranularReplicationsComponents, +} from './granular-replications.js'; + export {getApiErrorMessage} from './error-utils.js'; export {createTlsDispatcher} from './tls-dispatcher.js'; diff --git a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts index 1174b6e1..126abfdf 100644 --- a/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts +++ b/packages/b2c-tooling-sdk/src/clients/middleware-registry.ts @@ -54,6 +54,7 @@ export type HttpClientType = | 'custom-apis' | 'scapi-schemas' | 'cdn-zones' + | 'granular-replications' | 'webdav' | 'am-users-api' | 'am-roles-api' diff --git a/packages/b2c-tooling-sdk/src/index.ts b/packages/b2c-tooling-sdk/src/index.ts index 5cda8a8d..98d3b3cb 100644 --- a/packages/b2c-tooling-sdk/src/index.ts +++ b/packages/b2c-tooling-sdk/src/index.ts @@ -71,6 +71,7 @@ export { createAccountManagerApiClientsClient, createAccountManagerOrgsClient, createCdnZonesClient, + createGranularReplicationsClient, createCipClient, toOrganizationId, normalizeTenantId, @@ -153,6 +154,19 @@ export type { ZonesEnvelope, CdnZonesPaths, CdnZonesComponents, + GranularReplicationsClient, + GranularReplicationsClientConfig, + GranularReplicationsError, + GranularReplicationsResponse, + ProductItem, + PriceTableItem, + ContentAssetItemPrivate, + ContentAssetItemShared, + PublishProcessResponse, + PublishProcessListResponse, + PublishIdResponse, + GranularReplicationsPaths, + GranularReplicationsComponents, CipClient, CipClientConfig, CipColumn, diff --git a/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts b/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts new file mode 100644 index 00000000..d937c211 --- /dev/null +++ b/packages/b2c-tooling-sdk/test/clients/granular-replications.test.ts @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * SPDX-License-Identifier: Apache-2 + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 + */ +import {expect} from 'chai'; +import {http, HttpResponse} from 'msw'; +import {setupServer} from 'msw/node'; +import {createGranularReplicationsClient} from '../../src/clients/granular-replications.js'; +import {MockAuthStrategy} from '../helpers/mock-auth.js'; + +const SHORT_CODE = 'kv7kzm78'; +const ORG_ID = 'f_ecom_zzxy_prd'; +const BASE_URL = `https://${SHORT_CODE}.api.commercecloud.salesforce.com/operation/replications/v1`; + +describe('Granular Replications Client', () => { + const server = setupServer(); + let client: ReturnType; + let mockAuth: MockAuthStrategy; + + before(() => server.listen({onUnhandledRequest: 'error'})); + afterEach(() => server.resetHandlers()); + after(() => server.close()); + + beforeEach(() => { + mockAuth = new MockAuthStrategy(); + client = createGranularReplicationsClient({shortCode: SHORT_CODE, organizationId: ORG_ID}, mockAuth); + }); + + it('should create client with config', () => { + expect(client).to.exist; + }); + + describe('LIST granular-processes', () => { + it('should list granular processes', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, () => { + return HttpResponse.json({ + data: [ + { + id: 'proc-1', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }, + { + id: 'proc-2', + status: 'in_progress', + startTime: '2025-01-01T01:00:00Z', + initiatedBy: 'user@example.com', + priceTableItem: {priceTableId: 'table-1'}, + }, + ], + total: 2, + }); + }), + ); + + const result = await client.GET('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + }); + + expect(result.data?.data).to.have.length(2); + expect(result.data?.total).to.equal(2); + expect(result.data?.data?.[0].id).to.equal('proc-1'); + expect(result.data?.data?.[0].status).to.equal('completed'); + }); + + it('should list with pagination parameters', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, ({request}) => { + const url = new URL(request.url); + expect(url.searchParams.get('limit')).to.equal('10'); + expect(url.searchParams.get('offset')).to.equal('20'); + return HttpResponse.json({ + data: [], + total: 0, + }); + }), + ); + + await client.GET('/organizations/{organizationId}/granular-processes', { + params: { + path: {organizationId: ORG_ID}, + query: {limit: 10, offset: 20}, + }, + }); + }); + + it('should handle list errors', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, () => { + return HttpResponse.json( + { + title: 'Forbidden', + detail: 'Feature not enabled for this organization', + }, + {status: 403}, + ); + }), + ); + + const result = await client.GET('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + }); + + expect(result.error).to.exist; + expect(result.response.status).to.equal(403); + }); + }); + + describe('POST granular-processes', () => { + it('should queue product for publishing', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, async ({request}) => { + const body = (await request.json()) as Record; + expect(body).to.deep.equal({product: {productId: 'PROD-1'}}); + return HttpResponse.json({id: 'proc-123'}, {status: 201}); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {product: {productId: 'PROD-1'}}, + }); + + expect(result.data?.id).to.equal('proc-123'); + expect(result.response.status).to.equal(201); + }); + + it('should queue price table for publishing', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, async ({request}) => { + const body = (await request.json()) as Record; + expect(body).to.deep.equal({priceTable: {priceTableId: 'table-1'}}); + return HttpResponse.json({id: 'proc-456'}, {status: 201}); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {priceTable: {priceTableId: 'table-1'}}, + }); + + expect(result.data?.id).to.equal('proc-456'); + }); + + it('should queue private content asset for publishing', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, async ({request}) => { + const body = (await request.json()) as Record; + expect(body).to.deep.equal({ + contentAsset: {contentId: 'hero-banner', type: 'private', siteId: 'RefArch'}, + }); + return HttpResponse.json({id: 'proc-789'}, {status: 201}); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {contentAsset: {contentId: 'hero-banner', type: 'private', siteId: 'RefArch'}}, + }); + + expect(result.data?.id).to.equal('proc-789'); + }); + + it('should queue shared content asset for publishing', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, async ({request}) => { + const body = (await request.json()) as Record; + expect(body).to.deep.equal({ + contentAsset: {contentId: 'footer-links', type: 'shared', libraryId: 'SharedLibrary'}, + }); + return HttpResponse.json({id: 'proc-abc'}, {status: 201}); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {contentAsset: {contentId: 'footer-links', type: 'shared', libraryId: 'SharedLibrary'}}, + }); + + expect(result.data?.id).to.equal('proc-abc'); + }); + + it('should handle 422 error when not on staging', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, () => { + return HttpResponse.json( + { + title: 'Unprocessable Entity', + detail: 'Granular replication can only be initiated from staging instances', + }, + {status: 422}, + ); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {product: {productId: 'PROD-1'}}, + }); + + expect(result.error).to.exist; + expect(result.response.status).to.equal(422); + }); + + it('should handle 409 conflict during replication', async () => { + server.use( + http.post(`${BASE_URL}/organizations/${ORG_ID}/granular-processes`, () => { + return HttpResponse.json( + { + title: 'Conflict', + detail: 'Cannot queue items while full replication is running', + }, + {status: 409}, + ); + }), + ); + + const result = await client.POST('/organizations/{organizationId}/granular-processes', { + params: {path: {organizationId: ORG_ID}}, + body: {product: {productId: 'PROD-1'}}, + }); + + expect(result.error).to.exist; + expect(result.response.status).to.equal(409); + }); + }); + + describe('GET granular-processes/{id}', () => { + it('should get process details', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes/proc-1`, () => { + return HttpResponse.json({ + id: 'proc-1', + status: 'completed', + startTime: '2025-01-01T00:00:00Z', + endTime: '2025-01-01T00:05:00Z', + initiatedBy: 'user@example.com', + productItem: {productId: 'PROD-1'}, + }); + }), + ); + + const result = await client.GET('/organizations/{organizationId}/granular-processes/{id}', { + params: {path: {organizationId: ORG_ID, id: 'proc-1'}}, + }); + + expect(result.data?.id).to.equal('proc-1'); + expect(result.data?.status).to.equal('completed'); + expect(result.data?.productItem?.productId).to.equal('PROD-1'); + }); + + it('should get process with content asset', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes/proc-2`, () => { + return HttpResponse.json({ + id: 'proc-2', + status: 'in_progress', + startTime: '2025-01-01T01:00:00Z', + initiatedBy: 'user@example.com', + contentAssetItem: {contentId: 'hero', type: 'private', siteId: 'RefArch'}, + }); + }), + ); + + const result = await client.GET('/organizations/{organizationId}/granular-processes/{id}', { + params: {path: {organizationId: ORG_ID, id: 'proc-2'}}, + }); + + expect(result.data?.contentAssetItem?.contentId).to.equal('hero'); + expect(result.data?.contentAssetItem?.type).to.equal('private'); + }); + + it('should handle 404 for nonexistent process', async () => { + server.use( + http.get(`${BASE_URL}/organizations/${ORG_ID}/granular-processes/invalid`, () => { + return HttpResponse.json( + { + title: 'Not Found', + detail: 'Process not found', + }, + {status: 404}, + ); + }), + ); + + const result = await client.GET('/organizations/{organizationId}/granular-processes/{id}', { + params: {path: {organizationId: ORG_ID, id: 'invalid'}}, + }); + + expect(result.error).to.exist; + expect(result.response.status).to.equal(404); + }); + }); +});