diff --git a/spec/src/modules/recommendations.js b/spec/src/modules/recommendations.js index 4de637d7..0fcfc2e2 100644 --- a/spec/src/modules/recommendations.js +++ b/spec/src/modules/recommendations.js @@ -848,4 +848,214 @@ describe('ConstructorIO - Recommendations', () => { }); } }); + + describe('getRecommendationPage', () => { + const mockApiKey = 'key_mock_page_api'; + const pageId = 'pdp_b2c'; + const itemId = 'product-123'; + const pageResultId = 'page-result-id'; + const pageResponse = () => ({ + request: { page_id: pageId, item_id: itemId, num_results: 10 }, + response: { + page_id: pageId, + display_name: 'PDP - B2C', + page_type: 'pdp', + pods: [ + { + pod_id: 'similar_items', + request: { item_id: itemId, num_results: 12 }, + response: { + results: [ + { data: { id: 'product-987' }, value: 'Red Running Shoe', strategy: { id: 'alternative_items' } }, + { data: { id: 'product-988' }, value: 'Blue Running Shoe', strategy: { id: 'alternative_items' } }, + ], + total_num_results: 2, + pod: { id: 'similar_items', display_name: 'Similar Items' }, + }, + result_id: 'similar-items-result-id', + }, + { + pod_id: 'complete_the_look', + request: { item_id: itemId, num_results: 8 }, + response: { + results: [], + total_num_results: 0, + pod: { id: 'complete_the_look', display_name: 'Complete the Look' }, + }, + result_id: 'complete-the-look-result-id', + }, + ], + }, + result_id: pageResultId, + }); + let fetchStub; + + beforeEach(() => { + fetchStub = sinon.stub().resolves({ ok: true, json: () => Promise.resolve(pageResponse()) }); + }); + + it('Should request the page endpoint with shared parameters and user parameters', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub, securityToken: 'token' }); + + await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + numResults: 10, + section: 'Products', + filters: { in_stock: 'true' }, + filterMatchTypes: { color: 'any' }, + }, { + clientId: validClientId, + sessionId: validSessionId, + userId: 'user-id', + segments: ['vip'], + userIp: '127.0.0.1', + userAgent: 'agent', + }); + + const [requestedUrl, { headers }] = fetchStub.lastCall.args; + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchStub); + + expect(requestedUrl).to.match(/\/recommendations\/v1\/pages\/pdp_b2c\?/); + expect(requestedUrlParams).to.have.property('key').to.equal(mockApiKey); + expect(requestedUrlParams).to.have.property('i').to.equal(validClientId); + expect(requestedUrlParams).to.have.property('s').to.equal(validSessionId); + expect(requestedUrlParams).to.have.property('ui').to.equal('user-id'); + expect(requestedUrlParams).to.have.property('us').to.equal('vip'); + expect(requestedUrlParams).to.have.property('c').to.equal(clientVersion); + expect(requestedUrlParams).to.have.property('_dt'); + expect(requestedUrlParams).to.have.property('item_id').to.equal(itemId); + expect(requestedUrlParams).to.have.property('num_results').to.equal('10'); + expect(requestedUrlParams).to.have.property('section').to.equal('Products'); + expect(requestedUrlParams.filters).to.deep.equal({ in_stock: 'true' }); + expect(requestedUrlParams.filter_match_types).to.deep.equal({ color: 'any' }); + expect(requestedUrlParams).to.not.have.property('pod_overrides'); + expect(headers).to.include({ 'x-cnstrc-token': 'token', 'X-Forwarded-For': '127.0.0.1', 'User-Agent': 'agent' }); + }); + + it('Should encode podOverrides in bracket notation with the same wire format as shared parameters', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + const preFilterExpression = { or: [{ name: 'brand', value: 'acme' }] }; + const variationsMap = { group_by: [{ name: 'color', field: 'data.color' }], values: {}, dtype: 'array' }; + + await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + numResults: 10, + podOverrides: { + similar_items: { numResults: 0 }, + complete_the_look: { + numResults: 8, + filters: { in_stock: 'true', color: ['red', 'blue'] }, + filterMatchTypes: { color: 'all' }, + preFilterExpression, + variationsMap, + fmtOptions: { groups_max_depth: 2 }, + hiddenFields: ['inventory', 'margin'], + }, + }, + }); + + const requestedUrl = decodeURIComponent(fetchStub.lastCall.args[0]); + const requestedUrlParams = helpers.extractUrlParamsFromFetch(fetchStub); + const completeTheLook = requestedUrlParams.pod_overrides.complete_the_look; + + expect(requestedUrl).to.include('pod_overrides[similar_items][num_results]=0'); + expect(requestedUrl).to.include('pod_overrides[complete_the_look][filters][color]=red&pod_overrides[complete_the_look][filters][color]=blue'); + expect(requestedUrlParams.num_results).to.equal('10'); + expect(requestedUrlParams.pod_overrides.similar_items).to.deep.equal({ num_results: '0' }); + expect(completeTheLook.num_results).to.equal('8'); + expect(completeTheLook.filters).to.deep.equal({ in_stock: 'true', color: ['red', 'blue'] }); + expect(completeTheLook.filter_match_types).to.deep.equal({ color: 'all' }); + expect(JSON.parse(completeTheLook.pre_filter_expression)).to.deep.equal(preFilterExpression); + expect(JSON.parse(completeTheLook.variations_map)).to.deep.equal(variationsMap); + expect(completeTheLook.fmt_options).to.deep.equal({ groups_max_depth: '2', hidden_fields: ['inventory', 'margin'] }); + }); + + it('Should stamp each pod\'s own result_id onto its results, not the page result_id', async () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + const res = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + const [similarItems, completeTheLook] = res.response.pods; + + expect(res.result_id).to.equal(pageResultId); + similarItems.response.results.forEach((result) => { + expect(result.result_id).to.equal('similar-items-result-id'); + }); + expect(completeTheLook.response.results).to.deep.equal([]); + }); + + it('Should expose the request URL on the returned promise', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + const promise = recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + + expect(promise.requestUrl).to.match(/\/recommendations\/v1\/pages\/pdp_b2c\?/); + + return promise; + }); + + it('Should be rejected when the response has no pods', () => { + fetchStub = sinon.stub().resolves({ ok: true, json: () => Promise.resolve({ response: {} }) }); + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId)).to.eventually.be.rejectedWith('getRecommendationPage response data is malformed'); + }); + + it('Should be rejected when pageId is not provided', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(null, { itemIds: itemId })).to.eventually.be.rejectedWith('pageId is a required parameter of type string'); + }); + + it('Should be rejected when podOverrides contains a page-wide parameter', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId, { + podOverrides: { similar_items: { itemIds: 'other' } }, + })).to.eventually.be.rejectedWith('podOverrides.similar_items contains parameters that cannot be overridden per pod: itemIds'); + }); + + it('Should be rejected when variationId is provided without itemIds', () => { + const { recommendations } = new ConstructorIO({ apiKey: mockApiKey, fetch: fetchStub }); + + return expect(recommendations.getRecommendationPage(pageId, { variationId: 'v1' })).to.eventually.be.rejectedWith('itemIds is a required parameter for variationId'); + }); + }); + + // The page endpoint is not yet enabled on the test index, and the test index has no + // page configured (`/recommendations/v1/pages/pdp_b2c` returns 404 there) + describe.skip('getRecommendationPage - live', () => { + const pageId = 'pdp_b2c'; + const itemId = 'power_drill'; + + it('Should return a response with each pod\'s result_id stamped on its results', async () => { + const { recommendations } = new ConstructorIO({ ...validOptions, fetch: fetchSpy }); + + const res = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + + expect(res).to.have.property('result_id').to.be.a('string'); + expect(res.response.pods).to.be.an('array').that.is.not.empty; + res.response.pods.forEach((pod) => { + expect(pod).to.have.property('pod_id').to.be.a('string'); + expect(pod).to.have.property('result_id').to.be.a('string').that.does.not.equal(res.result_id); + expect(pod.response.pod.id).to.equal(pod.pod_id); + pod.response.results.forEach((result) => { + expect(result.result_id).to.equal(pod.result_id); + }); + }); + }); + + it('Should apply a numResults override to one pod', async () => { + const { recommendations } = new ConstructorIO({ ...validOptions, fetch: fetchSpy }); + const first = await recommendations.getRecommendationPage(pageId, { itemIds: itemId }); + const overriddenPodId = first.response.pods[0].pod_id; + + const res = await recommendations.getRecommendationPage(pageId, { + itemIds: itemId, + podOverrides: { [overriddenPodId]: { numResults: 1 } }, + }); + + expect(res.response.pods[0].request.num_results).to.equal(1); + expect(res.response.pods[0].response.results.length).to.be.at.most(1); + }); + }); }); diff --git a/src/modules/recommendations.js b/src/modules/recommendations.js index 628e470c..33714481 100644 --- a/src/modules/recommendations.js +++ b/src/modules/recommendations.js @@ -3,12 +3,22 @@ const qs = require('qs'); const { AbortController } = require('node-abort-controller'); const helpers = require('../utils/helpers'); -// Create URL from supplied parameters -function createRecommendationsUrl(podId, parameters, userParameters, options) { +// Parameters that a page request accepts per pod in `podOverrides` +const PAGE_OVERRIDABLE_PARAMETERS = [ + 'numResults', + 'filters', + 'filterMatchTypes', + 'preFilterExpression', + 'fmtOptions', + 'hiddenFields', + 'variationsMap', +]; + +// Create base query parameters shared by pod and page requests +function createBaseQueryParams(userParameters, options) { const { apiKey, version, - serviceUrl, } = options; const { sessionId, @@ -17,17 +27,12 @@ function createRecommendationsUrl(podId, parameters, userParameters, options) { segments, originReferrer, } = userParameters; - let queryParams = { c: version }; + const queryParams = { c: version }; queryParams.key = apiKey; queryParams.i = clientId; queryParams.s = sessionId; - // Validate pod identifier is provided - if (!podId || typeof podId !== 'string') { - throw new Error('podId is a required parameter of type string'); - } - // Pull user segments from options if (segments && segments.length) { queryParams.us = segments; @@ -43,6 +48,44 @@ function createRecommendationsUrl(podId, parameters, userParameters, options) { queryParams.origin_referrer = originReferrer; } + return queryParams; +} + +// Create request headers shared by pod and page requests +function createRequestHeaders(userParameters, networkParameters, options) { + const headers = {}; + + Object.assign(headers, helpers.combineCustomHeaders(options, networkParameters)); + + // Append security token as 'x-cnstrc-token' if available + if (options.securityToken && typeof options.securityToken === 'string') { + headers['x-cnstrc-token'] = options.securityToken; + } + + // Append user IP as 'X-Forwarded-For' if available + if (userParameters.userIp && typeof userParameters.userIp === 'string') { + headers['X-Forwarded-For'] = userParameters.userIp; + } + + // Append user agent as 'User-Agent' if available + if (userParameters.userAgent && typeof userParameters.userAgent === 'string') { + headers['User-Agent'] = userParameters.userAgent; + } + + return headers; +} + +// Create URL from supplied parameters +function createRecommendationsUrl(podId, parameters, userParameters, options) { + const { serviceUrl } = options; + + // Validate pod identifier is provided + if (!podId || typeof podId !== 'string') { + throw new Error('podId is a required parameter of type string'); + } + + let queryParams = createBaseQueryParams(userParameters, options); + if (parameters) { const { numResults, @@ -122,6 +165,123 @@ function createRecommendationsUrl(podId, parameters, userParameters, options) { return `${serviceUrl}/recommendations/v1/pods/${helpers.encodeURIComponentRFC3986(helpers.normalizeSpaces(podId).trim())}?${queryString}`; } +// Map parameters that can be set per pod on a page request to their query parameter form +// - uses the same wire format as `createRecommendationsUrl` +function createPageOverridableParams(parameters) { + const { + numResults, + filters, + filterMatchTypes, + preFilterExpression, + fmtOptions, + hiddenFields, + variationsMap, + } = parameters; + const params = {}; + + if (!helpers.isNil(numResults)) { + params.num_results = numResults; + } + + if (filters) { + params.filters = filters; + } + + if (filterMatchTypes) { + params.filter_match_types = filterMatchTypes; + } + + if (fmtOptions) { + params.fmt_options = { ...fmtOptions }; + } + + if (hiddenFields) { + params.fmt_options = { ...params.fmt_options, hidden_fields: hiddenFields }; + } + + if (variationsMap) { + params.variations_map = JSON.stringify(variationsMap); + } + + if (preFilterExpression) { + params.pre_filter_expression = JSON.stringify(preFilterExpression); + } + + return params; +} + +// Create page URL from supplied parameters +function createRecommendationPageUrl(pageId, parameters, userParameters, options) { + const { serviceUrl } = options; + + // Validate page identifier is provided + if (!pageId || typeof pageId !== 'string') { + throw new Error('pageId is a required parameter of type string'); + } + + let queryParams = createBaseQueryParams(userParameters, options); + const { itemIds, variationId, section, term, podOverrides } = parameters; + + // Pull item ids from parameters + if (itemIds) { + queryParams.item_id = itemIds; + } + + if (variationId) { + if (!itemIds) { + throw new Error('itemIds is a required parameter for variationId'); + } + + if (Array.isArray(itemIds) && !itemIds.length) { + throw new Error('At least one itemId is a required parameter for variationId'); + } + + queryParams.variation_id = variationId; + } + + // Pull section from parameters + if (section) { + queryParams.section = section; + } + + // Pull term from parameters + if (term) { + queryParams.term = term; + } + + queryParams = { ...queryParams, ...createPageOverridableParams(parameters) }; + + // Pull per-pod overrides from parameters + if (podOverrides) { + if (typeof podOverrides !== 'object' || Array.isArray(podOverrides)) { + throw new Error('podOverrides must be an object keyed by pod id'); + } + + const podOverrideParams = {}; + + Object.keys(podOverrides).forEach((podId) => { + const override = podOverrides[podId] || {}; + const unsupported = Object.keys(override).filter((name) => !PAGE_OVERRIDABLE_PARAMETERS.includes(name)); + + if (unsupported.length) { + throw new Error(`podOverrides.${podId} contains parameters that cannot be overridden per pod: ${unsupported.join(', ')}. Supported: ${PAGE_OVERRIDABLE_PARAMETERS.join(', ')}`); + } + + podOverrideParams[podId] = createPageOverridableParams(override); + }); + + queryParams.pod_overrides = podOverrideParams; + } + + // eslint-disable-next-line no-underscore-dangle + queryParams._dt = Date.now(); + queryParams = helpers.cleanParams(queryParams); + + const queryString = qs.stringify(queryParams, { indices: false }); + + return `${serviceUrl}/recommendations/v1/pages/${helpers.encodeURIComponentRFC3986(helpers.normalizeSpaces(pageId).trim())}?${queryString}`; +} + /** * Interface to recommendations related API calls * @@ -175,7 +335,6 @@ class Recommendations { const { fetch } = this.options; const controller = new AbortController(); const { signal } = controller; - const headers = {}; parameters = parameters || {}; @@ -185,22 +344,7 @@ class Recommendations { return Promise.reject(e); } - Object.assign(headers, helpers.combineCustomHeaders(this.options, networkParameters)); - - // Append security token as 'x-cnstrc-token' if available - if (this.options.securityToken && typeof this.options.securityToken === 'string') { - headers['x-cnstrc-token'] = this.options.securityToken; - } - - // Append user IP as 'X-Forwarded-For' if available - if (userParameters.userIp && typeof userParameters.userIp === 'string') { - headers['X-Forwarded-For'] = userParameters.userIp; - } - - // Append user agent as 'User-Agent' if available - if (userParameters.userAgent && typeof userParameters.userAgent === 'string') { - headers['User-Agent'] = userParameters.userAgent; - } + const headers = createRequestHeaders(userParameters, networkParameters, this.options); // Handle network timeout if specified helpers.applyNetworkTimeout(this.options, networkParameters, controller); @@ -237,6 +381,98 @@ class Recommendations { return promise; } + /** + * Get recommendations for every pod configured on a page + * + * Beta: available to a small group of users and may change. Contact your Customer Success + * Manager to request access. + * + * @function getRecommendationPage + * @description Each pod's `result_id` is appended to that pod's results. The top-level `result_id` + * identifies the page request; send each pod's own `result_id` with that pod's tracking events. + * @param {string} pageId - Page identifier + * @param {object} [parameters] - Additional parameters to refine results, applied to every pod + * @param {string|array} [parameters.itemIds] - Item ID(s) to retrieve recommendations for (strategy specific). Required for variationId + * @param {string} [parameters.variationId] - Variation ID to retrieve recommendations for (strategy specific) + * @param {number} [parameters.numResults] - The number of results to return per pod + * @param {string} [parameters.section] - The section to return results from + * @param {string} [parameters.term] - The term to use to refine results (strategy specific) + * @param {object} [parameters.filters] - Key / value mapping of filters used to refine results + * @param {object} [parameters.filterMatchTypes] - Whether results must match `all`, `any` or `none` of each filter's values + * @param {object} [parameters.variationsMap] - The variations map object to aggregate variations. Please refer to https://docs.constructor.com/reference/shared-variations-mapping for details + * @param {object} [parameters.preFilterExpression] - Faceting expression to scope search results. Please refer to https://docs.constructor.com/reference/configuration-collections + * @param {object} [parameters.fmtOptions] - An object containing options to format different aspects of the response + * @param {string[]} [parameters.hiddenFields] - Hidden metadata fields to return + * @param {object} [parameters.podOverrides] - Per-pod values keyed by pod id. Each value may contain `numResults`, `filters`, `filterMatchTypes`, `preFilterExpression`, `fmtOptions`, `hiddenFields` and `variationsMap`, and replaces (does not merge with) the page-wide value for that pod. `hiddenFields` is sent inside `fmt_options`, so an override with either `fmtOptions` or `hiddenFields` replaces all page-wide `fmtOptions` and `hiddenFields` for that pod + * @param {object} [userParameters] - Parameters relevant to the user request + * @param {number} [userParameters.sessionId] - Session ID, utilized to personalize results + * @param {string} [userParameters.clientId] - Client ID, utilized to personalize results + * @param {string} [userParameters.userId] - User ID, utilized to personalize results + * @param {string[]} [userParameters.segments] - User segments + * @param {string} [userParameters.originReferrer] - Client page URL (including path) + * @param {string} [userParameters.userIp] - Origin user IP, from client + * @param {string} [userParameters.userAgent] - Origin user agent, from client + * @param {object} [networkParameters] - Parameters relevant to the network request + * @param {number} [networkParameters.timeout] - Request timeout (in milliseconds) + * @returns {Promise} + * @see https://docs.constructor.com/reference/v1-recommendations-get-page-results + * @example + * constructorio.recommendations.getRecommendationPage('pdp_b2c', { + * itemIds: 'product-123', + * numResults: 10, + * podOverrides: { + * complete_the_look: { numResults: 8, filters: { category: 'Apparel' } }, + * }, + * }); + */ + getRecommendationPage(pageId, parameters = {}, userParameters = {}, networkParameters = {}) { + let requestUrl; + const { fetch } = this.options; + const controller = new AbortController(); + const { signal } = controller; + + parameters = parameters || {}; + userParameters = userParameters || {}; + + try { + requestUrl = createRecommendationPageUrl(pageId, parameters, userParameters, this.options); + } catch (e) { + return Promise.reject(e); + } + + const headers = createRequestHeaders(userParameters, networkParameters, this.options); + + // Handle network timeout if specified + helpers.applyNetworkTimeout(this.options, networkParameters, controller); + + const promise = fetch(requestUrl, { headers, signal }).then((response) => { + if (response.ok) { + return response.json(); + } + + return helpers.throwHttpErrorFromResponse(new Error(), response); + }).then((json) => { + if (json.response && Array.isArray(json.response.pods)) { + json.response.pods.forEach((pod) => { + // Append the pod's own `result_id` (not the page's) to each of its results + if (pod && pod.result_id && pod.response && Array.isArray(pod.response.results)) { + pod.response.results.forEach((result) => { + result.result_id = pod.result_id; + }); + } + }); + + return json; + } + + throw new Error('getRecommendationPage response data is malformed'); + }); + + promise.requestUrl = requestUrl; + + return promise; + } + /** * Get all recommendation pods * diff --git a/src/types/recommendations.d.ts b/src/types/recommendations.d.ts index 7fac18ad..1e90abf5 100644 --- a/src/types/recommendations.d.ts +++ b/src/types/recommendations.d.ts @@ -1,4 +1,4 @@ -import { ConstructorClientOptions, NetworkParameters, UserParameters, VariationsMap, FilterExpression } from '.'; +import { ConstructorClientOptions, NetworkParameters, UserParameters, VariationsMap, FilterExpression, FmtOptions } from '.'; export default Recommendations; @@ -14,6 +14,25 @@ export interface RecommendationsParameters { hiddenFields?: string[]; } +/** Parameters that can be set per pod on a page request. Each replaces the page-wide value. */ +export interface RecommendationPagePodOverride { + numResults?: number; + filters?: Record; + filterMatchTypes?: Record; + preFilterExpression?: FilterExpression; + fmtOptions?: FmtOptions; + hiddenFields?: string[]; + variationsMap?: VariationsMap; +} + +export interface RecommendationPageParameters extends RecommendationPagePodOverride { + itemIds?: string | string[]; + variationId?: string; + section?: string; + term?: string; + podOverrides?: Record; +} + declare class Recommendations { constructor(options: ConstructorClientOptions); @@ -26,6 +45,13 @@ declare class Recommendations { networkParameters?: NetworkParameters ): Promise; + getRecommendationPage( + pageId: string, + parameters?: RecommendationPageParameters, + userParameters?: UserParameters, + networkParameters?: NetworkParameters + ): Promise; + getRecommendationPods( networkParameters?: NetworkParameters ): Promise; @@ -38,6 +64,30 @@ export interface RecommendationsResponse extends Record { result_id: string; } +/* Recommendation page results returned from server */ +export interface RecommendationPageResponse extends Record { + request: Record; + response: RecommendationPageResponseType; + /** Identifies the page request. Not a tracking id: use each pod's `result_id`. */ + result_id: string; +} + +export interface RecommendationPageResponseType extends Record { + page_id: string; + display_name: string; + page_type: string; + pods: RecommendationPagePod[]; +} + +export interface RecommendationPagePod extends Record { + pod_id: string; + /** The pod's effective request: page-wide parameters with this pod's overrides applied */ + request: Partial; + response: Partial; + /** Send this with the pod's recommendation view and click events */ + result_id: string; +} + export interface RecommendationsRequestType extends Record { num_results: number; item_id: string | string[]; diff --git a/src/types/tests/recommedations.test-d.ts b/src/types/tests/recommedations.test-d.ts index 4fae24dc..89019d24 100644 --- a/src/types/tests/recommedations.test-d.ts +++ b/src/types/tests/recommedations.test-d.ts @@ -1,6 +1,10 @@ import { expectAssignable } from 'tsd'; import { RecommendationPodsResponse } from '..'; -import { RecommendationsResponse } from '../recommendations.d'; +import { + RecommendationsResponse, + RecommendationPageResponse, + RecommendationPageParameters, +} from '../recommendations.d'; expectAssignable({ request: { @@ -72,3 +76,52 @@ expectAssignable({ ], total_count: 2, }); + +expectAssignable({ + itemIds: 'product-123', + numResults: 10, + filters: { in_stock: 'true' }, + podOverrides: { + similar_items: { numResults: 12 }, + complete_the_look: { + numResults: 8, + filters: { in_stock: 'true', category: 'Apparel' }, + filterMatchTypes: { category: 'any' }, + preFilterExpression: { name: 'brand', value: 'acme' }, + }, + }, +}); + +expectAssignable({ + request: { + page_id: 'pdp_b2c', + item_id: 'product-123', + num_results: 10, + }, + response: { + page_id: 'pdp_b2c', + display_name: 'PDP - B2C', + page_type: 'pdp', + pods: [ + { + pod_id: 'similar_items', + request: { item_id: 'product-123', num_results: 12 }, + response: { + results: [ + { + data: { id: 'product-987', variation_id: 'product-987-red' }, + value: 'Red Running Shoe', + is_slotted: false, + labels: {}, + strategy: { id: 'alternative_items' }, + }, + ], + total_num_results: 1, + pod: { id: 'similar_items', display_name: 'Similar Items' }, + }, + result_id: 'a1b2c3d4-0000-0000-0000-000000000001', + }, + ], + }, + result_id: 'a1b2c3d4-0000-0000-0000-0000000000ff', +});