diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index e4a036128..f0edae4ae 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -212,6 +212,8 @@ paths: $ref: './sites-api.yaml#/site-brand-profile' /sites/{siteId}/config/cdn-logs: $ref: './site-api.yaml#/site-cdn-logs-config' + /sites/{siteId}/page-relationships/search: + $ref: './site-api.yaml#/site-page-relationships-search' /sites/{siteId}/opportunities: $ref: './site-opportunities.yaml#/site-opportunities' /sites/{siteId}/opportunities/by-status/{status}: diff --git a/docs/openapi/examples.yaml b/docs/openapi/examples.yaml index d44fcd332..e67f54ea8 100644 --- a/docs/openapi/examples.yaml +++ b/docs/openapi/examples.yaml @@ -631,4 +631,85 @@ customer-config-v2-patch: origin: "manual" source: "api" categoryId: "859b91c5-0420-4f3b-a464-8d6f6ac34f64" - topicId: "topic-acrobat-features" \ No newline at end of file + topicId: "topic-acrobat-features" + +page-relationships-search-request: + description: Example request body for on-demand page relationship search + value: + pages: + - key: "row-1" + pageUrl: "/de/de/adventures" + suggestionType: "Missing Title" + - key: "row-2" + pageUrl: "/us/en/adventures/camping" + suggestionType: "Missing Description" + +page-relationships-search-response: + description: Relationship lookup response with resolved relationships + value: + supported: true + relationships: + "row-1": + pagePath: "/de/de/adventures" + pageId: "123e4567-e89b-12d3-a456-426614174000" + chain: + - pageId: "123e4567-e89b-12d3-a456-426614174010" + pagePath: "/content/site/language-masters/de/adventures" + metadata: + sourceType: "liveCopyOf" + - pageId: "123e4567-e89b-12d3-a456-426614174011" + pagePath: "/content/site/language-masters/en/adventures" + metadata: + sourceType: "langCopyOf" + "row-2": + pagePath: "/us/en/adventures/camping" + pageId: "123e4567-e89b-12d3-a456-426614174001" + chain: + - pageId: "123e4567-e89b-12d3-a456-426614174012" + pagePath: "/content/site/us/adventures" + metadata: + sourceType: "liveCopyOf" + - pageId: "123e4567-e89b-12d3-a456-426614174013" + pagePath: "/content/site/global/adventures" + metadata: + sourceType: "langCopyOf" + errors: {} + +page-relationships-search-no-relationship-response: + description: Relationship lookup response where no source relationship is detected for a page + value: + supported: true + relationships: + "row-1": + pagePath: "/de/de/adventures" + pageId: "123e4567-e89b-12d3-a456-426614174000" + chain: [] + errors: {} + +page-relationships-search-partial-response: + description: Relationship lookup response with partial per-page failures + value: + supported: true + relationships: + "row-2": + pagePath: "/us/en/adventures/camping" + pageId: "123e4567-e89b-12d3-a456-426614174002" + chain: + - pageId: "123e4567-e89b-12d3-a456-426614174020" + pagePath: "/content/site/language-masters/de/adventures" + metadata: + sourceType: "langCopyOf" + - pageId: "123e4567-e89b-12d3-a456-426614174021" + pagePath: "/content/site/language-masters/en/adventures" + metadata: + sourceType: "langCopyOf" + errors: + "row-1": + error: "Could not determine page ID" + +page-relationships-search-unsupported-response: + description: Response when page relationships are not supported for a site + value: + supported: false + relationships: {} + errors: {} diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 4b334e763..41f26eace 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -6129,3 +6129,125 @@ SentimentConfig: items: $ref: '#/SentimentGuideline' description: Guidelines (optionally filtered by audit type) + +PageRelationshipsSearchPage: + type: object + required: + - pageUrl + - key + properties: + pageUrl: + type: string + description: Page path or absolute URL to resolve. + example: '/us/en/products' + suggestionType: + type: string + description: Suggestion/issue classifier used by the backend to apply relationship checks. + example: 'Missing Title' + key: + type: string + description: Caller-provided key used in relationships/errors maps. + example: 'row-1' + +PageRelationshipChainItem: + type: object + description: | + A single step in the relationship lineage. + Chain items are ordered from the requested page's immediate parent upward through ancestors. + required: + - pageId + - pagePath + properties: + pageId: + type: string + description: page identifier for this chain. + example: '123e4567-e89b-12d3-a456-426614174000' + pagePath: + type: string + description: Page path for this chain hop. + example: '/language-masters/en/adventures' + metadata: + $ref: '#/PageRelationshipMetadata' + +PageRelationshipMetadata: + type: object + additionalProperties: true + properties: + sourceType: + oneOf: + - type: string + - type: "null" + description: | + Source classification for this chain entry from the relationship provider. + Value is provider-defined and intentionally not constrained. + +PageRelationship: + type: object + required: + - pagePath + - pageId + - chain + properties: + pagePath: + type: string + description: Normalized page path or absolute URL for the requested page. + example: '/us/en/adventures' + pageId: + type: string + description: Resolved page identifier used by downstream fix operations. + example: 'pg-123' + chain: + type: array + description: | + Ordered relationship lineage returned by content API. + Sequence starts with the immediate parent of the requested page, + then grandparent, and so on. + items: + $ref: '#/PageRelationshipChainItem' + +PageRelationshipError: + type: object + required: + - error + properties: + error: + type: string + example: 'Could not determine page ID' + detail: + type: string + example: 'HTTP 404' + +PageRelationshipsMapping: + type: object + additionalProperties: + $ref: '#/PageRelationship' + +PageRelationshipsErrorMapping: + type: object + additionalProperties: + $ref: '#/PageRelationshipError' + +PageRelationshipsSearchRequest: + type: object + required: + - pages + properties: + pages: + type: array + items: + $ref: '#/PageRelationshipsSearchPage' + +PageRelationshipsSearchResponse: + type: object + required: + - supported + - relationships + - errors + properties: + supported: + type: boolean + example: true + relationships: + $ref: '#/PageRelationshipsMapping' + errors: + $ref: '#/PageRelationshipsErrorMapping' diff --git a/docs/openapi/site-api.yaml b/docs/openapi/site-api.yaml index a5e382a59..2f38ee092 100644 --- a/docs/openapi/site-api.yaml +++ b/docs/openapi/site-api.yaml @@ -150,6 +150,58 @@ site-cdn-logs-config: '500': $ref: './responses.yaml#/500' +site-page-relationships-search: + parameters: + - $ref: './parameters.yaml#/siteId' + post: + tags: + - site + summary: Search page relationships for caller-provided pages + description: | + Resolves page relationship context for the provided set of page paths/URLs. + This endpoint is intended for on-demand UI flows (for example, fix-confirmation popups), + where only specific pages need relationship context at request time. + The returned `relationships[*].chain` is ordered from the immediate parent + of the requested page to grandparent, and so on toward the root. + operationId: searchSitePageRelationships + security: + - ims_key: [ ] + requestBody: + required: true + content: + application/json: + schema: + $ref: './schemas.yaml#/PageRelationshipsSearchRequest' + examples: + page-relationships-search-request: + $ref: './examples.yaml#/page-relationships-search-request' + responses: + '200': + description: Relationship lookup completed + content: + application/json: + schema: + $ref: './schemas.yaml#/PageRelationshipsSearchResponse' + examples: + page-relationships-search-response: + $ref: './examples.yaml#/page-relationships-search-response' + page-relationships-search-no-relationship-response: + $ref: './examples.yaml#/page-relationships-search-no-relationship-response' + page-relationships-search-partial-response: + $ref: './examples.yaml#/page-relationships-search-partial-response' + page-relationships-search-unsupported-response: + $ref: './examples.yaml#/page-relationships-search-unsupported-response' + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403-site-access-forbidden' + '404': + $ref: './responses.yaml#/404-site-not-found-with-id' + '500': + $ref: './responses.yaml#/500' + site-reports: parameters: - $ref: './parameters.yaml#/siteId' diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js new file mode 100644 index 000000000..7eec6190b --- /dev/null +++ b/src/controllers/page-relationships.js @@ -0,0 +1,331 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + hasText, + isNonEmptyArray, + isValidUUID, +} from '@adobe/spacecat-shared-utils'; +import { + badRequest, + createResponse, + forbidden, + notFound, + internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import AccessControlUtil from '../support/access-control-util.js'; +import { getIMSPromiseToken, exchangePromiseToken, ErrorWithStatusCode } from '../support/utils.js'; +import { + isAEMAuthoredSite, + resolvePageIds, + fetchRelationships, + buildCheckPath, +} from '../support/aem-content-api.js'; + +const MAX_PAGES = 50; +const EMPTY_RELATIONSHIPS_RESPONSE = { + supported: false, + relationships: {}, + errors: {}, +}; + +function chunkPages(pages, chunkSize) { + const chunks = []; + for (let i = 0; i < pages.length; i += chunkSize) { + chunks.push(pages.slice(i, i + chunkSize)); + } + return chunks; +} + +function normalizePageUrlForLookup(pageUrl, siteBaseURL) { + const trimmed = pageUrl.trim(); + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed; + } + + try { + const siteUrl = new URL(siteBaseURL); + const suggestionUrl = new URL(trimmed); + if (suggestionUrl.host !== siteUrl.host) { + return trimmed; + } + return suggestionUrl.pathname || '/'; + } catch (e) { + return trimmed; + } +} + +function getStatusFromError(error) { + if (Number.isInteger(error?.statusCode)) { + return error.statusCode; + } + if (Number.isInteger(error?.status)) { + return error.status; + } + return null; +} + +function getSourceType(value) { + if (!hasText(value)) { + return null; + } + return String(value).trim(); +} + +function mapRelationship(rawRelationship) { + let rawChain = []; + if (Array.isArray(rawRelationship?.upstream?.chain)) { + rawChain = rawRelationship.upstream.chain; + } else if (Array.isArray(rawRelationship?.chain)) { + rawChain = rawRelationship.chain; + } + + const relationshipSourceType = getSourceType(rawRelationship?.metadata?.sourceType); + + const chain = rawChain + .map((edge) => { + const pageId = [ + edge?.pageId, + edge?.id, + edge?.page?.pageId, + edge?.page?.id, + ].find(hasText)?.trim(); + const pagePath = [ + edge?.pagePath, + edge?.path, + typeof edge?.page === 'string' ? edge.page : undefined, + edge?.page?.pagePath, + edge?.page?.path, + ].find(hasText)?.trim(); + if (!pageId || !pagePath) { + return null; + } + const sourceType = getSourceType(edge?.sourceType || edge?.relation || edge?.type) + || relationshipSourceType + || null; + const chainItem = { + pageId, + pagePath, + }; + if (sourceType) { + chainItem.metadata = { sourceType }; + } + return chainItem; + }) + .filter(Boolean); + + const relationship = { + chain, + }; + if (hasText(rawRelationship?.pageId)) { + relationship.pageId = rawRelationship.pageId.trim(); + } + + return relationship; +} + +/** + * Page relationships controller: proxy to AEM Content API for upstream relationship data. + * Used for on-demand popup lookups with caller-provided pages. + * @param {object} ctx - Context with dataAccess, log. + * @returns {object} Controller with search. + */ +function PageRelationshipsController(ctx) { + const { dataAccess, log } = ctx; + if (!dataAccess) { + throw new Error('Data access required'); + } + + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + function getSupportState(site) { + const deliveryType = site.getDeliveryType(); + if (!isAEMAuthoredSite(deliveryType)) { + return { + supported: false, + relationships: {}, + errors: {}, + }; + } + + const deliveryConfig = site.getDeliveryConfig(); + const authorURL = deliveryConfig?.authorURL; + if (!hasText(authorURL)) { + return { + supported: false, + relationships: {}, + errors: {}, + }; + } + + return { supported: true, deliveryConfig, authorURL }; + } + + async function lookupRelationships(site, pages, imsToken, options = {}) { + const { deliveryConfig, authorURL } = options; + const baseURL = site.getBaseURL(); + if (!hasText(baseURL)) { + return { + relationships: {}, + errors: { _config: { error: 'Site has no baseURL' } }, + }; + } + + const allRelationships = {}; + const allErrors = {}; + const pageChunks = chunkPages(pages, MAX_PAGES); + + for (const pageBatch of pageChunks) { + const normalizedBatch = pageBatch.map((pageSpec) => ({ + ...pageSpec, + normalizedPageUrl: normalizePageUrlForLookup(pageSpec.pageUrl, baseURL), + })); + const pageUrls = normalizedBatch.map((pageSpec) => pageSpec.normalizedPageUrl); + // eslint-disable-next-line no-await-in-loop + const resolved = await resolvePageIds( + baseURL, + authorURL, + pageUrls, + imsToken, + log, + ); + + const items = []; + const resolveErrors = {}; + const relationshipContextByKey = {}; + + for (let i = 0; i < normalizedBatch.length; i += 1) { + const pageSpec = normalizedBatch[i]; + const r = resolved[i] || {}; + const suggestionType = hasText(pageSpec.suggestionType) ? pageSpec.suggestionType : ''; + const responseKey = pageSpec.key; + if (r.error || !r.pageId) { + resolveErrors[responseKey] = { error: r.error || 'Could not resolve page' }; + } else { + relationshipContextByKey[responseKey] = { + pagePath: pageSpec.normalizedPageUrl, + pageId: r.pageId, + }; + const checkPath = buildCheckPath(suggestionType, deliveryConfig); + const item = { + key: pageSpec.key, + pageId: r.pageId, + include: ['upstream'], + }; + if (hasText(checkPath)) { + item.checkPath = checkPath; + } + items.push(item); + } + } + + Object.assign(allErrors, resolveErrors); + + if (items.length > 0) { + // eslint-disable-next-line no-await-in-loop + const aemResponse = await fetchRelationships(authorURL, items, imsToken, log); + const mappedResultEntries = Object.entries(aemResponse.results || {}) + .map(([key, value]) => { + const relationshipContext = relationshipContextByKey[key]; + if (!hasText(relationshipContext?.pagePath) || !hasText(relationshipContext?.pageId)) { + return null; + } + const mappedRelationship = mapRelationship(value); + mappedRelationship.pagePath = relationshipContext.pagePath; + mappedRelationship.pageId = relationshipContext.pageId; + return [key, mappedRelationship]; + }) + .filter(Boolean); + const mappedResults = Object.fromEntries( + mappedResultEntries, + ); + Object.assign(allRelationships, mappedResults); + Object.assign(allErrors, aemResponse.errors); + } + } + + return { + relationships: allRelationships, + errors: allErrors, + }; + } + + /** + * POST /sites/:siteId/page-relationships/search + * Resolves page relationships for caller-provided pages. + * Returns { supported, relationships, errors }. + */ + async function search(context) { + const siteId = context.params?.siteId; + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + + const site = await dataAccess.Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('Only users belonging to the organization can access this site'); + } + + const pages = context.data?.pages; + if (!isNonEmptyArray(pages)) { + return badRequest('pages array required'); + } + if (pages.some((page) => !page || !hasText(page.pageUrl))) { + return badRequest('Each page must include a non-empty pageUrl'); + } + if (pages.some((page) => !hasText(page.key))) { + return badRequest('Each page must include a non-empty key'); + } + + const supportState = getSupportState(site); + if (!supportState.supported) { + return createResponse(EMPTY_RELATIONSHIPS_RESPONSE); + } + const { deliveryConfig, authorURL } = supportState; + + let imsToken; + try { + const promiseTokenResponse = await getIMSPromiseToken(context); + imsToken = await exchangePromiseToken(context, promiseTokenResponse.promise_token); + } catch (e) { + if (e instanceof ErrorWithStatusCode) { + return createResponse({ message: e.message }, e.status || 400); + } + const status = getStatusFromError(e); + const detail = [e.statusCode, e.status, e.message].filter(Boolean).join(' ') || e.message || 'Unknown error'; + if (status && status >= 400 && status < 500) { + return createResponse({ message: `Problem getting IMS token: ${detail}` }, status); + } + log.error(`Problem getting IMS token for site ${siteId}: ${detail}`); + return internalServerError('Error getting IMS token'); + } + + const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { + deliveryConfig, + authorURL, + }); + + return createResponse({ + supported: true, + relationships, + errors, + }); + } + + return { search }; +} + +export default PageRelationshipsController; diff --git a/src/index.js b/src/index.js index 1f56bd33d..100189751 100644 --- a/src/index.js +++ b/src/index.js @@ -82,6 +82,7 @@ import PTA2Controller from './controllers/paid/pta2.js'; import TrafficToolsController from './controllers/paid/traffic-tools.js'; import BotBlockerController from './controllers/bot-blocker.js'; import SentimentController from './controllers/sentiment.js'; +import PageRelationshipsController from './controllers/page-relationships.js'; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -207,6 +208,7 @@ async function run(request, context) { const trafficToolsController = TrafficToolsController(context, log, context.env); const botBlockerController = BotBlockerController(context, log); const sentimentController = SentimentController(context, log); + const pageRelationshipsController = PageRelationshipsController(context); const routeHandlers = getRouteHandlers( auditsController, @@ -247,6 +249,7 @@ async function run(request, context) { trafficToolsController, botBlockerController, sentimentController, + pageRelationshipsController, ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/routes/index.js b/src/routes/index.js index b6f1ac92d..112b61139 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -85,6 +85,7 @@ function isStaticRoute(routePattern) { * @param {Object} trafficToolsController - The traffic tools controller. * @param {Object} botBlockerController - The bot blocker controller. * @param {Object} sentimentController - The sentiment controller. + * @param {Object} pageRelationshipsController - The page relationships controller. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ export default function getRouteHandlers( @@ -126,6 +127,7 @@ export default function getRouteHandlers( trafficToolsController, botBlockerController, sentimentController, + pageRelationshipsController, ) { const staticRoutes = {}; const dynamicRoutes = {}; @@ -209,6 +211,7 @@ export default function getRouteHandlers( 'GET /sites/:siteId/opportunities/top-paid': topPaidOpportunitiesController.getTopPaidOpportunities, 'GET /sites/:siteId/opportunities/by-status/:status': opportunitiesController.getByStatus, 'GET /sites/:siteId/opportunities/:opportunityId': opportunitiesController.getByID, + 'POST /sites/:siteId/page-relationships/search': pageRelationshipsController.search, 'POST /sites/:siteId/opportunities': opportunitiesController.createOpportunity, 'PATCH /sites/:siteId/opportunities/:opportunityId': opportunitiesController.patchOpportunity, 'DELETE /sites/:siteId/opportunities/:opportunityId': opportunitiesController.removeOpportunity, diff --git a/src/support/aem-content-api.js b/src/support/aem-content-api.js new file mode 100644 index 000000000..c62699f52 --- /dev/null +++ b/src/support/aem-content-api.js @@ -0,0 +1,149 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + DELIVERY_TYPES, + determineAEMCSPageId, +} from '@adobe/spacecat-shared-utils'; + +const AEM_AUTHORED_TYPES = [ + DELIVERY_TYPES.AEM_CS, + DELIVERY_TYPES.AEM_AMS, +]; + +/** + * Whether the delivery type supports AEM Content API (page relationships, resolve). + * @param {string} deliveryType - Site delivery type. + * @returns {boolean} + */ +export function isAEMAuthoredSite(deliveryType) { + return deliveryType && AEM_AUTHORED_TYPES.includes(deliveryType); +} + +/** + * Resolve page URLs to AEM page IDs using the shared determineAEMCSPageId + * utility (fetches HTML, reads content-page-ref / content-page-id meta, + * resolves via AEM Content API when needed). + * @param {string} siteBaseURL - Published site base URL. + * @param {string} authorURL - AEM author URL. + * @param {string[]} pageUrls - Page paths (e.g. /us/en/products). + * @param {string} imsToken - Bearer token (without "Bearer " prefix). + * @param {object} log - Logger. + * @returns {Promise>} + */ +export async function resolvePageIds(siteBaseURL, authorURL, pageUrls, imsToken, log) { + const base = siteBaseURL.replace(/\/$/, ''); + const bearerToken = `Bearer ${imsToken}`; + const out = []; + + for (const pageUrl of pageUrls) { + const normalized = typeof pageUrl === 'string' ? pageUrl.trim() : ''; + if (!normalized) { + out.push({ url: pageUrl, error: 'Invalid pageUrl' }); + } else { + const slash = normalized.startsWith('/') ? '' : '/'; + const fullUrl = `${base}${slash}${normalized}`; + try { + /* eslint-disable-next-line no-await-in-loop -- sequential per page */ + const pageId = await determineAEMCSPageId( + fullUrl, + authorURL, + bearerToken, + true, + log, + ); + if (pageId) { + out.push({ url: normalized, pageId }); + } else { + out.push({ url: normalized, error: 'Could not determine page ID' }); + } + } catch (e) { + log.warn(`resolvePageIds failed for ${normalized}: ${e.message}`); + out.push({ url: normalized, error: e.message }); + } + } + } + + return out; +} + +/** + * Call AEM POST .../adobe/pages/relationships/search. + * @param {string} authorURL - AEM author base URL. + * @param {Array<{ key: string, pageId: string, include: string[], checkPath?: string }>} items + * Batch items. + * @param {string} imsToken - Bearer token. + * @param {object} log - Logger. + * @returns {Promise<{ results: object, errors: object }>} + */ +export async function fetchRelationships(authorURL, items, imsToken, log) { + const base = authorURL.replace(/\/$/, ''); + const url = `${base}/adobe/pages/relationships/search`; + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${imsToken}`, + }, + body: JSON.stringify({ items }), + }); + if (!res.ok) { + log.warn(`Relationships search returned ${res.status}`); + return { + results: {}, + errors: { default: { error: `HTTP ${res.status}` } }, + }; + } + const data = await res.json(); + return { + results: data.results || {}, + errors: data.errors || {}, + }; + } catch (e) { + log.warn(`Relationships search error: ${e.message}`); + return { + results: {}, + errors: { default: { error: e.message } }, + }; + } +} + +const METATAG_PATTERNS = [ + { regex: /\btitle\b/i, property: 'title', defaultJcr: 'jcr:title' }, + { regex: /\bdescription\b/i, property: 'description', defaultJcr: 'jcr:description' }, +]; + +/** + * Build checkPath for relationship API from suggestion type and delivery config. + * Detects which metatag property the suggestion targets (title / description) + * by matching keywords in the issue string, then resolves to a JCR property + * path via metaTagPropertyMap or known defaults. Returns undefined when the + * suggestion does not target a known metatag property. + * @param {string} [suggestionType] - Issue string, e.g. "Missing title", + * "Title too short", "Missing meta description", "Duplicate title". + * @param {object} [deliveryConfig] - Site delivery config. + * @returns {string|undefined} + */ +export function buildCheckPath(suggestionType, deliveryConfig = {}) { + if (!suggestionType) return undefined; + + for (const { regex, property, defaultJcr } of METATAG_PATTERNS) { + if (regex.test(suggestionType)) { + const { metaTagPropertyMap = {} } = deliveryConfig; + const jcrProperty = metaTagPropertyMap[property] || defaultJcr; + return `/properties/${jcrProperty}`; + } + } + + return undefined; +} diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js new file mode 100644 index 000000000..89b69f500 --- /dev/null +++ b/test/controllers/page-relationships.test.js @@ -0,0 +1,852 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +import AccessControlUtil from '../../src/support/access-control-util.js'; +import { ErrorWithStatusCode } from '../../src/support/utils.js'; + +use(chaiAsPromised); +use(sinonChai); + +describe('Page Relationships Controller', () => { + const sandbox = sinon.createSandbox(); + + const SITE_ID = 'f964a7f8-5402-4b01-bd5b-1ab499bcf797'; + const SITE_ID_INVALID = 'not-a-uuid'; + + let PageRelationshipsController; + let resolvePageIdsStub; + let fetchRelationshipsStub; + let isAEMAuthoredSiteStub; + let buildCheckPathStub; + let getIMSPromiseTokenStub; + let exchangePromiseTokenStub; + + let mockDataAccess; + let mockSite; + let controllerContext; + let requestContext; + let log; + + beforeEach(async () => { + log = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + + resolvePageIdsStub = sandbox.stub(); + fetchRelationshipsStub = sandbox.stub(); + isAEMAuthoredSiteStub = sandbox.stub(); + buildCheckPathStub = sandbox.stub(); + getIMSPromiseTokenStub = sandbox.stub().resolves({ + promise_token: 'test-promise-token', + expires_in: 60, + token_type: 'bearer', + }); + exchangePromiseTokenStub = sandbox.stub().resolves('test-ims-token'); + + mockSite = { + getDeliveryType: sandbox.stub().returns('aem_cs'), + getDeliveryConfig: sandbox.stub().returns({ + authorURL: 'https://author.example.com', + metaTagPropertyMap: {}, + }), + getBaseURL: sandbox.stub().returns('https://example.com'), + }; + + mockDataAccess = { + Site: { + findById: sandbox.stub().resolves(mockSite), + }, + }; + + controllerContext = { + dataAccess: mockDataAccess, + log, + }; + + requestContext = { + params: { siteId: SITE_ID }, + data: { pages: [{ key: 'row-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }] }, + pathInfo: { + headers: { authorization: 'Bearer test-ims-token' }, + }, + }; + + sandbox.stub(AccessControlUtil, 'fromContext').returns({ hasAccess: sandbox.stub().resolves(true) }); + + PageRelationshipsController = (await esmock('../../src/controllers/page-relationships.js', { + '../../src/support/aem-content-api.js': { + isAEMAuthoredSite: isAEMAuthoredSiteStub, + resolvePageIds: resolvePageIdsStub, + fetchRelationships: fetchRelationshipsStub, + buildCheckPath: buildCheckPathStub, + }, + '../../src/support/utils.js': { + getIMSPromiseToken: (...args) => getIMSPromiseTokenStub(...args), + exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), + ErrorWithStatusCode, + }, + })).default; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + it('throws if dataAccess is missing', () => { + expect(() => PageRelationshipsController({ log })).to.throw('Data access required'); + }); + + it('returns controller with search function', () => { + const controller = PageRelationshipsController(controllerContext); + expect(controller).to.have.property('search').that.is.a('function'); + }); + }); + + describe('search', () => { + it('returns 400 for invalid siteId', async () => { + const controller = PageRelationshipsController(controllerContext); + requestContext.params.siteId = SITE_ID_INVALID; + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Site ID required'); + expect(mockDataAccess.Site.findById).to.not.have.been.called; + }); + + it('returns 404 when site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(404); + expect(body.message).to.equal('Site not found'); + expect(mockDataAccess.Site.findById).to.have.been.calledOnceWith(SITE_ID); + }); + + it('returns 403 when user does not have access', async () => { + AccessControlUtil.fromContext.returns({ hasAccess: sandbox.stub().resolves(false) }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(403); + expect(body.message).to.equal('Only users belonging to the organization can access this site'); + }); + + it('returns 400 when pages is missing or empty', async () => { + isAEMAuthoredSiteStub.returns(true); + const controller = PageRelationshipsController(controllerContext); + + requestContext.data = {}; + const response1 = await controller.search(requestContext); + const body1 = await response1.json(); + expect(response1.status).to.equal(400); + expect(body1.message).to.equal('pages array required'); + + requestContext.data = { pages: [] }; + const response2 = await controller.search(requestContext); + const body2 = await response2.json(); + expect(response2.status).to.equal(400); + expect(body2.message).to.equal('pages array required'); + }); + + it('returns 400 when pages entry has missing pageUrl', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { pages: [{ key: 'row-1' }] }; + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Each page must include a non-empty pageUrl'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 400 when pages entry has missing key', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { pages: [{ pageUrl: '/us/en/page1' }] }; + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Each page must include a non-empty key'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns supported: false when delivery type is not AEM-authored', async () => { + isAEMAuthoredSiteStub.returns(false); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + expect(getIMSPromiseTokenStub).to.not.have.been.called; + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns supported: false when authorURL is missing', async () => { + isAEMAuthoredSiteStub.returns(true); + mockSite.getDeliveryConfig.returns({}); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + expect(getIMSPromiseTokenStub).to.not.have.been.called; + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 400 when Authorization header is missing', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header', 400)); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Missing Authorization header'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('uses status from ErrorWithStatusCode for token-flow errors', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Authentication failed', 401)); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(401); + expect(body.message).to.equal('Authentication failed'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('falls back to 400 when ErrorWithStatusCode has no status', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header')); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Missing Authorization header'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 4xx with IMS token problem details when generic error contains a 4xx status', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(Object.assign(new Error('exchange failed'), { status: 401 })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(401); + expect(body.message).to.equal('Problem getting IMS token: 401 exchange failed'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('uses statusCode when generic error includes statusCode', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(Object.assign(new Error('rate limited'), { statusCode: 429 })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(429); + expect(body.message).to.equal('Problem getting IMS token: 429 rate limited'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 500 when generic token error has no client status', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(Object.assign(new Error(), { message: '' })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(500); + expect(body.message).to.equal('Error getting IMS token'); + expect(log.error).to.have.been.calledOnce; + expect(log.error.firstCall.args[0]).to.include('Problem getting IMS token'); + expect(log.error.firstCall.args[0]).to.include('Unknown error'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 500 when generic token error has non-4xx status', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(Object.assign(new Error('upstream unavailable'), { status: 503 })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(500); + expect(body.message).to.equal('Error getting IMS token'); + expect(log.error).to.have.been.calledOnce; + expect(log.error.firstCall.args[0]).to.include('503 upstream unavailable'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns _config error when site has no baseURL', async () => { + isAEMAuthoredSiteStub.returns(true); + mockSite.getBaseURL.returns(''); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg1' }]); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.supported).to.equal(true); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.have.property('_config'); + // eslint-disable-next-line dot-notation -- _config needs bracket notation + expect(body.errors['_config'].error).to.equal('Site has no baseURL'); + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('passes exchanged IMS token to resolver and normalizes same-host absolute URL for lookup', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [{ key: 'row-abs', pageUrl: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }], + }; + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, + }, + errors: {}, + })); + + const controller = PageRelationshipsController(controllerContext); + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(getIMSPromiseTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(exchangePromiseTokenStub).to.have.been.calledOnceWithExactly(requestContext, 'test-promise-token'); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1']); + expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-abs'); + expect(body.relationships).to.have.property('row-abs'); + expect(body.relationships['row-abs'].pagePath).to.equal('/us/en/page1'); + expect(body.relationships['row-abs'].pageId).to.equal('pg-123'); + }); + + it('maps upstream relationship payload to sourceType and minimal chain shape', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [ + { key: 'row-live', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }, + { key: 'row-lang', pageUrl: '/us/de/page2', suggestionType: 'Missing Description' }, + { key: 'row-none', pageUrl: '/us/en/page3', suggestionType: 'Missing Description' }, + { key: 'row-plain', pageUrl: '/us/en/page4', suggestionType: 'Missing Description' }, + ], + }; + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-1' }, + { url: '/us/de/page2', pageId: 'pg-2' }, + { url: '/us/en/page3', pageId: 'pg-3' }, + { url: '/us/en/page4', pageId: 'pg-4' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + 'row-live': { + upstream: { + chain: [ + { pageId: 'pg-parent-1', relation: 'liveCopyOf', pagePath: '/language-masters/en/page1' }, + { pageId: 'pg-parent-2', relation: 'unknownRelation', pagePath: '/ignored/path' }, + { relation: 'liveCopyOf', pagePath: '/missing/page-id' }, + ], + }, + }, + 'row-lang': { + metadata: { sourceType: 'langcopy' }, + chain: [ + { pageId: 'pg-global-de', relation: 'languageCopyOf', path: '/global/de/page2' }, + ], + }, + 'row-none': {}, + 'row-plain': { + chain: [ + { pageId: 'pg-plain', pagePath: '/global/plain/page' }, + ], + }, + }, + errors: {}, + }); + + const controller = PageRelationshipsController(controllerContext); + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships['row-live']).to.deep.equal({ + pagePath: '/us/en/page1', + pageId: 'pg-1', + chain: [ + { pageId: 'pg-parent-1', pagePath: '/language-masters/en/page1', metadata: { sourceType: 'liveCopyOf' } }, + { pageId: 'pg-parent-2', pagePath: '/ignored/path', metadata: { sourceType: 'unknownRelation' } }, + ], + }); + expect(body.relationships['row-lang']).to.deep.equal({ + pagePath: '/us/de/page2', + pageId: 'pg-2', + chain: [{ pageId: 'pg-global-de', pagePath: '/global/de/page2', metadata: { sourceType: 'languageCopyOf' } }], + }); + expect(body.relationships['row-none']).to.deep.equal({ + pagePath: '/us/en/page3', + pageId: 'pg-3', + chain: [], + }); + expect(body.relationships['row-plain']).to.deep.equal({ + pagePath: '/us/en/page4', + pageId: 'pg-4', + chain: [{ pageId: 'pg-plain', pagePath: '/global/plain/page' }], + }); + }); + + it('infers sourceType from edge.type and maps pagePath from edge.page', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ + results: { + 'row-1': { + upstream: { + chain: [ + { pageId: 'pg-parent', type: 'liveCopyOf', page: '/language-masters/en/page1' }, + ], + }, + }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships['row-1']).to.deep.equal({ + pagePath: '/us/en/page1', + pageId: 'pg-1', + chain: [{ pageId: 'pg-parent', pagePath: '/language-masters/en/page1', metadata: { sourceType: 'liveCopyOf' } }], + }); + }); + + it('infers sourceType from edge.sourceType when relation and type are absent', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ + results: { + 'row-1': { + upstream: { + chain: [ + { pageId: 'pg-global-fr', sourceType: 'langcopy', pagePath: '/global/fr/page1' }, + ], + }, + }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships['row-1']).to.deep.equal({ + pagePath: '/us/en/page1', + pageId: 'pg-1', + chain: [{ pageId: 'pg-global-fr', pagePath: '/global/fr/page1', metadata: { sourceType: 'langcopy' } }], + }); + }); + + it('handles missing relationship results payload by returning an empty relationships map', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ errors: {} }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + }); + + it('ignores relationship results that do not match requested page keys', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ + results: { + 'unknown-key': { + pageId: 'pg-external', + upstream: { chain: [] }, + }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + }); + + it('uses caller key for fetch item keys and resolve errors', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [ + { key: 'row-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }, + { key: 'row-2', pageUrl: '/us/en/page2', suggestionType: 'Missing Description' }, + ], + }; + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-1' }, + { url: '/us/en/page2', error: 'HTTP 404' }, + ]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, + }, + errors: {}, + })); + + const controller = PageRelationshipsController(controllerContext); + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-1'); + expect(body.relationships).to.have.property('row-1'); + expect(body.relationships['row-1'].pagePath).to.equal('/us/en/page1'); + expect(body.relationships['row-1'].pageId).to.equal('pg-1'); + expect(body.errors).to.have.property('row-2'); + expect(body.errors['row-2'].error).to.equal('HTTP 404'); + }); + + it('keeps absolute URL for lookup when suggestion host differs from site host', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [{ key: 'row-external', pageUrl: 'https://external.example.com/us/en/page1', suggestionType: 'Missing Title' }], + }; + resolvePageIdsStub.resolves([ + { url: 'https://external.example.com/us/en/page1', pageId: 'pg-1' }, + ]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, + }, + errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['https://external.example.com/us/en/page1']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-external'); + expect(body.relationships).to.have.property('row-external'); + expect(body.relationships['row-external'].pagePath).to.equal('https://external.example.com/us/en/page1'); + expect(body.relationships['row-external'].pageId).to.equal('pg-1'); + }); + + it('keeps absolute URL when normalization cannot parse site base URL', async () => { + isAEMAuthoredSiteStub.returns(true); + mockSite.getBaseURL.returns('invalid-site-url'); + requestContext.data = { + pages: [{ key: 'row-invalid-base', pageUrl: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }], + }; + resolvePageIdsStub.resolves([{ url: 'https://example.com/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, + }, + errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['https://example.com/us/en/page1']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-invalid-base'); + expect(body.relationships).to.have.property('row-invalid-base'); + expect(body.relationships['row-invalid-base'].pagePath).to.equal('https://example.com/us/en/page1'); + expect(body.relationships['row-invalid-base'].pageId).to.equal('pg-1'); + }); + + it('falls back to root path when normalized absolute URL has empty pathname', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [{ key: 'row-root', pageUrl: 'https://example.com', suggestionType: 'Missing Title' }], + }; + sandbox.replace(globalThis, 'URL', class URLMock { + constructor(value) { + this.host = 'example.com'; + this.pathname = value === 'https://example.com' ? '' : '/'; + } + }); + resolvePageIdsStub.resolves([{ url: '/', pageId: 'pg-root' }]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, + }, + errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-root'); + expect(body.relationships).to.have.property('row-root'); + expect(body.relationships['row-root'].pagePath).to.equal('/'); + expect(body.relationships['row-root'].pageId).to.equal('pg-root'); + }); + + it('includes checkPath when buildCheckPath returns non-empty value', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns('/properties/jcr:title'); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ results: {}, errors: {} }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + await response.json(); + + expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); + }); + + it('does not include checkPath when buildCheckPath returns empty string', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns(''); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ results: {}, errors: {} }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + await response.json(); + + expect(fetchRelationshipsStub.firstCall.args[1][0]).to.not.have.property('checkPath'); + }); + + it('merges resolve and relationship API errors', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [ + { key: 'row-1', pageUrl: '/us/en/page1' }, + { key: 'row-2', pageUrl: '/us/en/page2' }, + ], + }; + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-1' }, + { url: '/us/en/page2', error: 'HTTP 404' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + 'row-1': { pageId: 'pg-1', upstream: { chain: [] } }, + }, + errors: { + 'row-1': { error: 'NOT_FOUND' }, + }, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.errors).to.have.property('row-2'); + expect(body.errors['row-2'].error).to.equal('HTTP 404'); + expect(body.errors['row-1'].error).to.equal('NOT_FOUND'); + }); + + it('returns default resolve error when resolver returns no error and no pageId', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1' }]); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships).to.deep.equal({}); + expect(body.errors['row-1'].error).to.equal('Could not resolve page'); + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('uses fallback resolve item when resolver returns fewer entries than requested pages', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [ + { key: 'row-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }, + { key: 'row-2', pageUrl: '/us/en/page2', suggestionType: 'Missing Title' }, + ], + }; + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ + results: { + 'row-1': { pageId: 'pg-1', upstream: { chain: [] } }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships).to.have.property('row-1'); + expect(body.errors).to.have.property('row-2'); + expect(body.errors['row-2'].error).to.equal('Could not resolve page'); + }); + + it('skips fetchRelationships when all pages fail to resolve', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', error: 'No content-page-id or content-page-ref' }, + ]); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.relationships).to.deep.equal({}); + expect(body.errors['row-1'].error).to.equal('No content-page-id or content-page-ref'); + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('uses caller key when suggestionType is not provided', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: [{ key: 'row-empty-type', pageUrl: '/us/en/page1' }], + }; + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: { + [items[0].key]: { pageId: 'pg-1', upstream: { chain: [] } }, + }, + errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('row-empty-type'); + expect(body.relationships).to.have.property('row-empty-type'); + expect(body.relationships['row-empty-type'].pagePath).to.equal('/us/en/page1'); + expect(body.relationships['row-empty-type'].pageId).to.equal('pg-1'); + }); + + it('batches requests in chunks of 50 for more than 50 pages', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: Array.from({ length: 120 }, (_, i) => ({ + key: `row-${i}`, + pageUrl: `/us/en/page-${i}`, + suggestionType: 'Missing Title', + })), + }; + resolvePageIdsStub.callsFake(async (baseURL, authorURL, pageUrls) => ( + pageUrls.map((pageUrl) => ({ url: pageUrl, pageId: `pg-${pageUrl}` })) + )); + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ + results: Object.fromEntries( + items.map((item) => [item.key, { pageId: item.pageId, upstream: { chain: [] } }]), + ), + errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.supported).to.equal(true); + expect(resolvePageIdsStub).to.have.callCount(3); + expect(resolvePageIdsStub.firstCall.args[2]).to.have.lengthOf(50); + expect(resolvePageIdsStub.secondCall.args[2]).to.have.lengthOf(50); + expect(resolvePageIdsStub.thirdCall.args[2]).to.have.lengthOf(20); + expect(fetchRelationshipsStub).to.have.callCount(3); + expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(50); + expect(fetchRelationshipsStub.secondCall.args[1]).to.have.lengthOf(50); + expect(fetchRelationshipsStub.thirdCall.args[1]).to.have.lengthOf(20); + expect(Object.keys(body.relationships)).to.have.lengthOf(120); + expect(body.relationships).to.have.property('row-0'); + expect(body.relationships['row-0'].pagePath).to.equal('/us/en/page-0'); + expect(body.relationships['row-0'].pageId).to.equal('pg-/us/en/page-0'); + expect(body.errors).to.deep.equal({}); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 92c4a4e28..1e4a3e4d8 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -338,6 +338,10 @@ describe('getRouteHandlers', () => { getConfig: sinon.stub(), }; + const mockPageRelationshipsController = { + search: sinon.stub(), + }; + it('segregates static and dynamic routes', () => { const { staticRoutes, dynamicRoutes } = getRouteHandlers( mockAuditsController, @@ -378,6 +382,7 @@ describe('getRouteHandlers', () => { mockTrafficToolsController, mockBotBlockerController, mockSentimentController, + mockPageRelationshipsController, ); expect(staticRoutes).to.have.all.keys( @@ -504,6 +509,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/opportunities/top-paid', 'GET /sites/:siteId/opportunities/by-status/:status', 'GET /sites/:siteId/opportunities/:opportunityId', + 'POST /sites/:siteId/page-relationships/search', 'POST /sites/:siteId/opportunities', 'PATCH /sites/:siteId/opportunities/:opportunityId', 'DELETE /sites/:siteId/opportunities/:opportunityId', @@ -722,6 +728,10 @@ describe('getRouteHandlers', () => { expect(dynamicRoutes['GET /sites/:siteId/opportunities/:opportunityId'].paramNames).to.deep.equal(['siteId', 'opportunityId']); expect(dynamicRoutes['POST /sites/:siteId/opportunities'].handler).to.equal(mockOpportunitiesController.createOpportunity); expect(dynamicRoutes['POST /sites/:siteId/opportunities'].paramNames).to.deep.equal(['siteId']); + expect(dynamicRoutes['POST /sites/:siteId/page-relationships/search'].handler) + .to.equal(mockPageRelationshipsController.search); + expect(dynamicRoutes['POST /sites/:siteId/page-relationships/search'].paramNames) + .to.deep.equal(['siteId']); expect(dynamicRoutes['PATCH /sites/:siteId/opportunities/:opportunityId'].handler).to.equal(mockOpportunitiesController.patchOpportunity); expect(dynamicRoutes['PATCH /sites/:siteId/opportunities/:opportunityId'].paramNames).to.deep.equal(['siteId', 'opportunityId']); expect(dynamicRoutes['DELETE /sites/:siteId/opportunities/:opportunityId'].handler).to.equal(mockOpportunitiesController.removeOpportunity); diff --git a/test/support/aem-content-api.test.js b/test/support/aem-content-api.test.js new file mode 100644 index 000000000..2ad5efa60 --- /dev/null +++ b/test/support/aem-content-api.test.js @@ -0,0 +1,389 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import esmock from 'esmock'; + +import { + isAEMAuthoredSite, + buildCheckPath, + fetchRelationships, +} from '../../src/support/aem-content-api.js'; + +use(chaiAsPromised); +use(sinonChai); + +describe('AEM Content API support', () => { + const sandbox = sinon.createSandbox(); + let log; + let originalFetch; + let fetchStub; + + beforeEach(() => { + originalFetch = global.fetch; + fetchStub = sandbox.stub(); + global.fetch = fetchStub; + + log = { + info: sandbox.stub(), + error: sandbox.stub(), + warn: sandbox.stub(), + debug: sandbox.stub(), + }; + }); + + afterEach(() => { + global.fetch = originalFetch; + sandbox.restore(); + }); + + describe('isAEMAuthoredSite', () => { + it('returns true for aem_cs', () => { + expect(isAEMAuthoredSite('aem_cs')).to.be.true; + }); + + it('returns true for aem_ams', () => { + expect(isAEMAuthoredSite('aem_ams')).to.be.true; + }); + + it('returns false for aem_edge', () => { + expect(isAEMAuthoredSite('aem_edge')).to.be.false; + }); + + it('returns false for other', () => { + expect(isAEMAuthoredSite('other')).to.be.false; + }); + + it('returns falsy for null', () => { + expect(isAEMAuthoredSite(null)).to.not.equal(true); + }); + + it('returns falsy for undefined', () => { + expect(isAEMAuthoredSite(undefined)).to.not.equal(true); + }); + + it('returns falsy for empty string', () => { + expect(isAEMAuthoredSite('')).to.not.equal(true); + }); + }); + + describe('buildCheckPath', () => { + it('matches title-related issues to jcr:title by default', () => { + expect(buildCheckPath('Missing title', {})).to.equal('/properties/jcr:title'); + expect(buildCheckPath('Title too short', {})).to.equal('/properties/jcr:title'); + expect(buildCheckPath('Invalid title', {})).to.equal('/properties/jcr:title'); + expect(buildCheckPath('Duplicate title', {})).to.equal('/properties/jcr:title'); + expect(buildCheckPath('Missing title tag', {})).to.equal('/properties/jcr:title'); + }); + + it('matches description-related issues to jcr:description by default', () => { + expect(buildCheckPath('Missing description', {})).to.equal('/properties/jcr:description'); + expect(buildCheckPath('Missing meta description', {})).to.equal('/properties/jcr:description'); + expect(buildCheckPath('Description too long', {})).to.equal('/properties/jcr:description'); + }); + + it('uses custom metaTagPropertyMap title when provided', () => { + const config = { metaTagPropertyMap: { title: 'myTitle' } }; + expect(buildCheckPath('Missing title', config)).to.equal('/properties/myTitle'); + expect(buildCheckPath('Title too short', config)).to.equal('/properties/myTitle'); + }); + + it('uses custom metaTagPropertyMap description when provided', () => { + const config = { metaTagPropertyMap: { description: 'myDesc' } }; + expect(buildCheckPath('Missing meta description', config)).to.equal('/properties/myDesc'); + }); + + it('returns undefined for non-metatag issues', () => { + expect(buildCheckPath('Missing Alt Text')).to.be.undefined; + expect(buildCheckPath('Broken link')).to.be.undefined; + expect(buildCheckPath('')).to.be.undefined; + }); + + it('returns undefined when suggestionType is undefined', () => { + expect(buildCheckPath(undefined)).to.be.undefined; + }); + + it('is case-insensitive', () => { + expect(buildCheckPath('MISSING TITLE', {})).to.equal('/properties/jcr:title'); + expect(buildCheckPath('missing meta description', {})).to.equal('/properties/jcr:description'); + }); + + it('uses defaults when deliveryConfig is undefined', () => { + expect(buildCheckPath('Missing title', undefined)).to.equal('/properties/jcr:title'); + expect(buildCheckPath('Missing description', undefined)).to.equal('/properties/jcr:description'); + }); + + it('does not apply metaTagPropertyMap to non-metatag suggestions', () => { + const config = { metaTagPropertyMap: { 'alt text': 'dam:altText' } }; + expect(buildCheckPath('Missing Alt Text', config)).to.be.undefined; + }); + }); + + describe('resolvePageIds', () => { + let resolvePageIds; + let determineAEMCSPageIdStub; + + beforeEach(async () => { + determineAEMCSPageIdStub = sandbox.stub(); + ({ resolvePageIds } = await esmock( + '../../src/support/aem-content-api.js', + { + '@adobe/spacecat-shared-utils': { + DELIVERY_TYPES: { AEM_CS: 'aem_cs', AEM_AMS: 'aem_ams' }, + determineAEMCSPageId: determineAEMCSPageIdStub, + }, + }, + )); + }); + + it('returns invalid pageUrl error for empty or non-string entries', async () => { + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + [undefined, ' '], + 'token', + log, + ); + + expect(result).to.deep.equal([ + { url: undefined, error: 'Invalid pageUrl' }, + { url: ' ', error: 'Invalid pageUrl' }, + ]); + expect(determineAEMCSPageIdStub).to.not.have.been.called; + }); + + it('returns pageId when shared utility resolves successfully', async () => { + determineAEMCSPageIdStub.resolves('pg-123-abc'); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/us/en/page1'], + 'token', + log, + ); + + expect(result).to.have.lengthOf(1); + expect(result[0]).to.deep.equal({ url: '/us/en/page1', pageId: 'pg-123-abc' }); + expect(determineAEMCSPageIdStub).to.have.been.calledOnceWith( + 'https://example.com/us/en/page1', + 'https://author.example.com', + 'Bearer token', + true, + log, + ); + }); + + it('constructs full URL with slash for paths without leading slash', async () => { + determineAEMCSPageIdStub.resolves('pg-456'); + + await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['us/en/page2'], + 'token', + log, + ); + + expect(determineAEMCSPageIdStub).to.have.been.calledWith( + 'https://example.com/us/en/page2', + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + ); + }); + + it('strips trailing slash from siteBaseURL', async () => { + determineAEMCSPageIdStub.resolves('pg-x'); + + await resolvePageIds( + 'https://example.com/', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(determineAEMCSPageIdStub).to.have.been.calledWith( + 'https://example.com/page', + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, + ); + }); + + it('returns error when shared utility returns null', async () => { + determineAEMCSPageIdStub.resolves(null); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/us/en/page1'], + 'token', + log, + ); + + expect(result).to.have.lengthOf(1); + expect(result[0].url).to.equal('/us/en/page1'); + expect(result[0].error).to.equal('Could not determine page ID'); + }); + + it('returns error when shared utility throws', async () => { + determineAEMCSPageIdStub.rejects(new Error('Network error')); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0].error).to.equal('Network error'); + expect(log.warn).to.have.been.calledWith( + sinon.match(/resolvePageIds failed/), + ); + }); + + it('resolves multiple pages in batch', async () => { + determineAEMCSPageIdStub.onFirstCall().resolves('pg-1'); + determineAEMCSPageIdStub.onSecondCall().resolves(null); + determineAEMCSPageIdStub.onThirdCall() + .rejects(new Error('fail')); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page1', '/page2', '/page3'], + 'token', + log, + ); + + expect(result).to.have.lengthOf(3); + expect(result[0]).to.deep.equal({ url: '/page1', pageId: 'pg-1' }); + expect(result[1].error).to.equal('Could not determine page ID'); + expect(result[2].error).to.equal('fail'); + }); + }); + + describe('fetchRelationships', () => { + it('POSTs to correct URL and returns results and errors', async () => { + const mockResults = { k1: { pageId: 'pg1', upstream: { chain: [] } } }; + const mockErrors = {}; + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ results: mockResults, errors: mockErrors }), + }); + + const items = [ + { key: 'k1', pageId: 'pg1', include: ['upstream'] }, + ]; + + const result = await fetchRelationships( + 'https://author.example.com', + items, + 'ims-token', + log, + ); + + expect(result.results).to.deep.equal(mockResults); + expect(result.errors).to.deep.equal(mockErrors); + expect(global.fetch).to.have.been.calledOnceWith( + 'https://author.example.com/adobe/pages/relationships/search', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ims-token', + }, + body: JSON.stringify({ items }), + }, + ); + }); + + it('strips trailing slash from authorURL', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ results: {}, errors: {} }), + }); + + await fetchRelationships( + 'https://author.example.com/', + [], + 'token', + log, + ); + + expect(global.fetch).to.have.been.calledWith( + 'https://author.example.com/adobe/pages/relationships/search', + sinon.match.any, + ); + }); + + it('returns results and errors on non-ok response', async () => { + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const result = await fetchRelationships( + 'https://author.example.com', + [], + 'token', + log, + ); + + expect(result.results).to.deep.equal({}); + expect(result.errors).to.deep.equal({ default: { error: 'HTTP 500' } }); + expect(log.warn).to.have.been.calledWith('Relationships search returned 500'); + }); + + it('defaults missing results and errors to empty objects', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({}), + }); + + const result = await fetchRelationships( + 'https://author.example.com', + [], + 'token', + log, + ); + + expect(result.results).to.deep.equal({}); + expect(result.errors).to.deep.equal({}); + }); + + it('returns default error when fetch throws', async () => { + fetchStub.rejects(new Error('Connection refused')); + + const result = await fetchRelationships( + 'https://author.example.com', + [], + 'token', + log, + ); + + expect(result.results).to.deep.equal({}); + expect(result.errors).to.deep.equal({ default: { error: 'Connection refused' } }); + expect(log.warn).to.have.been.calledWith(sinon.match(/Relationships search error/)); + }); + }); +}); diff --git a/test/support/utils.test.js b/test/support/utils.test.js index 2cff4f05c..55123b558 100644 --- a/test/support/utils.test.js +++ b/test/support/utils.test.js @@ -18,7 +18,10 @@ import sinon from 'sinon'; import nock from 'nock'; import { - createProject, deriveProjectName, autoResolveAuthorUrl, updateCodeConfig, + createProject, + deriveProjectName, + autoResolveAuthorUrl, + updateCodeConfig, } from '../../src/support/utils.js'; use(chaiAsPromised);