From 5b08368c6787e7b4839395e5ad03c1668e1f02b7 Mon Sep 17 00:00:00 2001 From: "Mark J. Becker" Date: Fri, 24 Jan 2025 14:12:14 +0100 Subject: [PATCH] SITES-28176 - Support product URLs with only urlKeys (#18) * Introduce path format and fetching products by urlKey * Fixes and testing * SITES-28179 - Add support for locale URLs (#19) * Add support for locale URLs * Allow config overrides from context variable * Adjust poller to use same configuration parameters * Fixes * Use HLX_LOCALES for allowedLocales * Update mapLocale to throw instead of using a default value --- actions/check-product-changes/lib/aem.js | 22 +- actions/check-product-changes/poller.js | 72 +++---- actions/pdp-renderer/index.js | 79 ++++++-- actions/pdp-renderer/ldJson.js | 8 +- actions/pdp-renderer/lib.js | 46 ++--- actions/queries.js | 246 ++++++++++++----------- actions/utils.js | 79 +++++++- app.config.yaml | 33 ++- e2e/pdp-ssg.e2e.test.js | 15 +- test/check-product-changes.test.js | 6 +- test/ldJson.test.js | 2 +- test/lib.test.js | 32 +-- test/mock-responses/mock-product-ls.json | 187 +++++++++++++++++ test/mock-server.js | 5 + test/pdp-renderer.test.js | 85 ++++++++ test/utils.test.js | 51 ++++- 16 files changed, 703 insertions(+), 265 deletions(-) create mode 100644 test/mock-responses/mock-product-ls.json diff --git a/actions/check-product-changes/lib/aem.js b/actions/check-product-changes/lib/aem.js index 685445c..0329a77 100644 --- a/actions/check-product-changes/lib/aem.js +++ b/actions/check-product-changes/lib/aem.js @@ -123,17 +123,17 @@ class AdminAPI { doPreview(item) { this.trackInFlight(`preview ${item.record.path}`, async (complete) => { - const { log } = this.context; + const { logger } = this.context; const { record } = item; const start = new Date(); try { record.previewedAt = new Date(); await this.execAdminRequest('POST', 'preview', record.path); - log.info(`Previewed ${record.path}`); + logger.info(`Previewed ${record.path}`); this.publishQueue.push(item); } catch (e) { - log.error(e); + logger.error(e); // only resolve the item promise in case of an error item.resolve(record); } finally { @@ -145,18 +145,18 @@ class AdminAPI { doPublish(items) { this.trackInFlight(`publish ${items.length}`, async (complete) => { - const { log } = this.context; + const { logger } = this.context; try { const paths = items.map(({ record }) => record.path); const body = { forceUpdate: false, paths }; await this.execAdminRequest('POST', 'live', '/*', body); - log.info(`Published ${items.length} items`); + logger.info(`Published ${items.length} items`); // set published date after publishing done items.forEach(({ record }) => record.publishedAt = new Date()); } catch (e) { - log.error(e); + logger.error(e); } finally { complete(); // resolve the original promises @@ -167,16 +167,16 @@ class AdminAPI { doUnpublishAndDelete(item) { this.trackInFlight(`unpublish ${item.record.path}`, async (complete) => { - const { log } = this.context; + const { logger } = this.context; const { record } = item; try { await this.execAdminRequest('DELETE', 'live', record.path); await this.execAdminRequest('DELETE', 'preview', record.path); - log.info(`Unpublished ${record.path}`); + logger.info(`Unpublished ${record.path}`); record.deletedAt = new Date(); } catch (e) { - log.error(e); + logger.error(e); } finally { complete(); item.resolve(record); @@ -186,8 +186,8 @@ class AdminAPI { processQueues() { if (this.lastStatusLog < new Date() - 60000) { - const { log } = this.context; - log.info(`Queues: preview=${this.previewQueue.length},` + const { logger } = this.context; + logger.info(`Queues: preview=${this.previewQueue.length},` + ` publish=${this.publishQueue.length},` + ` unpublish=${this.unpublishQueue.length},` + ` inflight=${this.inflight.length}`); diff --git a/actions/check-product-changes/poller.js b/actions/check-product-changes/poller.js index 68c4d3a..78734b5 100644 --- a/actions/check-product-changes/poller.js +++ b/actions/check-product-changes/poller.js @@ -12,18 +12,18 @@ governing permissions and limitations under the License. const { Timings, aggregate } = require('./lib/benchmark'); const { AdminAPI } = require('./lib/aem'); -const { requestSaaS, requestSpreadsheet, isValidUrl } = require('../utils'); +const { requestSaaS, requestSpreadsheet, isValidUrl, getProductUrl, mapLocale } = require('../utils'); const { GetAllSkusQuery, GetLastModifiedQuery } = require('../queries'); const { Core } = require('@adobe/aio-sdk'); const BATCH_SIZE = 50; -async function loadState(storeCode, stateLib) { - const stateKey = storeCode ? `${storeCode}` : 'default'; +async function loadState(locale, stateLib) { + const stateKey = locale ? `${locale}` : 'default'; const stateData = await stateLib.get(stateKey); if (!stateData?.value) { return { - storeCode, + locale, skusLastQueriedAt: new Date(0), skus: {}, }; @@ -34,7 +34,7 @@ async function loadState(storeCode, stateLib) { // folloed by a pair of SKUs and timestamps which are the last preview times per SKU const [catalogQueryTimestamp, ...skus] = stateData && stateData.value ? stateData.value.split(',') : [0]; return { - storeCode, + locale, skusLastQueriedAt: new Date(parseInt(catalogQueryTimestamp)), skus: Object.fromEntries(skus .map((sku, i, arr) => (i % 2 === 0 ? [sku, new Date(parseInt(arr[i + 1]))] : null)) @@ -43,11 +43,11 @@ async function loadState(storeCode, stateLib) { } async function saveState(state, stateLib) { - let { storeCode } = state; - if (!storeCode) { - storeCode = 'default'; + let { locale } = state; + if (!locale) { + locale = 'default'; } - const stateKey = `${storeCode}`; + const stateKey = `${locale}`; const stateData = [ state.skusLastQueriedAt.getTime(), ...Object.entries(state.skus).flatMap(([sku, lastPreviewedAt]) => [sku, lastPreviewedAt.getTime()]), @@ -61,28 +61,28 @@ async function saveState(state, stateLib) { * state accordingly. * * @param {Object} params - The parameters object. - * @param {string} params.siteName - The name of the site (repo or repoless). - * @param {string} params.PDPURIPrefix - The URI prefix for Product Detail Pages. + * @param {string} params.HLX_SITE_NAME - The name of the site (repo or repoless). + * @param {string} params.HLX_PATH_FORMAT - The URL format for product detail pages. * @param {string} params.PLPURIPrefix - The URI prefix for Product List Pages. - * @param {string} params.orgName - The name of the organization. - * @param {string} params.configName - The name of the configuration json/xlsx. + * @param {string} params.HLX_ORG_NAME - The name of the organization. + * @param {string} params.HLX_CONFIG_NAME - The name of the configuration json/xlsx. * @param {number} [params.requestPerSecond=5] - The number of requests per second allowed by the throttling logic. * @param {string} params.authToken - The authentication token. * @param {number} [params.skusRefreshInterval=600000] - The interval for refreshing SKUs in milliseconds. - * @param {string} [params.storeUrl] - The store's base URL. - * @param {string} [params.storeCodes] - Comma-separated list of store codes. + * @param {string} [params.HLX_STORE_URL] - The store's base URL. + * @param {string} [params.HLX_LOCALES] - Comma-separated list of allowed locales. * @param {string} [params.LOG_LEVEL] - The log level. * @param {Object} stateLib - The state provider object. * @returns {Promise} The result of the polling action. */ function checkParams(params) { - const requiredParams = ['siteName', 'PDPURIPrefix', 'PLPURIPrefix', 'orgName', 'configName', 'authToken']; + const requiredParams = ['HLX_SITE_NAME', 'HLX_PATH_FORMAT', 'PLPURIPrefix', 'HLX_ORG_NAME', 'HLX_CONFIG_NAME', 'authToken']; const missingParams = requiredParams.filter(param => !params[param]); if (missingParams.length > 0) { throw new Error(`Missing required parameters: ${missingParams.join(', ')}`); } - if (params.storeUrl && !isValidUrl(params.storeUrl)) { + if (params.HLX_STORE_URL && !isValidUrl(params.HLX_STORE_URL)) { throw new Error('Invalid storeUrl'); } } @@ -110,24 +110,24 @@ function shouldProcessProduct(product) { async function poll(params, stateLib) { checkParams(params); - const log = Core.Logger('main', { level: params.LOG_LEVEL || 'info' }); + const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' }); const { - siteName, - PDPURIPrefix, - orgName, - configName, + HLX_SITE_NAME: siteName, + HLX_PATH_FORMAT: pathFormat, + HLX_ORG_NAME: orgName, + HLX_CONFIG_NAME: configName, requestPerSecond = 5, authToken, skusRefreshInterval = 600000, } = params; - const storeUrl = params.storeUrl ? params.storeUrl : `https://main--${siteName}--${orgName}.aem.live`; - const storeCodes = params.storeCodes ? params.storeCodes.split(',') : [null]; + const storeUrl = params.HLX_STORE_URL ? params.HLX_STORE_URL : `https://main--${siteName}--${orgName}.aem.live`; + const locales = params.HLX_LOCALES ? params.HLX_LOCALES.split(',') : [null]; const counts = { published: 0, unpublished: 0, ignored: 0, failed: 0, }; const sharedContext = { - storeUrl, configName, log, counts, + storeUrl, configName, logger, counts, pathFormat, }; const timings = new Timings(); const adminApi = new AdminAPI({ @@ -135,18 +135,22 @@ async function poll(params, stateLib) { site: siteName, }, sharedContext, { requestPerSecond, authToken }); - log.info(`Starting poll from ${storeUrl} for store codes ${storeCodes}`); + logger.info(`Starting poll from ${storeUrl} for locales ${locales}`); try { // start processing preview and publish queues await adminApi.startProcessing(); - const results = await Promise.all(storeCodes.map(async (storeCode) => { + const results = await Promise.all(locales.map(async (locale) => { const timings = new Timings(); // load state - const state = await loadState(storeCode, stateLib); + const state = await loadState(locale, stateLib); timings.sample('loadedState'); - const context = { ...sharedContext, storeCode }; + + let context = { ...sharedContext }; + if (locale) { + context = { ...context, ...mapLocale(locale, context) }; + } // setup preview / publish queues @@ -173,7 +177,7 @@ async function poll(params, stateLib) { const skus = Object.keys(state.skus); const lastModifiedResp = await requestSaaS(GetLastModifiedQuery, 'getLastModified', { skus }, context); timings.sample('fetchedLastModifiedDates'); - log.info(`Fetched last modified date for ${lastModifiedResp.data.products.length} skus, total ${skus.length}`); + logger.info(`Fetched last modified date for ${lastModifiedResp.data.products.length} skus, total ${skus.length}`); // group preview in batches of 50 let products = lastModifiedResp.data.products @@ -198,7 +202,7 @@ async function poll(params, stateLib) { const batches = products.filter(shouldProcessProduct) .reduce((acc, product, i, arr) => { const { sku, urlKey } = product; - const path = (storeCode ? `/${storeCode}${PDPURIPrefix}/${urlKey}/${sku}` : `${PDPURIPrefix}/${urlKey}/${sku}`).toLowerCase(); + const path = getProductUrl({ urlKey, sku }, context, false).toLowerCase(); const req = adminApi.previewAndPublish({ path, sku }); acc.push(req); if (acc.length === BATCH_SIZE || i === arr.length - 1) { @@ -251,7 +255,7 @@ async function poll(params, stateLib) { } } catch (e) { // in case the index doesn't yet exist or any other error - log.error(e); + logger.error(e); } timings.sample('unpublishedPaths'); @@ -277,14 +281,14 @@ async function poll(params, stateLib) { } timings.measures.previewDuration = aggregate(adminApi.previewDurations); } catch (e) { - log.error(e); + logger.error(e); // wait for queues to finish, even in error case await adminApi.stopProcessing(); } const elapsed = new Date() - timings.now; -log.info(`Finished polling, elapsed: ${elapsed}ms`); +logger.info(`Finished polling, elapsed: ${elapsed}ms`); return { state: 'completed', diff --git a/actions/pdp-renderer/index.js b/actions/pdp-renderer/index.js index df7a1f1..6754b25 100644 --- a/actions/pdp-renderer/index.js +++ b/actions/pdp-renderer/index.js @@ -15,9 +15,9 @@ const path = require('path'); const { Core } = require('@adobe/aio-sdk') const Handlebars = require('handlebars'); -const { errorResponse, stringParameters, requestSaaS } = require('../utils'); +const { errorResponse, stringParameters, requestSaaS, mapLocale } = require('../utils'); const { extractPathDetails, findDescription, prepareBaseTemplate, getPrimaryImage, generatePriceString, getImageList } = require('./lib'); -const { ProductQuery } = require('../queries'); +const { ProductQuery, ProductByUrlKeyQuery } = require('../queries'); const { generateLdJson } = require('./ldJson'); function toTemplateProductData(baseProduct) { @@ -39,41 +39,78 @@ function toTemplateProductData(baseProduct) { * Parameters * @param {Object} params The parameters object * @param {string} params.__ow_path The path of the request - * @param {string} params.__ow_query The query parameters of the request - * @param {string} params.__ow_query.configName Overwrite for HLX_CONFIG_NAME - * @param {string} params.__ow_query.contentUrl Overwrite for HLX_CONTENT_URL - * @param {string} params.__ow_query.storeUrl Overwrite for HLX_STORE_URL - * @param {string} params.__ow_query.productsTemplate Overwrite for HLX_PRODUCTS_TEMPLATE + * @param {string} params.configName Overwrite for HLX_CONFIG_NAME using query parameter + * @param {string} params.contentUrl Overwrite for HLX_CONTENT_URL using query parameter + * @param {string} params.storeUrl Overwrite for HLX_STORE_URL using query parameter + * @param {string} params.productsTemplate Overwrite for HLX_PRODUCTS_TEMPLATE using query parameter + * @param {string} params.pathFormat Overwrite for HLX_PATH_FORMAT using query parameter * @param {string} params.HLX_CONFIG_NAME The config sheet to use (e.g. configs for prod, configs-dev for dev) * @param {string} params.HLX_CONTENT_URL Edge Delivery URL of the store (e.g. aem.live) * @param {string} params.HLX_STORE_URL Public facing URL of the store * @param {string} params.HLX_PRODUCTS_TEMPLATE URL to the products template page + * @param {string} params.HLX_PATH_FORMAT The path format to use for parsing */ async function main (params) { const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' }) try { logger.debug(stringParameters(params)) - const { __ow_path, __ow_query, HLX_STORE_URL, HLX_CONTENT_URL, HLX_CONFIG_NAME, HLX_PRODUCTS_TEMPLATE } = params; - const { sku } = extractPathDetails(__ow_path); - - const configName = __ow_query?.configName || HLX_CONFIG_NAME; - const contentUrl = __ow_query?.contentUrl || HLX_CONTENT_URL; - const storeUrl = __ow_query?.storeUrl || HLX_STORE_URL || contentUrl; - const productsTemplate = __ow_query?.productsTemplate || HLX_PRODUCTS_TEMPLATE; - const context = { contentUrl, storeUrl, configName, logger }; - - if (!sku || !contentUrl) { + const { + __ow_path, + pathFormat : pathFormatQuery, + configName : configNameQuery, + contentUrl : contentUrlQuery, + storeUrl : storeUrlQuery, + productsTemplate : productsTemplateQuery, + HLX_STORE_URL, + HLX_CONTENT_URL, + HLX_CONFIG_NAME, + HLX_PRODUCTS_TEMPLATE, + HLX_PATH_FORMAT, + HLX_LOCALES, + } = params; + + const pathFormat = pathFormatQuery || HLX_PATH_FORMAT || '/products/{urlKey}/{sku}'; + const configName = configNameQuery || HLX_CONFIG_NAME; + const contentUrl = contentUrlQuery || HLX_CONTENT_URL; + const storeUrl = storeUrlQuery || HLX_STORE_URL || contentUrl; + const productsTemplate = productsTemplateQuery || HLX_PRODUCTS_TEMPLATE; + const allowedLocales = HLX_LOCALES ? HLX_LOCALES.split(',').map(a => a.trim()) : []; + let context = { contentUrl, storeUrl, configName, logger, pathFormat, allowedLocales }; + + const result = extractPathDetails(__ow_path, pathFormat); + logger.debug('Path parse results', JSON.stringify(result, null, 4)); + const { sku, urlKey, locale } = result; + + if ((!sku && !urlKey) || !contentUrl) { return errorResponse(400, 'Invalid path', logger); } + // Map locale to context + if (locale) { + try { + context = { ...context, ...mapLocale(locale, context) }; + // eslint-disable-next-line no-unused-vars + } catch(e) { + return errorResponse(400, 'Invalid locale', logger); + } + } + // Retrieve base product - const baseProductData = await requestSaaS(ProductQuery, 'ProductQuery', { sku }, context); - if (!baseProductData.data.products || baseProductData.data.products.length === 0) { + let baseProduct; + if (sku) { + const baseProductData = await requestSaaS(ProductQuery, 'ProductQuery', { sku: sku.toUpperCase() }, context); + if (!baseProductData?.data?.products || baseProductData?.data?.products?.length === 0) { + return errorResponse(404, 'Product not found', logger); + } + baseProduct = baseProductData.data.products[0]; + } else if (urlKey) { + const baseProductData = await requestSaaS(ProductByUrlKeyQuery, 'ProductByUrlKey', { urlKey }, context); + if (!baseProductData?.data?.productSearch || baseProductData?.data?.productSearch?.items?.length === 0) { return errorResponse(404, 'Product not found', logger); + } + baseProduct = baseProductData.data.productSearch.items[0].productView; } - const baseProduct = baseProductData.data.products[0]; - logger.debug('Retrieved base product', JSON.stringify(baseProduct, null, 4)); // Assign meta tag data for template diff --git a/actions/pdp-renderer/ldJson.js b/actions/pdp-renderer/ldJson.js index 74cc52f..68a40f8 100644 --- a/actions/pdp-renderer/ldJson.js +++ b/actions/pdp-renderer/ldJson.js @@ -1,5 +1,5 @@ -const { requestSaaS } = require('../utils'); -const { getProductUrl, findDescription, getPrimaryImage } = require('./lib'); +const { requestSaaS, getProductUrl } = require('../utils'); +const { findDescription, getPrimaryImage } = require('./lib'); const { VariantsQuery } = require('../queries'); function getOffer(product, url) { @@ -64,9 +64,9 @@ async function getVariants(baseProduct, url, axes, context) { } async function generateLdJson(product, context) { - const { name, sku, urlKey, __typename } = product; + const { name, sku, __typename } = product; const image = getPrimaryImage(product); - const url = getProductUrl(urlKey, sku, context); + const url = getProductUrl(product, context); const gtin = ''; // TODO: Add based on your data model (https://schema.org/gtin) let ldJson; diff --git a/actions/pdp-renderer/lib.js b/actions/pdp-renderer/lib.js index 887f9b2..d554026 100644 --- a/actions/pdp-renderer/lib.js +++ b/actions/pdp-renderer/lib.js @@ -2,43 +2,35 @@ const striptags = require('striptags'); const cheerio = require('cheerio'); /** - * Extracts the SKU from the path. + * Extracts details from the path based on the provided format. * @param {string} path The path. - * @returns {Object} An object containing the SKU. + * @param {string} format The format to extract details from the path. + * @returns {Object} An object containing the extracted details. * @throws Throws an error if the path is invalid. */ -function extractPathDetails(path) { +function extractPathDetails(path, format) { if (!path) { return {}; } - // TODO: Extend to support store code as well if configured - // Strip leading slash if present - if (path.startsWith('/')) { - path = path.substring(1); - } + const formatParts = format.split('/').filter(Boolean); + const pathParts = path.split('/').filter(Boolean); - const pathParts = path.split('/'); - if (pathParts.length !== 3 || pathParts[0] !== 'products') { - throw new Error(`Invalid path. Expected '/products/{urlKey}/{sku}'`); + if (formatParts.length !== pathParts.length) { + throw new Error(`Invalid path. Expected '${format}' format.`); } - const sku = pathParts[2].toUpperCase(); - - return { sku }; -} + const result = {}; + formatParts.forEach((part, index) => { + if (part.startsWith('{') && part.endsWith('}')) { + const key = part.substring(1, part.length - 1); + result[key] = pathParts[index]; + } else if (part !== pathParts[index]) { + throw new Error(`Invalid path. Expected '${format}' format.`); + } + }); -/** - * Constructs the URL of a product. - * - * @param {string} urlKey The url key of the product. - * @param {string} sku The sku of the product. - * @param {Object} context The context object containing the store URL. - * @returns {string} The product url. - */ -function getProductUrl(urlKey, sku, context) { - const { storeUrl } = context; - return `${storeUrl}/products/${urlKey}/${sku}`; + return result; } /** @@ -174,4 +166,4 @@ function getImageList(primary, images) { return imageList; } -module.exports = { extractPathDetails, getProductUrl, findDescription, getPrimaryImage, prepareBaseTemplate, generatePriceString, getImageList }; +module.exports = { extractPathDetails, findDescription, getPrimaryImage, prepareBaseTemplate, generatePriceString, getImageList }; diff --git a/actions/queries.js b/actions/queries.js index 00e7c1a..afbda8f 100644 --- a/actions/queries.js +++ b/actions/queries.js @@ -1,139 +1,159 @@ -const ProductQuery = `query ProductQuery($sku: String!) { - products(skus: [$sku]) { - __typename +const PriceFragment = `fragment priceFields on ProductViewPrice { + roles + regular { + amount { + currency + value + } + } + final { + amount { + currency + value + } + } +}`; + +const ProductViewFragment = `fragment productViewFields on ProductView { + __typename + id + sku + name + url + description + shortDescription + metaDescription + metaKeyword + metaTitle + urlKey + inStock + externalId + lastModifiedAt + images(roles: []) { + url + label + roles + } + attributes(roles: ["visible_in_pdp"]) { + name + label + value + roles + } + ... on SimpleProductView { + price { + ...priceFields + } + } + ... on ComplexProductView { + options { id - sku - name - url - description - shortDescription - metaDescription - metaKeyword - metaTitle - urlKey - inStock - externalId - lastModifiedAt - images(roles: []) { - url - label - roles - } - attributes(roles: ["visible_in_pdp"]) { - name - label - value - roles - } - ... on SimpleProductView { - price { - ...priceFields - } - } - ... on ComplexProductView { - options { - id - title - required - values { - id - title - inStock - ... on ProductViewOptionValueSwatch { - type - value - } - } - } - priceRange { - maximum { - ...priceFields - } - minimum { - ...priceFields - } + title + required + values { + id + title + inStock + ... on ProductViewOptionValueSwatch { + type + value } } } - } - fragment priceFields on ProductViewPrice { - roles - regular { - amount { - currency - value + priceRange { + maximum { + ...priceFields + } + minimum { + ...priceFields } } - final { - amount { - currency - value + } +}`; + +const ProductQuery = `query ProductQuery($sku: String!) { + products(skus: [$sku]) { + ...productViewFields + } +} +${ProductViewFragment} +${PriceFragment}`; + +const ProductByUrlKeyQuery = `query ProductByUrlKey($urlKey: String!) { + productSearch( + current_page: 1 + filter: [{ attribute: "url_key", eq: $urlKey }] + page_size: 1 + phrase: "" + ) { + items { + productView { + ...productViewFields } } - }`; + } +} +${ProductViewFragment} +${PriceFragment}`; const VariantsQuery = `query VariantsQuery($sku: String!) { - variants(sku: $sku) { - variants { - selections - product { - sku + variants(sku: $sku) { + variants { + selections + product { + sku + name + inStock + images(roles: []) { + url + roles + } + attributes(roles: ["visible_in_pdp"]) { name - inStock - images(roles: []) { - url - roles - } - attributes(roles: ["visible_in_pdp"]) { - name - label - value + label + value + roles + } + ... on SimpleProductView { + price { roles - } - ... on SimpleProductView { - price { - roles - regular { - amount { - value - currency - } + regular { + amount { + value + currency } - final { - amount { - value - currency - } + } + final { + amount { + value + currency } } } } } } - }`; + } +}`; -const GetAllSkusQuery = ` - query getAllSkus { - productSearch(phrase: "", page_size: 500) { - items { - productView { - urlKey - sku - } +const GetAllSkusQuery = `query getAllSkus { + productSearch(phrase: "", page_size: 500) { + items { + productView { + urlKey + sku } } } -`; +}`; -const GetLastModifiedQuery = ` - query getLastModified($skus: [String]!) { - products(skus: $skus) { - sku - urlKey - lastModifiedAt - } +const GetLastModifiedQuery = `query getLastModified($skus: [String]!) { + products(skus: $skus) { + sku + urlKey + lastModifiedAt } -`; +}`; const GetAllSkusPaginatedQuery = `query getAllSkusPaginated($currentPage: Int!) { productSearch(phrase: "", page_size: 500, current_page: $currentPage) { @@ -144,11 +164,11 @@ const GetAllSkusPaginatedQuery = `query getAllSkusPaginated($currentPage: Int!) } } } -} -`; +}`; module.exports = { ProductQuery, + ProductByUrlKeyQuery, VariantsQuery, GetAllSkusQuery, GetAllSkusPaginatedQuery, diff --git a/actions/utils.js b/actions/utils.js index 50c2c57..f9ccbb3 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -112,7 +112,7 @@ function getBearerToken (params) { /** * - * Returns an error response object and attempts to log.info the status code and error message + * Returns an error response object and attempts to logger.info the status code and error message * * @param {number} statusCode the error status code. * e.g. 400 @@ -191,12 +191,8 @@ async function request(name, url, req, timeout = 60000) { * @returns {Promise} spreadsheet data as JSON. */ async function requestSpreadsheet(name, sheet, context) { - const { contentUrl, storeCode } = context; - let storeRoot = contentUrl; - if (storeCode) { - storeRoot += `/${storeCode}`; - } - let sheetUrl = `${storeRoot}/${name}.json` + const { contentUrl } = context; + let sheetUrl = `${contentUrl}/${name}.json` if (sheet) { sheetUrl += `?sheet=${sheet}`; } @@ -211,8 +207,9 @@ async function requestSpreadsheet(name, sheet, context) { * @returns {Promise} configuration as object. */ async function getConfig(context) { - const { configName = 'configs' } = context; + const { configName = 'configs', logger } = context; if (!context.config) { + logger.debug(`Fetching config ${configName}`); const configData = await requestSpreadsheet(configName, null, context); context.config = configData.data.reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}); } @@ -226,12 +223,11 @@ async function getConfig(context) { * @param {string} operationName name of the operation. * @param {object} variables query variables. * @param {object} context the context object. - * @param {object} [configOverrides] optional object to overwrite config values. * * @returns {Promise} GraphQL response as parsed object. */ -async function requestSaaS(query, operationName, variables, context, configOverrides = {}) { - const { storeUrl, logger } = context; +async function requestSaaS(query, operationName, variables, context) { + const { storeUrl, logger, configOverrides = {} } = context; const config = { ... (await getConfig(context)), ...configOverrides @@ -289,6 +285,65 @@ function isValidUrl(string) { } } +/** + * Constructs the URL of a product. + * + * @param {Object} product Product with sku and urlKey properties. + * @param {Object} context The context object containing the store URL and path format. + * @returns {string} The product url or null if storeUrl or pathFormat are missing. + */ +function getProductUrl(product, context, addStore = true) { + const { storeUrl, pathFormat } = context; + if (!storeUrl || !pathFormat) { + return null; + } + + const availableParams = { + sku: product.sku, + urlKey: product.urlKey, + locale: context.locale, + }; + + let path = pathFormat.split('/') + .filter(Boolean) + .map(part => { + if (part.startsWith('{') && part.endsWith('}')) { + const key = part.substring(1, part.length - 1); + return availableParams[key]; + } + return part; + }); + + if (addStore) { + path.unshift(storeUrl); + return path.join('/'); + } + + return `/${path.join('/')}`; +} + +/** + * Adjust the context according to the given locale. + * + * TODO: Customize this function to match your multi store setup + * + * @param {string} locale The locale to map. + * @returns {Object} An object containing the adjusted context. + */ +function mapLocale(locale, context) { + // Check if locale is valid + const allowedLocales = ['en', 'fr']; // Or use context.allowedLocales derived from HLX_LOCALES configuration + if (!locale || !allowedLocales.includes(locale)) { + throw new Error('Invalid locale'); + } + + // Example for dedicated config file per locale + return { + locale, + configName: [locale, context.configName].join('/'), + } +} + module.exports = { errorResponse, getBearerToken, @@ -299,4 +354,6 @@ module.exports = { request, requestSpreadsheet, isValidUrl, + getProductUrl, + mapLocale, } diff --git a/app.config.yaml b/app.config.yaml index 9c56012..30f9d33 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -4,17 +4,25 @@ application: packages: aem-commerce-ssg: license: Apache-2.0 + inputs: + LOG_LEVEL: debug + HLX_CONTENT_URL: "https://main--aem-boilerplate-commerce--hlxsites.aem.live" + HLX_PRODUCTS_TEMPLATE: "https://main--aem-boilerplate-commerce--hlxsites.aem.live/products/default" + HLX_ORG_NAME: "hlxsites" + HLX_SITE_NAME: "aem-boilerplate-commerce" + HLX_STORE_URL: "https://www.aemshop.net" + HLX_CONFIG_NAME: "configs" + HLX_PATH_FORMAT: "/products/{urlKey}/{sku}" + # HLX_LOCALES: comma-seprated list of allowed locales. + # i.e. us,uk,it,de,fr,es - or just one + # null if there is a single store and no + # URI prefixes are used + HLX_LOCALES: null actions: pdp-renderer: function: actions/pdp-renderer/index.js web: 'yes' runtime: nodejs:18 - inputs: - LOG_LEVEL: debug - HLX_CONTENT_URL: "https://main--aem-boilerplate-commerce--hlxsites.aem.live" - HLX_PRODUCTS_TEMPLATE: "https://main--aem-boilerplate-commerce--hlxsites.aem.live/products/default" - HLX_STORE_URL: "https://www.aemshop.net" - HLX_CONFIG_NAME: "configs" annotations: final: true include: @@ -27,19 +35,6 @@ application: memorySize: 128 timeout: 3600000 inputs: - LOG_LEVEL: debug - storeUrl: https://www.aemshop.net - orgName: hlxsites - siteName: aem-boilerplate-commerce - configName: configs - # storeCodes: comma-seprated list of store codes. - # i.e. us,uk,it,de,fr,es - or just one - # null if there is a single store and no - # URI prefixes are used - storeCodes: null - # PDPURIPrefix: URI prefix for the Product Pages - # i.e.: /products - PDPURIPrefix: /products authToken: ${AEM_ADMIN_API_AUTH_TOKEN} annotations: final: true diff --git a/e2e/pdp-ssg.e2e.test.js b/e2e/pdp-ssg.e2e.test.js index 825a58b..6b9c28d 100644 --- a/e2e/pdp-ssg.e2e.test.js +++ b/e2e/pdp-ssg.e2e.test.js @@ -21,7 +21,7 @@ const runtimePackage = 'aem-commerce-ssg' const actionUrl = `https://${namespace}.${hostname}/api/v1/web/${runtimePackage}/pdp-renderer` test('simple product markup', async () => { - const res = await fetch(`${actionUrl}/products/crown-summit-backpack/24-MB03`); + const res = await fetch(`${actionUrl}/products/crown-summit-backpack/24-mb03`); const content = await res.text(); // Parse markup and compare @@ -83,7 +83,7 @@ test('simple product markup', async () => { }); test('complex product markup', async () => { - const res = await fetch(`${actionUrl}/products/hollister-backyard-sweatshirt/MH05`); + const res = await fetch(`${actionUrl}/products/hollister-backyard-sweatshirt/mh05`); const content = await res.text(); // Parse markup and compare @@ -456,3 +456,14 @@ test('complex product markup', async () => { }; expect(ldJson).toEqual(expected); }); + +test('product by urlKey', async () => { + const res = await fetch(`${actionUrl}/crown-summit-backpack?pathFormat=/{urlKey}`); + const content = await res.text(); + + // Parse markup and compare + const $ = cheerio.load(content); + + // Validate H1 + expect($('h1').text()).toEqual('Crown Summit Backpack'); +}) diff --git a/test/check-product-changes.test.js b/test/check-product-changes.test.js index d43ae77..8eb349a 100644 --- a/test/check-product-changes.test.js +++ b/test/check-product-changes.test.js @@ -9,7 +9,7 @@ describe('Poller', () => { assert.deepEqual( state, { - storeCode: 'uk', + locale: 'uk', skus: {}, skusLastQueriedAt: new Date(0), } @@ -23,7 +23,7 @@ describe('Poller', () => { assert.deepEqual( state, { - storeCode: 'uk', + locale: 'uk', skus: { sku1: new Date(2), sku2: new Date(3), @@ -50,7 +50,7 @@ describe('Poller', () => { assert.deepEqual(newState, state); }); - it('loadState after saveState with null storeCode', async () => { + it('loadState after saveState with null locale', async () => { const stateLib = new State(0); await stateLib.put('default', '1,sku1,2,sku2,3,sku3,4'); const state = await loadState(null, stateLib); diff --git a/test/ldJson.test.js b/test/ldJson.test.js index ceea3f3..84c10b2 100644 --- a/test/ldJson.test.js +++ b/test/ldJson.test.js @@ -17,7 +17,7 @@ const { useMockServer, handlers } = require('./mock-server.js'); describe('ldJson', () => { - const mockContext = { contentUrl: 'https://content.com', storeUrl: 'https://example.com', configName: 'config', logger: { error: jest.fn() } }; + const mockContext = { contentUrl: 'https://content.com', storeUrl: 'https://example.com', configName: 'config', logger: { debug: jest.fn(), error: jest.fn() }, pathFormat: '/products/{urlKey}/{sku}' }; const server = useMockServer(); beforeEach(() => { diff --git a/test/lib.test.js b/test/lib.test.js index 88589e7..e452ceb 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -const { findDescription, getPrimaryImage, extractPathDetails, getProductUrl, generatePriceString } = require('../actions/pdp-renderer/lib'); +const { findDescription, getPrimaryImage, extractPathDetails, generatePriceString } = require('../actions/pdp-renderer/lib'); describe('lib', () => { test('findDescription', () => { @@ -42,18 +42,26 @@ describe('lib', () => { expect(getPrimaryImage({ images: [] })).toBeUndefined(); }); - test('extractPathDetails', () => { - expect(extractPathDetails('/products/urlKey/sku')).toEqual({ sku: 'SKU' }); - expect(extractPathDetails('products/urlKey/sku')).toEqual({ sku: 'SKU' }); - expect(() => extractPathDetails('/products/urlKey/sku/extra')).toThrow(`Invalid path. Expected '/products/{urlKey}/{sku}'`); - expect(() => extractPathDetails('/product/urlKey/sku')).toThrow(`Invalid path. Expected '/products/{urlKey}/{sku}'`); - expect(extractPathDetails('')).toEqual({}); - expect(extractPathDetails(null)).toEqual({}); - }); - test('getProductUrl', () => { - const context = { storeUrl: 'https://example.com' }; - expect(getProductUrl('urlKey', 'sku', context)).toBe('https://example.com/products/urlKey/sku'); + describe('extractPathDetails', () => { + test('extract sku and urlKey from path', () => { + expect(extractPathDetails('/products/my-url-key/my-sku', '/products/{urlKey}/{sku}')).toEqual({ sku: 'my-sku', urlKey: 'my-url-key' }); + }); + test('extract urlKey from path', () => { + expect(extractPathDetails('/my-url-key', '/{urlKey}')).toEqual({ urlKey: 'my-url-key' }); + }); + test('throw error if path is too long', () => { + expect(() => extractPathDetails('/products/my-url-key/my-sku', '/products/{urlKey}')).toThrow(`Invalid path. Expected '/products/{urlKey}' format.`); + }); + test('throw error if static part of path does not match', () => { + expect(() => extractPathDetails('/product/my-sku', '/products/{sku}')).toThrow(`Invalid path. Expected '/products/{sku}' format.`); + }); + test('empty object for empty path', () => { + expect(extractPathDetails('')).toEqual({}); + }); + test('empty object for null path', () => { + expect(extractPathDetails(null)).toEqual({}); + }); }); test('generatePriceString', () => { diff --git a/test/mock-responses/mock-product-ls.json b/test/mock-responses/mock-product-ls.json new file mode 100644 index 0000000..da1efe8 --- /dev/null +++ b/test/mock-responses/mock-product-ls.json @@ -0,0 +1,187 @@ +{ + "data": { + "productSearch": { + "items": [ + { + "productView": { + "__typename": "SimpleProductView", + "id": "TWpRdFRVSXdNdwBaR1ZtWVhWc2RBAFpqTTRZVEJrWlRBdE56WTBZaTAwTVdaaExXSmtNbU10TldKak1tWXpZemRpTXpsaABiV0ZwYmw5M1pXSnphWFJsWDNOMGIzSmwAWW1GelpRAFRVRkhNREExTXpZeE56STU", + "sku": "24-MB03", + "name": "Crown Summit Backpack", + "url": "http://www.aemshop.net/crown-summit-backpack.html", + "description": "

The Crown Summit Backpack is equally at home in a gym locker, study cube or a pup tent, so be sure yours is packed with books, a bag lunch, water bottles, yoga block, laptop, or whatever else you want in hand. Rugged enough for day hikes and camping trips, it has two large zippered compartments and padded, adjustable shoulder straps.

\r\n
    \r\n
  • Top handle.
  • \r\n
  • Grommet holes.
  • \r\n
  • Two-way zippers.
  • \r\n
  • H 20\" x W 14\" x D 12\".
  • \r\n
  • Weight: 2 lbs, 8 oz. Volume: 29 L.
  • \r\n
      ", + "shortDescription": "", + "metaDescription": "", + "metaKeyword": "", + "metaTitle": "", + "urlKey": "crown-summit-backpack", + "inStock": true, + "externalId": "7", + "lastModifiedAt": "2024-10-03T15:26:48.850Z", + "images": [ + { + "url": "http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0.jpg", + "label": "Image", + "roles": [ + "image", + "small_image", + "thumbnail" + ] + }, + { + "url": "http://www.aemshop.net/media/catalog/product/m/b/mb03-black-0_alt1.jpg", + "label": "Image", + "roles": [] + } + ], + "attributes": [ + { + "name": "activity", + "label": "Activity", + "value": [ + "Gym", + "Hiking", + "Overnight", + "School", + "Trail", + "Travel", + "Urban" + ], + "roles": [ + "visible_in_pdp", + "visible_in_compare_list", + "visible_in_search" + ] + }, + { + "name": "color", + "label": "Color", + "value": "", + "roles": [ + "visible_in_pdp", + "visible_in_compare_list", + "visible_in_plp", + "visible_in_search" + ] + }, + { + "name": "cost", + "label": "Cost", + "value": "", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "eco_collection", + "label": "Eco Collection", + "value": "no", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "erin_recommends", + "label": "Erin Recommends", + "value": "no", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "features_bags", + "label": "Features", + "value": [ + "Audio Pocket", + "Waterproof", + "Lightweight", + "Reflective", + "Laptop Sleeve" + ], + "roles": [ + "visible_in_pdp", + "visible_in_search" + ] + }, + { + "name": "material", + "label": "Material", + "value": [ + "Nylon", + "Polyester" + ], + "roles": [ + "visible_in_pdp", + "visible_in_search" + ] + }, + { + "name": "new", + "label": "New", + "value": "no", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "performance_fabric", + "label": "Performance Fabric", + "value": "no", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "sale", + "label": "Sale", + "value": "no", + "roles": [ + "visible_in_pdp" + ] + }, + { + "name": "strap_bags", + "label": "Strap/Handle", + "value": [ + "Adjustable", + "Double", + "Padded" + ], + "roles": [ + "visible_in_pdp", + "visible_in_search" + ] + }, + { + "name": "style_bags", + "label": "Style", + "value": "Backpack", + "roles": [ + "visible_in_pdp", + "visible_in_search" + ] + } + ], + "price": { + "roles": [ + "visible" + ], + "regular": { + "amount": { + "currency": "USD", + "value": 38 + } + }, + "final": { + "amount": { + "currency": "USD", + "value": 38 + } + } + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/test/mock-server.js b/test/mock-server.js index 00e67a1..f7bbd4a 100644 --- a/test/mock-server.js +++ b/test/mock-server.js @@ -17,6 +17,7 @@ const path = require('path'); const mockConfig = require('./mock-responses/mock-config.json'); const mockVariants = require('./mock-responses/mock-variants.json'); const mockProduct = require('./mock-responses/mock-product.json'); +const mockProductLs = require('./mock-responses/mock-product-ls.json'); const mockComplexProduct = require('./mock-responses/mock-complex-product.json'); const mockProductTemplate = fs.readFileSync(path.resolve(__dirname, './mock-responses/product-default.html'), 'utf8'); @@ -33,6 +34,10 @@ const handlers = { matcher?.(req); return HttpResponse.json(mockVariants); }), + defaultProductLiveSearch: (matcher) => graphql.query('ProductByUrlKey', (req) => { + matcher?.(req); + return HttpResponse.json(mockProductLs); + }), return404: (matcher) => graphql.query('ProductQuery', (req) => { matcher?.(req); return HttpResponse.json({ data: { products: [] }}); diff --git a/test/pdp-renderer.test.js b/test/pdp-renderer.test.js index f2e758e..275e7ca 100644 --- a/test/pdp-renderer.test.js +++ b/test/pdp-renderer.test.js @@ -11,6 +11,8 @@ governing permissions and limitations under the License. */ const cheerio = require('cheerio'); +const { http, HttpResponse } = require('msw'); + const { useMockServer, handlers } = require('./mock-server.js'); jest.mock('@adobe/aio-sdk', () => ({ @@ -97,6 +99,89 @@ describe('pdp-renderer', () => { expect($('body > main > div')).toHaveLength(1); }); + test('get product by sku', async () => { + server.use(handlers.defaultProduct()); + + const response = await action.main({ + HLX_STORE_URL: 'https://store.com', + HLX_CONTENT_URL: 'https://content.com', + HLX_CONFIG_NAME: 'config', + HLX_PATH_FORMAT: '/products/{sku}', + __ow_path: `/products/24-MB03`, + }); + + const $ = cheerio.load(response.body); + expect($('body > main > div.product-details > div > div > h1').text()).toEqual('Crown Summit Backpack'); + }); + + test('get product by urlKey', async () => { + server.use(handlers.defaultProductLiveSearch()); + + const response = await action.main({ + HLX_STORE_URL: 'https://store.com', + HLX_CONTENT_URL: 'https://content.com', + HLX_CONFIG_NAME: 'config', + HLX_PATH_FORMAT: '/{urlKey}', + __ow_path: `/crown-summit-backpack`, + }); + + const $ = cheerio.load(response.body); + expect($('body > main > div.product-details > div > div > h1').text()).toEqual('Crown Summit Backpack'); + }); + + test('render product with locale', async () => { + server.use(handlers.defaultProduct()); + + let configRequestUrl; + const mockConfig = require('./mock-responses/mock-config.json'); + server.use(http.get('https://content.com/en/config.json', async (req) => { + configRequestUrl = req.request.url; + return HttpResponse.json(mockConfig); + })); + + const response = await action.main({ + HLX_STORE_URL: 'https://store.com', + HLX_CONTENT_URL: 'https://content.com', + HLX_CONFIG_NAME: 'config', + HLX_PATH_FORMAT: '/{locale}/products/{sku}', + __ow_path: `/en/products/24-MB03`, + }); + + expect(configRequestUrl).toBe('https://content.com/en/config.json'); + + // Validate product + const $ = cheerio.load(response.body); + expect($('body > main > div.product-details > div > div > h1').text()).toEqual('Crown Summit Backpack'); + + // Validate product url in structured data + const ldJson = JSON.parse($('head > script[type="application/ld+json"]').html()); + expect(ldJson.offers[0].url).toEqual('https://store.com/en/products/24-MB03'); + }); + + test('return 400 if locale is not supported', async () => { + const response = await action.main({ + HLX_STORE_URL: 'https://store.com', + HLX_CONTENT_URL: 'https://content.com', + HLX_CONFIG_NAME: 'config', + HLX_PATH_FORMAT: '/{locale}/products/{sku}', + __ow_path: `/test/products/24-MB03`, + }); + + expect(response.error.statusCode).toEqual(400); + }); + + test('return 400 if neither sku nor urlKey are provided', async () => { + const response = await action.main({ + HLX_STORE_URL: 'https://store.com', + HLX_CONTENT_URL: 'https://content.com', + HLX_CONFIG_NAME: 'config', + HLX_PATH_FORMAT: '/{urlPath}', + __ow_path: `/crown-summit-backpack`, + }); + + expect(response.error.statusCode).toEqual(400); + }); + test('render images', async () => { server.use(handlers.defaultProduct()); diff --git a/test/utils.test.js b/test/utils.test.js index 47805b5..f889124 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -11,7 +11,7 @@ governing permissions and limitations under the License. */ const { useMockServer } = require('./mock-server'); -const { errorResponse, stringParameters, checkMissingRequestInputs, getBearerToken, request, requestSpreadsheet, getConfig, requestSaaS } = require('./../actions/utils.js'); +const { errorResponse, stringParameters, checkMissingRequestInputs, getBearerToken, request, requestSpreadsheet, getConfig, requestSaaS, getProductUrl } = require('./../actions/utils.js'); const { http, HttpResponse } = require('msw'); test('interface', () => { @@ -128,7 +128,17 @@ describe('request', () => { return HttpResponse.json({ data: [{ key: 'testKey', value: 'testValue' }] }); })); - const context = { contentUrl: 'https://content.com' }; + const context = { contentUrl: 'https://content.com', logger: { debug: jest.fn() } }; + const config = await getConfig(context); + expect(config).toEqual({ testKey: 'testValue' }); + }); + + test('getConfig with subpath', async () => { + server.use(http.get('https://content.com/en/configs.json', async () => { + return HttpResponse.json({ data: [{ key: 'testKey', value: 'testValue' }] }); + })); + + const context = { configName: 'en/configs', contentUrl: 'https://content.com', logger: { debug: jest.fn() } }; const config = await getConfig(context); expect(config).toEqual({ testKey: 'testValue' }); }); @@ -211,25 +221,25 @@ describe('request', () => { }); test('requestSpreadsheet', async () => { - server.use(http.get('https://content.com/test/config.json', async () => { + server.use(http.get('https://content.com/config.json', async () => { return HttpResponse.json({ data: [{ key: 'testKey', value: 'testValue' }] }); })); - const context = { contentUrl: 'https://content.com', storeCode: 'test' }; + const context = { contentUrl: 'https://content.com' }; const data = await requestSpreadsheet('config', null, context); expect(data).toEqual({ data: [{ key: 'testKey', value: 'testValue' }] }); }); test('requestSpreadsheet with sheet', async () => { let requestUrl; - server.use(http.get('https://content.com/test2/config.json', async ({ request }) => { + server.use(http.get('https://content.com/config.json', async ({ request }) => { requestUrl = request.url; return HttpResponse.json({ data: [{ key: 'testKey', value: 'testValue' }] }); })); - const context = { contentUrl: 'https://content.com', storeCode: 'test2' }; + const context = { contentUrl: 'https://content.com' }; await requestSpreadsheet('config', 'testSheet', context); - expect(requestUrl).toEqual('https://content.com/test2/config.json?sheet=testSheet'); + expect(requestUrl).toEqual('https://content.com/config.json?sheet=testSheet'); }); test('successful request', async () => { @@ -257,3 +267,30 @@ describe('request', () => { await expect(request('testRequest', 'https://example.com/timeout', {}, 100)).rejects.toThrow('This operation was aborted'); }); }); + +describe('getProductUrl', () => { + test('getProductUrl with urlKey and sku', () => { + const context = { storeUrl: 'https://example.com', pathFormat: '/products/{urlKey}/{sku}' }; + expect(getProductUrl({ urlKey: 'my-url-key', sku: 'my-sku' }, context)).toBe('https://example.com/products/my-url-key/my-sku'); + }); + + test('getProductUrl with urlKey', () => { + const context = { storeUrl: 'https://example.com', pathFormat: '/{urlKey}' }; + expect(getProductUrl({ urlKey: 'my-url-key' }, context)).toBe('https://example.com/my-url-key'); + }); + + test('return null for missing storeUrl', () => { + const context = { pathFormat: '/{urlKey}' }; + expect(getProductUrl({ urlKey: 'my-url-key' }, context)).toBe(null); + }); + + test('return null for missing pathFormat', () => { + const context = { storeUrl: 'https://example.com' }; + expect(getProductUrl({ urlKey: 'my-url-key' }, context)).toBe(null); + }); + + test('getProductUrl with path only', () => { + const context = { storeUrl: 'https://example.com', pathFormat: '/{locale}/products/{urlKey}/{sku}', locale: 'de' }; + expect(getProductUrl({ urlKey: 'my-url-key', sku: 'my-sku' }, context, false)).toBe('/de/products/my-url-key/my-sku'); + }); +});