From 7fc9317382a46d9c5436e3794d4d21a90cc4db9b Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Thu, 26 Feb 2026 11:27:15 +0530 Subject: [PATCH 01/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 159 +++++++ src/index.js | 3 + src/routes/index.js | 5 + src/support/aem-content-api.js | 174 ++++++++ test/controllers/page-relationships.test.js | 422 ++++++++++++++++++ test/routes/index.test.js | 8 + test/support/aem-content-api.test.js | 459 ++++++++++++++++++++ 7 files changed, 1230 insertions(+) create mode 100644 src/controllers/page-relationships.js create mode 100644 src/support/aem-content-api.js create mode 100644 test/controllers/page-relationships.test.js create mode 100644 test/support/aem-content-api.test.js diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js new file mode 100644 index 000000000..f92519e19 --- /dev/null +++ b/src/controllers/page-relationships.js @@ -0,0 +1,159 @@ +/* + * 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, +} from '@adobe/spacecat-shared-http-utils'; +import AccessControlUtil from '../support/access-control-util.js'; +import { getImsUserToken } from '../support/utils.js'; +import { + isAEMAuthoredSite, + resolvePageIds, + fetchRelationships, + buildCheckPath, +} from '../support/aem-content-api.js'; + +const MAX_PAGES = 50; + +/** + * Page relationships controller: proxy to AEM Content API for upstream relationship data. + * Used for list-time enrichment (metatags/alt-text) so the UI can show fix targets. + * @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); + + /** + * POST /sites/:siteId/page-relationships/search + * Body: { pages: [ { pageUrl, suggestionType }, ... ] } + * 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 deliveryType = site.getDeliveryType(); + if (!isAEMAuthoredSite(deliveryType)) { + return createResponse({ + supported: false, + relationships: {}, + errors: {}, + }); + } + + const authorURL = site.getDeliveryConfig()?.authorURL; + if (!hasText(authorURL)) { + return createResponse({ + supported: false, + relationships: {}, + errors: {}, + }); + } + + const pages = context.data?.pages; + if (!isNonEmptyArray(pages) || pages.length > MAX_PAGES) { + return badRequest(`pages array required (max ${MAX_PAGES} items)`); + } + if (pages.some((page) => !page || !hasText(page.pageUrl))) { + return badRequest('Each page must include a non-empty pageUrl'); + } + + let imsToken; + try { + imsToken = getImsUserToken(context); + } catch (e) { + return badRequest('Missing Authorization header'); + } + + const baseURL = site.getBaseURL(); + if (!hasText(baseURL)) { + return createResponse({ + supported: true, + relationships: {}, + errors: { _config: { error: 'Site has no baseURL' } }, + }); + } + + const metaTagPropertyMap = site.getDeliveryConfig()?.metaTagPropertyMap || {}; + const pageUrls = pages.map((p) => p.pageUrl.trim()); + + const resolved = await resolvePageIds( + baseURL, + authorURL, + pageUrls, + imsToken, + log, + ); + + const items = []; + const errors = {}; + + for (let i = 0; i < resolved.length; i += 1) { + const r = resolved[i]; + const pageSpec = pages[i] || {}; + if (r.error || !r.pageId) { + const errKey = pageSpec.key ?? r.url; + errors[errKey] = { error: r.error || 'Could not resolve page' }; + } else { + const checkPath = buildCheckPath(pageSpec.suggestionType, metaTagPropertyMap); + const key = pageSpec.key ?? `${r.url}:${pageSpec.suggestionType ?? ''}`; + items.push({ + key, + pageId: r.pageId, + include: ['upstream'], + ...(checkPath && { checkPath }), + }); + } + } + + if (items.length === 0) { + return createResponse({ + supported: true, + relationships: {}, + errors, + }); + } + + const aemResponse = await fetchRelationships(authorURL, items, imsToken, log); + + return createResponse({ + supported: true, + relationships: aemResponse.results, + errors: { ...errors, ...aemResponse.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..0d0f82e14 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 = {}; @@ -403,6 +405,9 @@ export default function getRouteHandlers( 'GET /sites-resolve': sitesController.resolveSite, + // Page relationships (AEM upstream chain for list-time fix target display) + 'POST /sites/:siteId/page-relationships/search': pageRelationshipsController.search, + // Sentiment Analysis endpoints // Topics 'GET /sites/:siteId/sentiment/topics': sentimentController.listTopics, diff --git a/src/support/aem-content-api.js b/src/support/aem-content-api.js new file mode 100644 index 000000000..1bd63c721 --- /dev/null +++ b/src/support/aem-content-api.js @@ -0,0 +1,174 @@ +/* + * 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 { Site as SiteModel } from '@adobe/spacecat-shared-data-access'; + +const AEM_DELIVERY_TYPES = [ + SiteModel.DELIVERY_TYPES.AEM_CS, + SiteModel.DELIVERY_TYPES.AEM_AMS, +]; + +function getContentPageIdFromHtml(html) { + const m = html.match(/>} + */ +export async function resolvePageIds(siteBaseURL, authorURL, pageUrls, imsToken, log) { + const base = siteBaseURL.replace(/\/$/, ''); + const out = []; + + for (const pageUrl of pageUrls) { + const normalizedPageUrl = typeof pageUrl === 'string' ? pageUrl.trim() : ''; + if (!normalizedPageUrl) { + out.push({ url: pageUrl, error: 'Invalid pageUrl' }); + } else { + const fullUrl = `${base}${normalizedPageUrl.startsWith('/') ? normalizedPageUrl : `/${normalizedPageUrl}`}`; + try { + /* eslint-disable-next-line no-await-in-loop -- sequential fetch per page */ + const res = await fetch(fullUrl, { method: 'GET', redirect: 'follow' }); + if (!res.ok) { + out.push({ url: normalizedPageUrl, error: `HTTP ${res.status}` }); + } else { + /* eslint-disable-next-line no-await-in-loop -- sequential fetch per page */ + const html = await res.text(); + const pageId = getContentPageIdFromHtml(html); + const pageRef = getContentPageRefFromHtml(html); + + if (pageId) { + out.push({ url: normalizedPageUrl, pageId }); + } else if (pageRef) { + /* eslint-disable-next-line no-await-in-loop -- sequential resolve per page */ + const resolved = await resolvePageRef(authorURL, pageRef, imsToken, log); + if (resolved) { + out.push({ url: normalizedPageUrl, pageId: resolved }); + } else { + out.push({ url: normalizedPageUrl, error: 'Resolve failed' }); + } + } else { + out.push({ url: normalizedPageUrl, error: 'No content-page-id or content-page-ref' }); + } + } + } catch (e) { + log.warn(`resolvePageIds failed for ${normalizedPageUrl}: ${e.message}`); + out.push({ url: normalizedPageUrl, 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 } }, + }; + } +} + +/** + * Build checkPath for relationship API from suggestion type and metaTagPropertyMap. + * Returns undefined for alt-text and other types where property-level check is not possible. + * @param {string} [suggestionType] - e.g. "Missing Title", "Missing Description". + * @param {object} [metaTagPropertyMap] - deliveryConfig.metaTagPropertyMap. + * @returns {string|undefined} + */ +export function buildCheckPath(suggestionType, metaTagPropertyMap = {}) { + switch (suggestionType) { + case 'Missing Title': + return `/properties/${metaTagPropertyMap.title || 'jcr:title'}`; + case 'Missing Description': + return `/properties/${metaTagPropertyMap.description || 'jcr:description'}`; + default: + return undefined; + } +} diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js new file mode 100644 index 000000000..d780e3cdf --- /dev/null +++ b/test/controllers/page-relationships.test.js @@ -0,0 +1,422 @@ +/* + * 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'; + +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 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(); + + 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: [{ 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, + }, + })).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 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(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(resolvePageIdsStub).to.not.have.been.called; + }); + + 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.include('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.include('pages array required'); + }); + + it('returns 400 when pages entry has missing pageUrl', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { pages: [{}] }; + 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 exceeds max size', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.data = { + pages: Array(51).fill({ pageUrl: '/p', suggestionType: 'x' }), + }; + 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.include('max 50'); + }); + + it('returns 400 when Authorization header is missing', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.pathInfo = { headers: {} }; + 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'); + }); + + it('returns supported: true with _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.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'); + }); + + it('returns supported: true with relationships when resolve and fetch succeed', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + const resultKey = '/us/en/page1:Missing Title'; + fetchRelationshipsStub.resolves({ + results: { + [resultKey]: { + pageId: 'pg-123', + upstream: { chain: [{ relation: 'liveCopyOf', pageId: 'pg-blueprint', pagePath: '/content/blueprint/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.supported).to.equal(true); + expect(body.relationships).to.have.property(resultKey); + expect(body.relationships[resultKey].upstream.chain).to.have.lengthOf(1); + expect(body.errors).to.deep.equal({}); + + expect(resolvePageIdsStub).to.have.been.calledOnce; + expect(resolvePageIdsStub.firstCall.args[0]).to.equal('https://example.com'); + expect(resolvePageIdsStub.firstCall.args[1]).to.equal('https://author.example.com'); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1']); + expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); + + expect(fetchRelationshipsStub).to.have.been.calledOnce; + expect(fetchRelationshipsStub.firstCall.args[0]).to.equal('https://author.example.com'); + expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); + expect(fetchRelationshipsStub.firstCall.args[1][0]).to.include({ key: resultKey, pageId: 'pg-123' }); + expect(fetchRelationshipsStub.firstCall.args[1][0].include).to.deep.equal(['upstream']); + expect(fetchRelationshipsStub.firstCall.args[2]).to.equal('test-ims-token'); + }); + + it('merges resolve errors and AEM API errors in response', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-123' }, + { url: '/us/en/page2', error: 'HTTP 404' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + '/us/en/page1:': { pageId: 'pg-123', upstream: { chain: [] } }, + }, + errors: { + '/us/en/page1:': { error: 'NOT_FOUND' }, + }, + }); + + requestContext.data = { + pages: [ + { pageUrl: '/us/en/page1' }, + { pageUrl: '/us/en/page2' }, + ], + }; + + 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.errors).to.include.keys('/us/en/page2'); + expect(body.errors['/us/en/page2'].error).to.equal('HTTP 404'); + expect(body.errors['/us/en/page1:'].error).to.equal('NOT_FOUND'); + }); + + it('calls buildCheckPath with suggestionType and metaTagPropertyMap', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns('/properties/jcr:title'); + mockSite.getDeliveryConfig.returns({ authorURL: 'https://author.example.com', metaTagPropertyMap: { title: 'jcr:title' } }); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); + + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', { title: 'jcr:title' }); + expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); + }); + + it('uses page key from request when provided', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns(undefined); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: { 'page-1': { upstream: { chain: [] } } }, errors: {} }); + requestContext.data = { + pages: [{ key: 'page-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }], + }; + + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('page-1'); + }); + + it('passes empty metaTagPropertyMap when delivery config has no map', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns(undefined); + mockSite.getDeliveryConfig.returns({ authorURL: 'https://author.example.com' }); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', {}); + }); + + it('returns supported: true with empty relationships 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.supported).to.equal(true); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.have.property('/us/en/page1'); + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('uses default resolve error message when resolve result has no pageId and no error', 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.errors['/us/en/page1'].error).to.equal('Could not resolve page'); + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('uses resolved url as error key when resolved item has no page spec match', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-123' }, + { url: '/us/en/page2', error: 'HTTP 404' }, + ]); + fetchRelationshipsStub.resolves({ + results: { '/us/en/page1:Missing Title': { pageId: 'pg-123', upstream: { chain: [] } } }, + errors: {}, + }); + requestContext.data = { + pages: [{ pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }], + }; + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.search(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.errors['/us/en/page2'].error).to.equal('HTTP 404'); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 92c4a4e28..9fc5fe976 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( @@ -524,6 +529,7 @@ describe('getRouteHandlers', () => { 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId', 'DELETE /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId', 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId/fixes', + 'POST /sites/:siteId/page-relationships/search', 'GET /sites/:siteId/scraped-content/:type', 'GET /sites/:siteId/top-pages', 'GET /sites/:siteId/top-pages/:source', @@ -722,6 +728,8 @@ 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..00cc6cc11 --- /dev/null +++ b/test/support/aem-content-api.test.js @@ -0,0 +1,459 @@ +/* + * 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 { Site as SiteModel } from '@adobe/spacecat-shared-data-access'; +import { + isAEMAuthoredSite, + buildCheckPath, + resolvePageIds, + 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(SiteModel.DELIVERY_TYPES.AEM_CS)).to.be.true; + }); + + it('returns true for aem_ams', () => { + expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.AEM_AMS)).to.be.true; + }); + + it('returns false for aem_edge', () => { + expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.AEM_EDGE)).to.be.false; + }); + + it('returns false for other', () => { + expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.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('returns /properties/jcr:title for Missing Title when metaTagPropertyMap is empty', () => { + expect(buildCheckPath('Missing Title', {})).to.equal('/properties/jcr:title'); + }); + + it('returns custom title property for Missing Title when metaTagPropertyMap.title is set', () => { + expect(buildCheckPath('Missing Title', { title: 'myTitle' })).to.equal('/properties/myTitle'); + }); + + it('returns /properties/jcr:description for Missing Description when metaTagPropertyMap is empty', () => { + expect(buildCheckPath('Missing Description', {})).to.equal('/properties/jcr:description'); + }); + + it('returns custom description property for Missing Description when metaTagPropertyMap.description is set', () => { + expect(buildCheckPath('Missing Description', { description: 'myDesc' })).to.equal('/properties/myDesc'); + }); + + it('returns undefined for alt-text or other suggestion types', () => { + expect(buildCheckPath('Missing Alt Text')).to.be.undefined; + expect(buildCheckPath('Other Type')).to.be.undefined; + expect(buildCheckPath('')).to.be.undefined; + }); + + it('returns undefined when suggestionType is undefined', () => { + expect(buildCheckPath(undefined)).to.be.undefined; + }); + + it('uses default jcr:title when metaTagPropertyMap is undefined', () => { + expect(buildCheckPath('Missing Title', undefined)).to.equal('/properties/jcr:title'); + }); + }); + + describe('resolvePageIds', () => { + 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(fetchStub).to.not.have.been.called; + }); + + it('returns pageId when HTML contains content-page-id meta', async () => { + const html = ''; + fetchStub.resolves({ + ok: true, + text: () => Promise.resolve(html), + }); + + 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(global.fetch).to.have.been.calledOnceWith( + 'https://example.com/us/en/page1', + { method: 'GET', redirect: 'follow' }, + ); + }); + + it('accepts pageUrl without leading slash and builds full URL', async () => { + const html = ''; + fetchStub.resolves({ + ok: true, + text: () => Promise.resolve(html), + }); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['us/en/page2'], + 'token', + log, + ); + + expect(result[0].pageId).to.equal('pg-456'); + expect(global.fetch).to.have.been.calledWith( + 'https://example.com/us/en/page2', + { method: 'GET', redirect: 'follow' }, + ); + }); + + it('strips trailing slash from siteBaseURL', async () => { + const html = ''; + fetchStub.resolves({ + ok: true, + text: () => Promise.resolve(html), + }); + + await resolvePageIds( + 'https://example.com/', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(global.fetch).to.have.been.calledWith( + 'https://example.com/page', + sinon.match.any, + ); + }); + + it('returns error when HTTP response is not ok', async () => { + fetchStub.resolves({ + ok: false, + status: 404, + }); + + 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', error: 'HTTP 404' }); + }); + + it('returns error when HTML has no content-page-id or content-page-ref', async () => { + fetchStub.resolves({ + ok: true, + text: () => Promise.resolve('no meta'), + }); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0].error).to.equal('No content-page-id or content-page-ref'); + }); + + it('resolves via content-page-ref when content-page-id is absent', async () => { + const html = ''; + fetchStub + .onFirstCall() + .resolves({ + ok: true, + text: () => Promise.resolve(html), + }) + .onSecondCall() + .resolves({ + ok: true, + json: () => Promise.resolve({ pageId: 'pg-from-ref' }), + }); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0]).to.deep.equal({ url: '/page', pageId: 'pg-from-ref' }); + expect(global.fetch).to.have.been.calledTwice; + const resolveCall = global.fetch.secondCall; + expect(resolveCall.args[0]).to.include('/adobe/pages/resolve'); + expect(resolveCall.args[0]).to.include('pageRef='); + expect(resolveCall.args[1].headers.Authorization).to.equal('Bearer token'); + }); + + it('returns resolve failed when content-page-ref resolve API is not ok', async () => { + const html = ''; + fetchStub + .onFirstCall() + .resolves({ + ok: true, + text: () => Promise.resolve(html), + }) + .onSecondCall() + .resolves({ + ok: false, + status: 404, + }); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); + expect(log.warn).to.have.been.calledWith('Resolve API returned 404 for pageRef'); + }); + + it('returns resolve failed when content-page-ref resolve API throws', async () => { + const html = ''; + fetchStub + .onFirstCall() + .resolves({ + ok: true, + text: () => Promise.resolve(html), + }) + .onSecondCall() + .rejects(new Error('Resolve failure')); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); + expect(log.warn).to.have.been.calledWith('Resolve API error: Resolve failure'); + }); + + it('returns resolve failed when content-page-ref resolve response has no pageId', async () => { + const html = ''; + fetchStub + .onFirstCall() + .resolves({ + ok: true, + text: () => Promise.resolve(html), + }) + .onSecondCall() + .resolves({ + ok: true, + json: () => Promise.resolve({}), + }); + + const result = await resolvePageIds( + 'https://example.com', + 'https://author.example.com', + ['/page'], + 'token', + log, + ); + + expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); + }); + + it('returns error when fetch throws', async () => { + fetchStub.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/)); + }); + }); + + 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/)); + }); + }); +}); From b30e16e8ee57cf867905ed342b0429aa7db271c1 Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Thu, 26 Feb 2026 13:34:28 +0530 Subject: [PATCH 02/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 11 +- src/support/aem-content-api.js | 137 +++++------ test/controllers/page-relationships.test.js | 54 ++++- test/support/aem-content-api.test.js | 256 +++++++------------- 4 files changed, 204 insertions(+), 254 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index f92519e19..92ef0e31d 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -71,7 +71,8 @@ function PageRelationshipsController(ctx) { }); } - const authorURL = site.getDeliveryConfig()?.authorURL; + const deliveryConfig = site.getDeliveryConfig(); + const authorURL = deliveryConfig?.authorURL; if (!hasText(authorURL)) { return createResponse({ supported: false, @@ -104,7 +105,6 @@ function PageRelationshipsController(ctx) { }); } - const metaTagPropertyMap = site.getDeliveryConfig()?.metaTagPropertyMap || {}; const pageUrls = pages.map((p) => p.pageUrl.trim()); const resolved = await resolvePageIds( @@ -125,13 +125,16 @@ function PageRelationshipsController(ctx) { const errKey = pageSpec.key ?? r.url; errors[errKey] = { error: r.error || 'Could not resolve page' }; } else { - const checkPath = buildCheckPath(pageSpec.suggestionType, metaTagPropertyMap); + const hasExplicitCheckPath = Object.prototype.hasOwnProperty.call(pageSpec, 'checkPath'); + const checkPath = hasExplicitCheckPath + ? pageSpec.checkPath + : buildCheckPath(pageSpec.suggestionType, deliveryConfig); const key = pageSpec.key ?? `${r.url}:${pageSpec.suggestionType ?? ''}`; items.push({ key, pageId: r.pageId, include: ['upstream'], - ...(checkPath && { checkPath }), + ...(hasText(checkPath) && { checkPath }), }); } } diff --git a/src/support/aem-content-api.js b/src/support/aem-content-api.js index 1bd63c721..c62699f52 100644 --- a/src/support/aem-content-api.js +++ b/src/support/aem-content-api.js @@ -10,102 +10,65 @@ * governing permissions and limitations under the License. */ -import { Site as SiteModel } from '@adobe/spacecat-shared-data-access'; +import { + DELIVERY_TYPES, + determineAEMCSPageId, +} from '@adobe/spacecat-shared-utils'; -const AEM_DELIVERY_TYPES = [ - SiteModel.DELIVERY_TYPES.AEM_CS, - SiteModel.DELIVERY_TYPES.AEM_AMS, +const AEM_AUTHORED_TYPES = [ + DELIVERY_TYPES.AEM_CS, + DELIVERY_TYPES.AEM_AMS, ]; -function getContentPageIdFromHtml(html) { - const m = html.match(/>} */ 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 normalizedPageUrl = typeof pageUrl === 'string' ? pageUrl.trim() : ''; - if (!normalizedPageUrl) { + const normalized = typeof pageUrl === 'string' ? pageUrl.trim() : ''; + if (!normalized) { out.push({ url: pageUrl, error: 'Invalid pageUrl' }); } else { - const fullUrl = `${base}${normalizedPageUrl.startsWith('/') ? normalizedPageUrl : `/${normalizedPageUrl}`}`; + const slash = normalized.startsWith('/') ? '' : '/'; + const fullUrl = `${base}${slash}${normalized}`; try { - /* eslint-disable-next-line no-await-in-loop -- sequential fetch per page */ - const res = await fetch(fullUrl, { method: 'GET', redirect: 'follow' }); - if (!res.ok) { - out.push({ url: normalizedPageUrl, error: `HTTP ${res.status}` }); + /* 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 { - /* eslint-disable-next-line no-await-in-loop -- sequential fetch per page */ - const html = await res.text(); - const pageId = getContentPageIdFromHtml(html); - const pageRef = getContentPageRefFromHtml(html); - - if (pageId) { - out.push({ url: normalizedPageUrl, pageId }); - } else if (pageRef) { - /* eslint-disable-next-line no-await-in-loop -- sequential resolve per page */ - const resolved = await resolvePageRef(authorURL, pageRef, imsToken, log); - if (resolved) { - out.push({ url: normalizedPageUrl, pageId: resolved }); - } else { - out.push({ url: normalizedPageUrl, error: 'Resolve failed' }); - } - } else { - out.push({ url: normalizedPageUrl, error: 'No content-page-id or content-page-ref' }); - } + out.push({ url: normalized, error: 'Could not determine page ID' }); } } catch (e) { - log.warn(`resolvePageIds failed for ${normalizedPageUrl}: ${e.message}`); - out.push({ url: normalizedPageUrl, error: e.message }); + log.warn(`resolvePageIds failed for ${normalized}: ${e.message}`); + out.push({ url: normalized, error: e.message }); } } } @@ -155,20 +118,32 @@ export async function fetchRelationships(authorURL, items, imsToken, log) { } } +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 metaTagPropertyMap. - * Returns undefined for alt-text and other types where property-level check is not possible. - * @param {string} [suggestionType] - e.g. "Missing Title", "Missing Description". - * @param {object} [metaTagPropertyMap] - deliveryConfig.metaTagPropertyMap. + * 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, metaTagPropertyMap = {}) { - switch (suggestionType) { - case 'Missing Title': - return `/properties/${metaTagPropertyMap.title || 'jcr:title'}`; - case 'Missing Description': - return `/properties/${metaTagPropertyMap.description || 'jcr:description'}`; - default: - return 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 index d780e3cdf..b6485d9a8 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -321,10 +321,11 @@ describe('Page Relationships Controller', () => { expect(body.errors['/us/en/page1:'].error).to.equal('NOT_FOUND'); }); - it('calls buildCheckPath with suggestionType and metaTagPropertyMap', async () => { + it('calls buildCheckPath with suggestionType and delivery config', async () => { isAEMAuthoredSiteStub.returns(true); buildCheckPathStub.returns('/properties/jcr:title'); - mockSite.getDeliveryConfig.returns({ authorURL: 'https://author.example.com', metaTagPropertyMap: { title: 'jcr:title' } }); + const deliveryConfig = { authorURL: 'https://author.example.com', metaTagPropertyMap: { title: 'jcr:title' } }; + mockSite.getDeliveryConfig.returns(deliveryConfig); resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); @@ -332,10 +333,50 @@ describe('Page Relationships Controller', () => { await controller.search(requestContext); - expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', { title: 'jcr:title' }); + expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', deliveryConfig); expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); }); + it('uses checkPath from page spec when provided instead of deriving it', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: {}, errors: {} }); + requestContext.data = { + pages: [{ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + checkPath: '/properties/custom-prop', + }], + }; + + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(buildCheckPathStub).to.not.have.been.called; + expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/custom-prop'); + }); + + it('does not derive checkPath when an explicit empty checkPath is provided', async () => { + isAEMAuthoredSiteStub.returns(true); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: {}, errors: {} }); + requestContext.data = { + pages: [{ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + checkPath: '', + }], + }; + + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(buildCheckPathStub).to.not.have.been.called; + expect(fetchRelationshipsStub.firstCall.args[1][0]).to.not.have.property('checkPath'); + }); + it('uses page key from request when provided', async () => { isAEMAuthoredSiteStub.returns(true); buildCheckPathStub.returns(undefined); @@ -352,17 +393,18 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('page-1'); }); - it('passes empty metaTagPropertyMap when delivery config has no map', async () => { + it('passes delivery config without metaTagPropertyMap when config has no map', async () => { isAEMAuthoredSiteStub.returns(true); buildCheckPathStub.returns(undefined); - mockSite.getDeliveryConfig.returns({ authorURL: 'https://author.example.com' }); + const deliveryConfig = { authorURL: 'https://author.example.com' }; + mockSite.getDeliveryConfig.returns(deliveryConfig); resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); const controller = PageRelationshipsController(controllerContext); await controller.search(requestContext); - expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', {}); + expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', deliveryConfig); }); it('returns supported: true with empty relationships when all pages fail to resolve', async () => { diff --git a/test/support/aem-content-api.test.js b/test/support/aem-content-api.test.js index 00cc6cc11..2ad5efa60 100644 --- a/test/support/aem-content-api.test.js +++ b/test/support/aem-content-api.test.js @@ -16,12 +16,11 @@ 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 { Site as SiteModel } from '@adobe/spacecat-shared-data-access'; import { isAEMAuthoredSite, buildCheckPath, - resolvePageIds, fetchRelationships, } from '../../src/support/aem-content-api.js'; @@ -54,19 +53,19 @@ describe('AEM Content API support', () => { describe('isAEMAuthoredSite', () => { it('returns true for aem_cs', () => { - expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.AEM_CS)).to.be.true; + expect(isAEMAuthoredSite('aem_cs')).to.be.true; }); it('returns true for aem_ams', () => { - expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.AEM_AMS)).to.be.true; + expect(isAEMAuthoredSite('aem_ams')).to.be.true; }); it('returns false for aem_edge', () => { - expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.AEM_EDGE)).to.be.false; + expect(isAEMAuthoredSite('aem_edge')).to.be.false; }); it('returns false for other', () => { - expect(isAEMAuthoredSite(SiteModel.DELIVERY_TYPES.OTHER)).to.be.false; + expect(isAEMAuthoredSite('other')).to.be.false; }); it('returns falsy for null', () => { @@ -83,25 +82,34 @@ describe('AEM Content API support', () => { }); describe('buildCheckPath', () => { - it('returns /properties/jcr:title for Missing Title when metaTagPropertyMap is empty', () => { - expect(buildCheckPath('Missing Title', {})).to.equal('/properties/jcr:title'); + 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('returns custom title property for Missing Title when metaTagPropertyMap.title is set', () => { - expect(buildCheckPath('Missing Title', { title: 'myTitle' })).to.equal('/properties/myTitle'); + 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('returns /properties/jcr:description for Missing Description when metaTagPropertyMap is empty', () => { - expect(buildCheckPath('Missing Description', {})).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('returns custom description property for Missing Description when metaTagPropertyMap.description is set', () => { - expect(buildCheckPath('Missing Description', { description: 'myDesc' })).to.equal('/properties/myDesc'); + 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 alt-text or other suggestion types', () => { + it('returns undefined for non-metatag issues', () => { expect(buildCheckPath('Missing Alt Text')).to.be.undefined; - expect(buildCheckPath('Other Type')).to.be.undefined; + expect(buildCheckPath('Broken link')).to.be.undefined; expect(buildCheckPath('')).to.be.undefined; }); @@ -109,12 +117,39 @@ describe('AEM Content API support', () => { expect(buildCheckPath(undefined)).to.be.undefined; }); - it('uses default jcr:title when metaTagPropertyMap is undefined', () => { - expect(buildCheckPath('Missing Title', undefined)).to.equal('/properties/jcr:title'); + 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', @@ -128,15 +163,11 @@ describe('AEM Content API support', () => { { url: undefined, error: 'Invalid pageUrl' }, { url: ' ', error: 'Invalid pageUrl' }, ]); - expect(fetchStub).to.not.have.been.called; + expect(determineAEMCSPageIdStub).to.not.have.been.called; }); - it('returns pageId when HTML contains content-page-id meta', async () => { - const html = ''; - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(html), - }); + it('returns pageId when shared utility resolves successfully', async () => { + determineAEMCSPageIdStub.resolves('pg-123-abc'); const result = await resolvePageIds( 'https://example.com', @@ -148,20 +179,19 @@ describe('AEM Content API support', () => { expect(result).to.have.lengthOf(1); expect(result[0]).to.deep.equal({ url: '/us/en/page1', pageId: 'pg-123-abc' }); - expect(global.fetch).to.have.been.calledOnceWith( + expect(determineAEMCSPageIdStub).to.have.been.calledOnceWith( 'https://example.com/us/en/page1', - { method: 'GET', redirect: 'follow' }, + 'https://author.example.com', + 'Bearer token', + true, + log, ); }); - it('accepts pageUrl without leading slash and builds full URL', async () => { - const html = ''; - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(html), - }); + it('constructs full URL with slash for paths without leading slash', async () => { + determineAEMCSPageIdStub.resolves('pg-456'); - const result = await resolvePageIds( + await resolvePageIds( 'https://example.com', 'https://author.example.com', ['us/en/page2'], @@ -169,19 +199,17 @@ describe('AEM Content API support', () => { log, ); - expect(result[0].pageId).to.equal('pg-456'); - expect(global.fetch).to.have.been.calledWith( + expect(determineAEMCSPageIdStub).to.have.been.calledWith( 'https://example.com/us/en/page2', - { method: 'GET', redirect: 'follow' }, + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match.any, ); }); it('strips trailing slash from siteBaseURL', async () => { - const html = ''; - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(html), - }); + determineAEMCSPageIdStub.resolves('pg-x'); await resolvePageIds( 'https://example.com/', @@ -191,17 +219,17 @@ describe('AEM Content API support', () => { log, ); - expect(global.fetch).to.have.been.calledWith( + 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 HTTP response is not ok', async () => { - fetchStub.resolves({ - ok: false, - status: 404, - }); + it('returns error when shared utility returns null', async () => { + determineAEMCSPageIdStub.resolves(null); const result = await resolvePageIds( 'https://example.com', @@ -212,14 +240,12 @@ describe('AEM Content API support', () => { ); expect(result).to.have.lengthOf(1); - expect(result[0]).to.deep.equal({ url: '/us/en/page1', error: 'HTTP 404' }); + expect(result[0].url).to.equal('/us/en/page1'); + expect(result[0].error).to.equal('Could not determine page ID'); }); - it('returns error when HTML has no content-page-id or content-page-ref', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve('no meta'), - }); + it('returns error when shared utility throws', async () => { + determineAEMCSPageIdStub.rejects(new Error('Network error')); const result = await resolvePageIds( 'https://example.com', @@ -229,126 +255,30 @@ describe('AEM Content API support', () => { log, ); - expect(result[0].error).to.equal('No content-page-id or content-page-ref'); - }); - - it('resolves via content-page-ref when content-page-id is absent', async () => { - const html = ''; - fetchStub - .onFirstCall() - .resolves({ - ok: true, - text: () => Promise.resolve(html), - }) - .onSecondCall() - .resolves({ - ok: true, - json: () => Promise.resolve({ pageId: 'pg-from-ref' }), - }); - - const result = await resolvePageIds( - 'https://example.com', - 'https://author.example.com', - ['/page'], - 'token', - log, - ); - - expect(result[0]).to.deep.equal({ url: '/page', pageId: 'pg-from-ref' }); - expect(global.fetch).to.have.been.calledTwice; - const resolveCall = global.fetch.secondCall; - expect(resolveCall.args[0]).to.include('/adobe/pages/resolve'); - expect(resolveCall.args[0]).to.include('pageRef='); - expect(resolveCall.args[1].headers.Authorization).to.equal('Bearer token'); - }); - - it('returns resolve failed when content-page-ref resolve API is not ok', async () => { - const html = ''; - fetchStub - .onFirstCall() - .resolves({ - ok: true, - text: () => Promise.resolve(html), - }) - .onSecondCall() - .resolves({ - ok: false, - status: 404, - }); - - const result = await resolvePageIds( - 'https://example.com', - 'https://author.example.com', - ['/page'], - 'token', - log, - ); - - expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); - expect(log.warn).to.have.been.calledWith('Resolve API returned 404 for pageRef'); - }); - - it('returns resolve failed when content-page-ref resolve API throws', async () => { - const html = ''; - fetchStub - .onFirstCall() - .resolves({ - ok: true, - text: () => Promise.resolve(html), - }) - .onSecondCall() - .rejects(new Error('Resolve failure')); - - const result = await resolvePageIds( - 'https://example.com', - 'https://author.example.com', - ['/page'], - 'token', - log, - ); - - expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); - expect(log.warn).to.have.been.calledWith('Resolve API error: Resolve failure'); - }); - - it('returns resolve failed when content-page-ref resolve response has no pageId', async () => { - const html = ''; - fetchStub - .onFirstCall() - .resolves({ - ok: true, - text: () => Promise.resolve(html), - }) - .onSecondCall() - .resolves({ - ok: true, - json: () => Promise.resolve({}), - }); - - 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/), ); - - expect(result[0]).to.deep.equal({ url: '/page', error: 'Resolve failed' }); }); - it('returns error when fetch throws', async () => { - fetchStub.rejects(new Error('Network error')); + 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', - ['/page'], + ['/page1', '/page2', '/page3'], 'token', log, ); - expect(result[0].error).to.equal('Network error'); - expect(log.warn).to.have.been.calledWith(sinon.match(/resolvePageIds failed/)); + 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'); }); }); From 80bc0c0c47e8ba3289ac47cb3c613b4082cb0ed4 Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Thu, 26 Feb 2026 20:50:24 +0530 Subject: [PATCH 03/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 319 +++++++++++++++---- src/routes/index.js | 1 + test/controllers/page-relationships.test.js | 330 +++++++++++++++++++- test/routes/index.test.js | 6 + 4 files changed, 588 insertions(+), 68 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index 92ef0e31d..909a94f86 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -10,7 +10,12 @@ * governing permissions and limitations under the License. */ -import { hasText, isNonEmptyArray, isValidUUID } from '@adobe/spacecat-shared-utils'; +import { + hasText, + isNonEmptyArray, + isValidUUID, + OPPORTUNITY_TYPES, +} from '@adobe/spacecat-shared-utils'; import { badRequest, createResponse, @@ -27,12 +32,98 @@ import { } 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 getSuggestionType(suggestion) { + const data = suggestion?.getData?.() || {}; + const rawType = [ + data.suggestionType, + data.issue, + ].find((value) => hasText(value)); + return hasText(rawType) ? rawType.trim() : ''; +} + +function getSuggestionPageUrls(suggestion, opportunityType = '') { + const data = suggestion?.getData?.() || {}; + const urls = new Set(); + const directUrlValues = ( + opportunityType === OPPORTUNITY_TYPES.BROKEN_BACKLINKS + || opportunityType === OPPORTUNITY_TYPES.BROKEN_INTERNAL_LINKS + ) ? [data.url_to, data.urlTo] + : [data.url, data.pageUrl]; + + directUrlValues.forEach((value) => { + if (hasText(value)) { + urls.add(value.trim()); + } + }); + + const { recommendations } = data; + if (Array.isArray(recommendations)) { + recommendations.forEach((recommendation) => { + const recommendationUrl = recommendation?.pageUrl; + if (hasText(recommendationUrl)) { + urls.add(recommendationUrl.trim()); + } + }); + } + + return Array.from(urls); +} + +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 extractPagesFromSuggestions(suggestions, options = {}) { + const { opportunityType = '', siteBaseURL = '' } = options; + const uniquePages = new Map(); + const suggestionList = Array.isArray(suggestions) ? suggestions : []; + suggestionList.forEach((suggestion) => { + const suggestionType = getSuggestionType(suggestion); + const pageUrls = getSuggestionPageUrls(suggestion, opportunityType); + pageUrls.forEach((pageUrl) => { + const normalizedPageUrl = normalizePageUrlForLookup(pageUrl, siteBaseURL); + const dedupeKey = `${normalizedPageUrl}:${suggestionType}`; + if (!uniquePages.has(dedupeKey)) { + uniquePages.set(dedupeKey, { pageUrl: normalizedPageUrl, suggestionType }); + } + }); + }); + return Array.from(uniquePages.values()); +} /** * Page relationships controller: proxy to AEM Content API for upstream relationship data. * Used for list-time enrichment (metatags/alt-text) so the UI can show fix targets. * @param {object} ctx - Context with dataAccess, log. - * @returns {object} Controller with search. + * @returns {object} Controller with search and getForOpportunity. */ function PageRelationshipsController(ctx) { const { dataAccess, log } = ctx; @@ -42,6 +133,99 @@ function PageRelationshipsController(ctx) { 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, chunked = false } = options; + const baseURL = site.getBaseURL(); + if (!hasText(baseURL)) { + return { + relationships: {}, + errors: { _config: { error: 'Site has no baseURL' } }, + }; + } + + const allRelationships = {}; + const allErrors = {}; + const pageChunks = chunked ? chunkPages(pages, MAX_PAGES) : [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 = {}; + + for (let i = 0; i < resolved.length; i += 1) { + const r = resolved[i]; + const pageSpec = normalizedBatch[i] || {}; + const responseUrl = pageSpec.normalizedPageUrl || r.url; + if (r.error || !r.pageId) { + const errKey = pageSpec.key ?? responseUrl; + resolveErrors[errKey] = { error: r.error || 'Could not resolve page' }; + } else { + const hasExplicitCheckPath = Object.prototype.hasOwnProperty.call(pageSpec, 'checkPath'); + const checkPath = hasExplicitCheckPath + ? pageSpec.checkPath + : buildCheckPath(pageSpec.suggestionType, deliveryConfig); + const key = pageSpec.key ?? `${responseUrl}:${pageSpec.suggestionType ?? ''}`; + items.push({ + key, + pageId: r.pageId, + include: ['upstream'], + ...(hasText(checkPath) && { checkPath }), + }); + } + } + + Object.assign(allErrors, resolveErrors); + + if (items.length > 0) { + // eslint-disable-next-line no-await-in-loop + const aemResponse = await fetchRelationships(authorURL, items, imsToken, log); + Object.assign(allRelationships, aemResponse.results); + Object.assign(allErrors, aemResponse.errors); + } + } + + return { + relationships: allRelationships, + errors: allErrors, + }; + } + /** * POST /sites/:siteId/page-relationships/search * Body: { pages: [ { pageUrl, suggestionType }, ... ] } @@ -62,24 +246,11 @@ function PageRelationshipsController(ctx) { return forbidden('Only users belonging to the organization can access this site'); } - const deliveryType = site.getDeliveryType(); - if (!isAEMAuthoredSite(deliveryType)) { - return createResponse({ - supported: false, - relationships: {}, - errors: {}, - }); - } - - const deliveryConfig = site.getDeliveryConfig(); - const authorURL = deliveryConfig?.authorURL; - if (!hasText(authorURL)) { - return createResponse({ - supported: false, - relationships: {}, - errors: {}, - }); + const supportState = getSupportState(site); + if (!supportState.supported) { + return createResponse(EMPTY_RELATIONSHIPS_RESPONSE); } + const { deliveryConfig, authorURL } = supportState; const pages = context.data?.pages; if (!isNonEmptyArray(pages) || pages.length > MAX_PAGES) { @@ -96,67 +267,81 @@ function PageRelationshipsController(ctx) { return badRequest('Missing Authorization header'); } - const baseURL = site.getBaseURL(); - if (!hasText(baseURL)) { - return createResponse({ - supported: true, - relationships: {}, - errors: { _config: { error: 'Site has no baseURL' } }, - }); + const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { + deliveryConfig, + authorURL, + chunked: false, + }); + + return createResponse({ + supported: true, + relationships, + errors, + }); + } + + /** + * GET /sites/:siteId/opportunities/:opportunityId/page-relationships + * Resolves page relationships from all opportunity suggestions. + * Returns { supported, relationships, errors }. + */ + async function getForOpportunity(context) { + const siteId = context.params?.siteId; + const opportunityId = context.params?.opportunityId; + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + if (!isValidUUID(opportunityId)) { + return badRequest('Opportunity ID required'); } - const pageUrls = pages.map((p) => p.pageUrl.trim()); + const site = await dataAccess.Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } - const resolved = await resolvePageIds( - baseURL, - authorURL, - pageUrls, - imsToken, - log, - ); - - const items = []; - const errors = {}; - - for (let i = 0; i < resolved.length; i += 1) { - const r = resolved[i]; - const pageSpec = pages[i] || {}; - if (r.error || !r.pageId) { - const errKey = pageSpec.key ?? r.url; - errors[errKey] = { error: r.error || 'Could not resolve page' }; - } else { - const hasExplicitCheckPath = Object.prototype.hasOwnProperty.call(pageSpec, 'checkPath'); - const checkPath = hasExplicitCheckPath - ? pageSpec.checkPath - : buildCheckPath(pageSpec.suggestionType, deliveryConfig); - const key = pageSpec.key ?? `${r.url}:${pageSpec.suggestionType ?? ''}`; - items.push({ - key, - pageId: r.pageId, - include: ['upstream'], - ...(hasText(checkPath) && { checkPath }), - }); - } + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('Only users belonging to the organization can access this site'); } - if (items.length === 0) { - return createResponse({ - supported: true, - relationships: {}, - errors, - }); + const opportunity = await dataAccess.Opportunity.findById(opportunityId); + if (!opportunity || opportunity.getSiteId() !== siteId) { + return notFound('Opportunity not found'); + } + + const supportState = getSupportState(site); + if (!supportState.supported) { + return createResponse(EMPTY_RELATIONSHIPS_RESPONSE); + } + const { deliveryConfig, authorURL } = supportState; + + const suggestions = await dataAccess.Suggestion.allByOpportunityId(opportunityId); + const pages = extractPagesFromSuggestions(suggestions, { + opportunityType: opportunity.getType?.(), + siteBaseURL: site.getBaseURL(), + }); + + let imsToken; + try { + imsToken = getImsUserToken(context); + } catch (e) { + return badRequest('Missing Authorization header'); } - const aemResponse = await fetchRelationships(authorURL, items, imsToken, log); + const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { + deliveryConfig, + authorURL, + chunked: true, + }); return createResponse({ supported: true, - relationships: aemResponse.results, - errors: { ...errors, ...aemResponse.errors }, + relationships, + errors, }); } - return { search }; + return { search, getForOpportunity }; } export default PageRelationshipsController; diff --git a/src/routes/index.js b/src/routes/index.js index 0d0f82e14..11122dddc 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -211,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, + 'GET /sites/:siteId/opportunities/:opportunityId/page-relationships': pageRelationshipsController.getForOpportunity, 'POST /sites/:siteId/opportunities': opportunitiesController.createOpportunity, 'PATCH /sites/:siteId/opportunities/:opportunityId': opportunitiesController.patchOpportunity, 'DELETE /sites/:siteId/opportunities/:opportunityId': opportunitiesController.removeOpportunity, diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index b6485d9a8..3d186f2b1 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -27,7 +27,10 @@ describe('Page Relationships Controller', () => { const sandbox = sinon.createSandbox(); const SITE_ID = 'f964a7f8-5402-4b01-bd5b-1ab499bcf797'; + const OPPORTUNITY_ID = '71b8f3a4-8c5f-4966-bc67-a933760de5c9'; const SITE_ID_INVALID = 'not-a-uuid'; + const OPPORTUNITY_ID_INVALID = 'invalid-opportunity-id'; + const ANOTHER_SITE_ID = '3a7ef7f6-ae34-4ec4-94f0-4e9707a406da'; let PageRelationshipsController; let resolvePageIdsStub; @@ -37,6 +40,7 @@ describe('Page Relationships Controller', () => { let mockDataAccess; let mockSite; + let mockOpportunity; let controllerContext; let requestContext; let log; @@ -63,10 +67,21 @@ describe('Page Relationships Controller', () => { getBaseURL: sandbox.stub().returns('https://example.com'), }; + mockOpportunity = { + getSiteId: sandbox.stub().returns(SITE_ID), + getType: sandbox.stub().returns('invalid-or-missing-metadata'), + }; + mockDataAccess = { Site: { findById: sandbox.stub().resolves(mockSite), }, + Opportunity: { + findById: sandbox.stub().resolves(mockOpportunity), + }, + Suggestion: { + allByOpportunityId: sandbox.stub().resolves([]), + }, }; controllerContext = { @@ -75,7 +90,7 @@ describe('Page Relationships Controller', () => { }; requestContext = { - params: { siteId: SITE_ID }, + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, data: { pages: [{ pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }] }, pathInfo: { headers: { authorization: 'Bearer test-ims-token' }, @@ -106,6 +121,7 @@ describe('Page Relationships Controller', () => { it('returns controller with search function', () => { const controller = PageRelationshipsController(controllerContext); expect(controller).to.have.property('search').that.is.a('function'); + expect(controller).to.have.property('getForOpportunity').that.is.a('function'); }); }); @@ -337,6 +353,19 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); }); + it('does not include checkPath when derived checkPath is an empty string', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns(''); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); + fetchRelationshipsStub.resolves({ results: {}, errors: {} }); + + const controller = PageRelationshipsController(controllerContext); + + await controller.search(requestContext); + + expect(fetchRelationshipsStub.firstCall.args[1][0]).to.not.have.property('checkPath'); + }); + it('uses checkPath from page spec when provided instead of deriving it', async () => { isAEMAuthoredSiteStub.returns(true); resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); @@ -461,4 +490,303 @@ describe('Page Relationships Controller', () => { expect(body.errors['/us/en/page2'].error).to.equal('HTTP 404'); }); }); + + describe('getForOpportunity', () => { + const createSuggestion = (data = {}, type = 'CONTENT_UPDATE') => ({ + getData: sandbox.stub().returns(data), + getType: sandbox.stub().returns(type), + }); + + it('returns 400 for invalid siteId', async () => { + const controller = PageRelationshipsController(controllerContext); + requestContext.params.siteId = SITE_ID_INVALID; + + const response = await controller.getForOpportunity(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 400 for invalid opportunityId', async () => { + const controller = PageRelationshipsController(controllerContext); + requestContext.params.opportunityId = OPPORTUNITY_ID_INVALID; + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Opportunity ID required'); + expect(mockDataAccess.Site.findById).to.not.have.been.called; + }); + + it('returns 404 when opportunity is not found', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Opportunity.findById.resolves(null); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(404); + expect(body.message).to.equal('Opportunity not found'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + }); + + it('returns 404 when opportunity belongs to another site', async () => { + isAEMAuthoredSiteStub.returns(true); + mockOpportunity.getSiteId.returns(ANOTHER_SITE_ID); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(404); + expect(body.message).to.equal('Opportunity not found'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + }); + + it('returns 400 when Authorization header is missing', async () => { + isAEMAuthoredSiteStub.returns(true); + requestContext.pathInfo = { headers: {} }; + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Missing Authorization header'); + }); + + it('returns supported: false when site is not AEM-authored', async () => { + isAEMAuthoredSiteStub.returns(false); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(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(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns empty relationships when opportunity has no suggestions', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([]); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(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.deep.equal({}); + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('extracts unique pageUrl + suggestionType pairs from suggestions', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ url: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }), + createSuggestion({ url: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }), + createSuggestion({ + issue: 'Missing Description', + recommendations: [{ pageUrl: 'https://example.com/us/en/page2' }], + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-1' }, + { url: '/us/en/page2', pageId: 'pg-2' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, + '/us/en/page2:Missing Description': { pageId: 'pg-2', upstream: { chain: [] } }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(body.supported).to.equal(true); + expect(resolvePageIdsStub).to.have.been.calledOnce; + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1', '/us/en/page2']); + expect(fetchRelationshipsStub).to.have.been.calledOnce; + expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(2); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:Missing Title'); + expect(fetchRelationshipsStub.firstCall.args[1][1].key).to.equal('/us/en/page2:Missing Description'); + expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + expect(body.relationships).to.have.property('/us/en/page2:Missing Description'); + }); + + it('uses normalized path key when suggestion URL is absolute', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + 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.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:Missing Title'); + expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + }); + + it('does not derive suggestion type from title or opportunity type', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion( + { + pageUrl: '/us/en/page1', + title: 'Marketing Page', + }, + 'CONTENT_UPDATE', + ), + ]); + resolvePageIdsStub.resolves([{ url: '/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.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:'); + expect(body.relationships).to.have.property('/us/en/page1:'); + }); + + it('uses url_to/urlTo for broken-link opportunities and ignores url_from/urlFrom', async () => { + isAEMAuthoredSiteStub.returns(true); + mockOpportunity.getType.returns('broken-backlinks'); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + url_from: '/us/en/referrer-page-a', + urlFrom: '/us/en/referrer-page-b', + url_to: '/us/en/page-with-issue', + urlTo: '/us/en/page-with-issue-camel', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/us/en/page-with-issue', pageId: 'pg-1' }, + { url: '/us/en/page-with-issue-camel', pageId: 'pg-2' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + '/us/en/page-with-issue:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, + '/us/en/page-with-issue-camel:Missing Title': { pageId: 'pg-2', upstream: { chain: [] } }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page-with-issue', '/us/en/page-with-issue-camel']); + expect(body.relationships).to.have.property('/us/en/page-with-issue:Missing Title'); + expect(body.relationships).to.have.property('/us/en/page-with-issue-camel:Missing Title'); + }); + + it('deduplicates suggestions by normalized pageUrl + suggestionType', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + url: 'https://example.com/us/en/page-dup', + suggestionType: 'Missing Title', + }), + createSuggestion({ + pageUrl: '/us/en/page-dup', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([{ url: '/us/en/page-dup', pageId: 'pg-dup' }]); + fetchRelationshipsStub.resolves({ + results: { + '/us/en/page-dup:Missing Title': { pageId: 'pg-dup', upstream: { chain: [] } }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolvePageIdsStub).to.have.been.calledOnce; + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page-dup']); + expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); + expect(body.relationships).to.have.property('/us/en/page-dup:Missing Title'); + }); + + it('batches requests in chunks of 50 for more than 50 unique pages', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves( + Array.from({ length: 120 }, (_, i) => createSuggestion({ + url: `/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.getForOpportunity(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); + }); + }); }); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 9fc5fe976..27093a1ee 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -340,6 +340,7 @@ describe('getRouteHandlers', () => { const mockPageRelationshipsController = { search: sinon.stub(), + getForOpportunity: sinon.stub(), }; it('segregates static and dynamic routes', () => { @@ -509,6 +510,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/opportunities/top-paid', 'GET /sites/:siteId/opportunities/by-status/:status', 'GET /sites/:siteId/opportunities/:opportunityId', + 'GET /sites/:siteId/opportunities/:opportunityId/page-relationships', 'POST /sites/:siteId/opportunities', 'PATCH /sites/:siteId/opportunities/:opportunityId', 'DELETE /sites/:siteId/opportunities/:opportunityId', @@ -730,6 +732,10 @@ describe('getRouteHandlers', () => { 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['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].handler) + .to.equal(mockPageRelationshipsController.getForOpportunity); + expect(dynamicRoutes['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].paramNames) + .to.deep.equal(['siteId', 'opportunityId']); 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); From 6706cad758fb57c47c5804e322644424f1456859 Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Thu, 26 Feb 2026 21:55:03 +0530 Subject: [PATCH 04/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 120 ++-- src/routes/index.js | 3 - test/controllers/page-relationships.test.js | 612 +++++++++----------- test/routes/index.test.js | 3 - 4 files changed, 314 insertions(+), 424 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index 909a94f86..e0c049953 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -12,9 +12,7 @@ import { hasText, - isNonEmptyArray, isValidUUID, - OPPORTUNITY_TYPES, } from '@adobe/spacecat-shared-utils'; import { badRequest, @@ -32,6 +30,7 @@ import { } from '../support/aem-content-api.js'; const MAX_PAGES = 50; +const SUPPORTED_OPPORTUNITY_TYPES = new Set(['meta-tags', 'alt-text']); const EMPTY_RELATIONSHIPS_RESPONSE = { supported: false, relationships: {}, @@ -55,14 +54,10 @@ function getSuggestionType(suggestion) { return hasText(rawType) ? rawType.trim() : ''; } -function getSuggestionPageUrls(suggestion, opportunityType = '') { +function getSuggestionPageUrls(suggestion) { const data = suggestion?.getData?.() || {}; const urls = new Set(); - const directUrlValues = ( - opportunityType === OPPORTUNITY_TYPES.BROKEN_BACKLINKS - || opportunityType === OPPORTUNITY_TYPES.BROKEN_INTERNAL_LINKS - ) ? [data.url_to, data.urlTo] - : [data.url, data.pageUrl]; + const directUrlValues = [data.url, data.pageUrl]; directUrlValues.forEach((value) => { if (hasText(value)) { @@ -102,12 +97,12 @@ function normalizePageUrlForLookup(pageUrl, siteBaseURL) { } function extractPagesFromSuggestions(suggestions, options = {}) { - const { opportunityType = '', siteBaseURL = '' } = options; + const { siteBaseURL = '' } = options; const uniquePages = new Map(); const suggestionList = Array.isArray(suggestions) ? suggestions : []; suggestionList.forEach((suggestion) => { const suggestionType = getSuggestionType(suggestion); - const pageUrls = getSuggestionPageUrls(suggestion, opportunityType); + const pageUrls = getSuggestionPageUrls(suggestion); pageUrls.forEach((pageUrl) => { const normalizedPageUrl = normalizePageUrlForLookup(pageUrl, siteBaseURL); const dedupeKey = `${normalizedPageUrl}:${suggestionType}`; @@ -123,7 +118,7 @@ function extractPagesFromSuggestions(suggestions, options = {}) { * Page relationships controller: proxy to AEM Content API for upstream relationship data. * Used for list-time enrichment (metatags/alt-text) so the UI can show fix targets. * @param {object} ctx - Context with dataAccess, log. - * @returns {object} Controller with search and getForOpportunity. + * @returns {object} Controller with getForOpportunity. */ function PageRelationshipsController(ctx) { const { dataAccess, log } = ctx; @@ -157,7 +152,7 @@ function PageRelationshipsController(ctx) { } async function lookupRelationships(site, pages, imsToken, options = {}) { - const { deliveryConfig, authorURL, chunked = false } = options; + const { deliveryConfig, authorURL } = options; const baseURL = site.getBaseURL(); if (!hasText(baseURL)) { return { @@ -168,7 +163,7 @@ function PageRelationshipsController(ctx) { const allRelationships = {}; const allErrors = {}; - const pageChunks = chunked ? chunkPages(pages, MAX_PAGES) : [pages]; + const pageChunks = chunkPages(pages, MAX_PAGES); for (const pageBatch of pageChunks) { const normalizedBatch = pageBatch.map((pageSpec) => ({ @@ -188,25 +183,23 @@ function PageRelationshipsController(ctx) { const items = []; const resolveErrors = {}; - for (let i = 0; i < resolved.length; i += 1) { - const r = resolved[i]; - const pageSpec = normalizedBatch[i] || {}; - const responseUrl = pageSpec.normalizedPageUrl || r.url; + for (let i = 0; i < normalizedBatch.length; i += 1) { + const pageSpec = normalizedBatch[i]; + const r = resolved[i] || {}; + const responseUrl = pageSpec.normalizedPageUrl; if (r.error || !r.pageId) { - const errKey = pageSpec.key ?? responseUrl; - resolveErrors[errKey] = { error: r.error || 'Could not resolve page' }; + resolveErrors[responseUrl] = { error: r.error || 'Could not resolve page' }; } else { - const hasExplicitCheckPath = Object.prototype.hasOwnProperty.call(pageSpec, 'checkPath'); - const checkPath = hasExplicitCheckPath - ? pageSpec.checkPath - : buildCheckPath(pageSpec.suggestionType, deliveryConfig); - const key = pageSpec.key ?? `${responseUrl}:${pageSpec.suggestionType ?? ''}`; - items.push({ - key, + const checkPath = buildCheckPath(pageSpec.suggestionType, deliveryConfig); + const item = { + key: `${responseUrl}:${pageSpec.suggestionType}`, pageId: r.pageId, include: ['upstream'], - ...(hasText(checkPath) && { checkPath }), - }); + }; + if (hasText(checkPath)) { + item.checkPath = checkPath; + } + items.push(item); } } @@ -226,60 +219,6 @@ function PageRelationshipsController(ctx) { }; } - /** - * POST /sites/:siteId/page-relationships/search - * Body: { pages: [ { pageUrl, suggestionType }, ... ] } - * 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 supportState = getSupportState(site); - if (!supportState.supported) { - return createResponse(EMPTY_RELATIONSHIPS_RESPONSE); - } - const { deliveryConfig, authorURL } = supportState; - - const pages = context.data?.pages; - if (!isNonEmptyArray(pages) || pages.length > MAX_PAGES) { - return badRequest(`pages array required (max ${MAX_PAGES} items)`); - } - if (pages.some((page) => !page || !hasText(page.pageUrl))) { - return badRequest('Each page must include a non-empty pageUrl'); - } - - let imsToken; - try { - imsToken = getImsUserToken(context); - } catch (e) { - return badRequest('Missing Authorization header'); - } - - const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { - deliveryConfig, - authorURL, - chunked: false, - }); - - return createResponse({ - supported: true, - relationships, - errors, - }); - } - /** * GET /sites/:siteId/opportunities/:opportunityId/page-relationships * Resolves page relationships from all opportunity suggestions. @@ -308,6 +247,19 @@ function PageRelationshipsController(ctx) { if (!opportunity || opportunity.getSiteId() !== siteId) { return notFound('Opportunity not found'); } + const opportunityType = opportunity.getType?.(); + if (!SUPPORTED_OPPORTUNITY_TYPES.has(opportunityType)) { + log.warn(`Unsupported opportunity type for page relationships: ${opportunityType || 'unknown'} (opportunityId=${opportunityId})`); + return createResponse({ + supported: false, + relationships: {}, + errors: { + _opportunity: { + error: `Unsupported opportunity type: ${opportunityType || 'unknown'}`, + }, + }, + }); + } const supportState = getSupportState(site); if (!supportState.supported) { @@ -317,7 +269,6 @@ function PageRelationshipsController(ctx) { const suggestions = await dataAccess.Suggestion.allByOpportunityId(opportunityId); const pages = extractPagesFromSuggestions(suggestions, { - opportunityType: opportunity.getType?.(), siteBaseURL: site.getBaseURL(), }); @@ -331,7 +282,6 @@ function PageRelationshipsController(ctx) { const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { deliveryConfig, authorURL, - chunked: true, }); return createResponse({ @@ -341,7 +291,7 @@ function PageRelationshipsController(ctx) { }); } - return { search, getForOpportunity }; + return { getForOpportunity }; } export default PageRelationshipsController; diff --git a/src/routes/index.js b/src/routes/index.js index 11122dddc..cb51e530f 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -406,9 +406,6 @@ export default function getRouteHandlers( 'GET /sites-resolve': sitesController.resolveSite, - // Page relationships (AEM upstream chain for list-time fix target display) - 'POST /sites/:siteId/page-relationships/search': pageRelationshipsController.search, - // Sentiment Analysis endpoints // Topics 'GET /sites/:siteId/sentiment/topics': sentimentController.listTopics, diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 3d186f2b1..1d15e2f59 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -69,7 +69,7 @@ describe('Page Relationships Controller', () => { mockOpportunity = { getSiteId: sandbox.stub().returns(SITE_ID), - getType: sandbox.stub().returns('invalid-or-missing-metadata'), + getType: sandbox.stub().returns('meta-tags'), }; mockDataAccess = { @@ -118,19 +118,23 @@ describe('Page Relationships Controller', () => { expect(() => PageRelationshipsController({ log })).to.throw('Data access required'); }); - it('returns controller with search function', () => { + it('returns controller with getForOpportunity function', () => { const controller = PageRelationshipsController(controllerContext); - expect(controller).to.have.property('search').that.is.a('function'); expect(controller).to.have.property('getForOpportunity').that.is.a('function'); }); }); - describe('search', () => { + describe('getForOpportunity', () => { + const createSuggestion = (data = {}, type = 'CONTENT_UPDATE') => ({ + getData: sandbox.stub().returns(data), + getType: sandbox.stub().returns(type), + }); + it('returns 400 for invalid siteId', async () => { const controller = PageRelationshipsController(controllerContext); requestContext.params.siteId = SITE_ID_INVALID; - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(400); @@ -138,101 +142,104 @@ describe('Page Relationships Controller', () => { expect(mockDataAccess.Site.findById).to.not.have.been.called; }); + it('returns 400 for invalid opportunityId', async () => { + const controller = PageRelationshipsController(controllerContext); + requestContext.params.opportunityId = OPPORTUNITY_ID_INVALID; + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Opportunity 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 response = await controller.getForOpportunity(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); + expect(mockDataAccess.Opportunity.findById).to.not.have.been.called; }); - it('returns 403 when user does not have access', async () => { + it('returns 403 when user does not have access to the site', async () => { AccessControlUtil.fromContext.returns({ hasAccess: sandbox.stub().resolves(false) }); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(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'); + expect(mockDataAccess.Opportunity.findById).to.not.have.been.called; }); - it('returns supported: false when delivery type is not AEM-authored', async () => { - isAEMAuthoredSiteStub.returns(false); + it('returns 404 when opportunity is not found', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Opportunity.findById.resolves(null); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(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(resolvePageIdsStub).to.not.have.been.called; + expect(response.status).to.equal(404); + expect(body.message).to.equal('Opportunity not found'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; }); - it('returns supported: false when authorURL is missing', async () => { + it('returns 404 when opportunity belongs to another site', async () => { isAEMAuthoredSiteStub.returns(true); - mockSite.getDeliveryConfig.returns({}); - + mockOpportunity.getSiteId.returns(ANOTHER_SITE_ID); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(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(resolvePageIdsStub).to.not.have.been.called; - }); - - 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.include('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.include('pages array required'); + expect(response.status).to.equal(404); + expect(body.message).to.equal('Opportunity not found'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; }); - it('returns 400 when pages entry has missing pageUrl', async () => { + it('returns supported: false for unsupported opportunity type', async () => { isAEMAuthoredSiteStub.returns(true); - requestContext.data = { pages: [{}] }; + mockOpportunity.getType.returns('broken-backlinks'); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(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(response.status).to.equal(200); + expect(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.have.property('_opportunity'); + const { _opportunity: unsupportedTypeError } = body.errors; + expect(unsupportedTypeError.error).to.equal('Unsupported opportunity type: broken-backlinks'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns 400 when pages exceeds max size', async () => { + it('returns supported: false with unknown type when opportunity type is missing', async () => { isAEMAuthoredSiteStub.returns(true); - requestContext.data = { - pages: Array(51).fill({ pageUrl: '/p', suggestionType: 'x' }), - }; + mockOpportunity.getType.returns(undefined); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); - expect(response.status).to.equal(400); - expect(body.message).to.include('max 50'); + expect(response.status).to.equal(200); + expect(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.have.property('_opportunity'); + const { _opportunity: unknownTypeError } = body.errors; + expect(unknownTypeError.error).to.equal('Unsupported opportunity type: unknown'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; }); it('returns 400 when Authorization header is missing', async () => { @@ -240,342 +247,183 @@ describe('Page Relationships Controller', () => { requestContext.pathInfo = { headers: {} }; const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(400); expect(body.message).to.equal('Missing Authorization header'); }); - it('returns supported: true with _config error when site has no baseURL', async () => { - isAEMAuthoredSiteStub.returns(true); - mockSite.getBaseURL.returns(''); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg1' }]); - + it('returns supported: false when site is not AEM-authored', async () => { + isAEMAuthoredSiteStub.returns(false); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(body.supported).to.equal(true); - 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(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + expect(resolvePageIdsStub).to.not.have.been.called; }); - it('returns supported: true with relationships when resolve and fetch succeed', async () => { + it('returns supported: false when authorURL is missing', async () => { isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - const resultKey = '/us/en/page1:Missing Title'; - fetchRelationshipsStub.resolves({ - results: { - [resultKey]: { - pageId: 'pg-123', - upstream: { chain: [{ relation: 'liveCopyOf', pageId: 'pg-blueprint', pagePath: '/content/blueprint/en/page1' }] }, - }, - }, - errors: {}, - }); - + mockSite.getDeliveryConfig.returns({}); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(body.supported).to.equal(true); - expect(body.relationships).to.have.property(resultKey); - expect(body.relationships[resultKey].upstream.chain).to.have.lengthOf(1); + expect(body.supported).to.equal(false); + expect(body.relationships).to.deep.equal({}); expect(body.errors).to.deep.equal({}); - - expect(resolvePageIdsStub).to.have.been.calledOnce; - expect(resolvePageIdsStub.firstCall.args[0]).to.equal('https://example.com'); - expect(resolvePageIdsStub.firstCall.args[1]).to.equal('https://author.example.com'); - expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1']); - expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); - - expect(fetchRelationshipsStub).to.have.been.calledOnce; - expect(fetchRelationshipsStub.firstCall.args[0]).to.equal('https://author.example.com'); - expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); - expect(fetchRelationshipsStub.firstCall.args[1][0]).to.include({ key: resultKey, pageId: 'pg-123' }); - expect(fetchRelationshipsStub.firstCall.args[1][0].include).to.deep.equal(['upstream']); - expect(fetchRelationshipsStub.firstCall.args[2]).to.equal('test-ims-token'); + expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('merges resolve errors and AEM API errors in response', async () => { + it('returns empty relationships when opportunity has no suggestions', async () => { isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1', pageId: 'pg-123' }, - { url: '/us/en/page2', error: 'HTTP 404' }, - ]); - fetchRelationshipsStub.resolves({ - results: { - '/us/en/page1:': { pageId: 'pg-123', upstream: { chain: [] } }, - }, - errors: { - '/us/en/page1:': { error: 'NOT_FOUND' }, - }, - }); - - requestContext.data = { - pages: [ - { pageUrl: '/us/en/page1' }, - { pageUrl: '/us/en/page2' }, - ], - }; - + mockDataAccess.Suggestion.allByOpportunityId.resolves([]); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); expect(body.supported).to.equal(true); - expect(body.errors).to.include.keys('/us/en/page2'); - expect(body.errors['/us/en/page2'].error).to.equal('HTTP 404'); - expect(body.errors['/us/en/page1:'].error).to.equal('NOT_FOUND'); - }); - - it('calls buildCheckPath with suggestionType and delivery config', async () => { - isAEMAuthoredSiteStub.returns(true); - buildCheckPathStub.returns('/properties/jcr:title'); - const deliveryConfig = { authorURL: 'https://author.example.com', metaTagPropertyMap: { title: 'jcr:title' } }; - mockSite.getDeliveryConfig.returns(deliveryConfig); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); - - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', deliveryConfig); - expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); - }); - - it('does not include checkPath when derived checkPath is an empty string', async () => { - isAEMAuthoredSiteStub.returns(true); - buildCheckPathStub.returns(''); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: {}, errors: {} }); - - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(fetchRelationshipsStub.firstCall.args[1][0]).to.not.have.property('checkPath'); - }); - - it('uses checkPath from page spec when provided instead of deriving it', async () => { - isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: {}, errors: {} }); - requestContext.data = { - pages: [{ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - checkPath: '/properties/custom-prop', - }], - }; - - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(buildCheckPathStub).to.not.have.been.called; - expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/custom-prop'); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('does not derive checkPath when an explicit empty checkPath is provided', async () => { + it('returns _config error when site has no baseURL', async () => { isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: {}, errors: {} }); - requestContext.data = { - pages: [{ + mockSite.getBaseURL.returns(''); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ pageUrl: '/us/en/page1', suggestionType: 'Missing Title', - checkPath: '', - }], - }; - - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(buildCheckPathStub).to.not.have.been.called; - expect(fetchRelationshipsStub.firstCall.args[1][0]).to.not.have.property('checkPath'); - }); - - it('uses page key from request when provided', async () => { - isAEMAuthoredSiteStub.returns(true); - buildCheckPathStub.returns(undefined); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: { 'page-1': { upstream: { chain: [] } } }, errors: {} }); - requestContext.data = { - pages: [{ key: 'page-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }], - }; - - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('page-1'); - }); - - it('passes delivery config without metaTagPropertyMap when config has no map', async () => { - isAEMAuthoredSiteStub.returns(true); - buildCheckPathStub.returns(undefined); - const deliveryConfig = { authorURL: 'https://author.example.com' }; - mockSite.getDeliveryConfig.returns(deliveryConfig); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-123' }]); - fetchRelationshipsStub.resolves({ results: { k1: { upstream: { chain: [] } } }, errors: {} }); - const controller = PageRelationshipsController(controllerContext); - - await controller.search(requestContext); - - expect(buildCheckPathStub).to.have.been.calledWith('Missing Title', deliveryConfig); - }); - - it('returns supported: true with empty relationships 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 response = await controller.getForOpportunity(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('/us/en/page1'); + 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('uses default resolve error message when resolve result has no pageId and no error', async () => { + it('returns empty relationships when suggestions payload is not an array', async () => { isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([{ url: '/us/en/page1' }]); - + mockDataAccess.Suggestion.allByOpportunityId.resolves({ suggestions: [] }); const controller = PageRelationshipsController(controllerContext); - const response = await controller.search(requestContext); + const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(body.errors['/us/en/page1'].error).to.equal('Could not resolve page'); + expect(body.supported).to.equal(true); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); + expect(resolvePageIdsStub).to.not.have.been.called; expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('uses resolved url as error key when resolved item has no page spec match', async () => { + it('skips suggestion entries that do not implement getData', async () => { isAEMAuthoredSiteStub.returns(true); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1', pageId: 'pg-123' }, - { url: '/us/en/page2', error: 'HTTP 404' }, + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + null, + {}, + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), ]); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); fetchRelationshipsStub.resolves({ - results: { '/us/en/page1:Missing Title': { pageId: 'pg-123', upstream: { chain: [] } } }, + results: { + '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, + }, errors: {}, }); - requestContext.data = { - pages: [{ pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }], - }; - const controller = PageRelationshipsController(controllerContext); - - const response = await controller.search(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(200); - expect(body.errors['/us/en/page2'].error).to.equal('HTTP 404'); - }); - }); - - describe('getForOpportunity', () => { - const createSuggestion = (data = {}, type = 'CONTENT_UPDATE') => ({ - getData: sandbox.stub().returns(data), - getType: sandbox.stub().returns(type), - }); - - it('returns 400 for invalid siteId', async () => { - const controller = PageRelationshipsController(controllerContext); - requestContext.params.siteId = SITE_ID_INVALID; - - const response = await controller.getForOpportunity(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 400 for invalid opportunityId', async () => { - const controller = PageRelationshipsController(controllerContext); - requestContext.params.opportunityId = OPPORTUNITY_ID_INVALID; - - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(400); - expect(body.message).to.equal('Opportunity ID required'); - expect(mockDataAccess.Site.findById).to.not.have.been.called; - }); - - it('returns 404 when opportunity is not found', async () => { - isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Opportunity.findById.resolves(null); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); const body = await response.json(); - expect(response.status).to.equal(404); - expect(body.message).to.equal('Opportunity not found'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(response.status).to.equal(200); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1']); + expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); + expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); }); - it('returns 404 when opportunity belongs to another site', async () => { + it('returns resolve error details when a page cannot be resolved', async () => { isAEMAuthoredSiteStub.returns(true); - mockOpportunity.getSiteId.returns(ANOTHER_SITE_ID); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', error: 'HTTP 404' }, + ]); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); const body = await response.json(); - expect(response.status).to.equal(404); - expect(body.message).to.equal('Opportunity not found'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(response.status).to.equal(200); + expect(body.supported).to.equal(true); + expect(body.relationships).to.deep.equal({}); + expect(body.errors['/us/en/page1'].error).to.equal('HTTP 404'); + expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns 400 when Authorization header is missing', async () => { + it('returns default resolve error when no pageId and no resolve error are returned', async () => { isAEMAuthoredSiteStub.returns(true); - requestContext.pathInfo = { headers: {} }; - const controller = PageRelationshipsController(controllerContext); - - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(400); - expect(body.message).to.equal('Missing Authorization header'); - }); - - it('returns supported: false when site is not AEM-authored', async () => { - isAEMAuthoredSiteStub.returns(false); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1' }, + ]); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(body.supported).to.equal(false); + expect(body.supported).to.equal(true); expect(body.relationships).to.deep.equal({}); - expect(body.errors).to.deep.equal({}); - expect(resolvePageIdsStub).to.not.have.been.called; + expect(body.errors['/us/en/page1'].error).to.equal('Could not resolve page'); + expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns empty relationships when opportunity has no suggestions', async () => { + it('returns default resolve error when resolver returns fewer entries than requested pages', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([]); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([]); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -584,8 +432,7 @@ describe('Page Relationships Controller', () => { expect(response.status).to.equal(200); expect(body.supported).to.equal(true); expect(body.relationships).to.deep.equal({}); - expect(body.errors).to.deep.equal({}); - expect(resolvePageIdsStub).to.not.have.been.called; + expect(body.errors['/us/en/page1'].error).to.equal('Could not resolve page'); expect(fetchRelationshipsStub).to.not.have.been.called; }); @@ -627,6 +474,37 @@ describe('Page Relationships Controller', () => { expect(body.relationships).to.have.property('/us/en/page2:Missing Description'); }); + it('includes derived checkPath when buildCheckPath returns a non-empty value', async () => { + isAEMAuthoredSiteStub.returns(true); + buildCheckPathStub.returns('/properties/jcr:title'); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/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.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); + expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + }); + it('uses normalized path key when suggestion URL is absolute', async () => { isAEMAuthoredSiteStub.returns(true); mockDataAccess.Suggestion.allByOpportunityId.resolves([ @@ -658,18 +536,17 @@ describe('Page Relationships Controller', () => { expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); }); - it('does not derive suggestion type from title or opportunity type', async () => { + it('keeps absolute URL for lookup when suggestion host differs from site host', async () => { isAEMAuthoredSiteStub.returns(true); mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion( - { - pageUrl: '/us/en/page1', - title: 'Marketing Page', - }, - 'CONTENT_UPDATE', - ), + createSuggestion({ + pageUrl: 'https://external.example.com/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: 'https://external.example.com/us/en/page1', pageId: 'pg-1' }, ]); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ results: { [items[0].key]: { @@ -685,42 +562,111 @@ describe('Page Relationships Controller', () => { const body = await response.json(); expect(response.status).to.equal(200); - expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:'); - expect(body.relationships).to.have.property('/us/en/page1:'); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['https://external.example.com/us/en/page1']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key) + .to.equal('https://external.example.com/us/en/page1:Missing Title'); + expect(body.relationships).to.have.property('https://external.example.com/us/en/page1:Missing Title'); }); - it('uses url_to/urlTo for broken-link opportunities and ignores url_from/urlFrom', async () => { + it('keeps absolute URL when normalization cannot parse site base URL', async () => { isAEMAuthoredSiteStub.returns(true); - mockOpportunity.getType.returns('broken-backlinks'); + mockSite.getBaseURL.returns('invalid-site-url'); mockDataAccess.Suggestion.allByOpportunityId.resolves([ createSuggestion({ - url_from: '/us/en/referrer-page-a', - urlFrom: '/us/en/referrer-page-b', - url_to: '/us/en/page-with-issue', - urlTo: '/us/en/page-with-issue-camel', + pageUrl: 'https://example.com/us/en/page1', suggestionType: 'Missing Title', }), ]); resolvePageIdsStub.resolves([ - { url: '/us/en/page-with-issue', pageId: 'pg-1' }, - { url: '/us/en/page-with-issue-camel', pageId: 'pg-2' }, + { url: 'https://example.com/us/en/page1', pageId: 'pg-1' }, ]); - fetchRelationshipsStub.resolves({ + fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ results: { - '/us/en/page-with-issue:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, - '/us/en/page-with-issue-camel:Missing Title': { pageId: 'pg-2', upstream: { chain: [] } }, + [items[0].key]: { + pageId: items[0].pageId, + upstream: { chain: [] }, + }, }, errors: {}, + })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(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('https://example.com/us/en/page1:Missing Title'); + expect(body.relationships).to.have.property('https://example.com/us/en/page1:Missing Title'); + }); + + it('falls back to root path when normalized absolute URL has empty pathname', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + 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.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page-with-issue', '/us/en/page-with-issue-camel']); - expect(body.relationships).to.have.property('/us/en/page-with-issue:Missing Title'); - expect(body.relationships).to.have.property('/us/en/page-with-issue-camel:Missing Title'); + expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/']); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/:Missing Title'); + expect(body.relationships).to.have.property('/:Missing Title'); + }); + + it('does not derive suggestion type from title or opportunity type', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion( + { + pageUrl: '/us/en/page1', + title: 'Marketing Page', + }, + 'CONTENT_UPDATE', + ), + ]); + resolvePageIdsStub.resolves([{ url: '/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.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:'); + expect(body.relationships).to.have.property('/us/en/page1:'); }); it('deduplicates suggestions by normalized pageUrl + suggestionType', async () => { diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 27093a1ee..249165fc1 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -531,7 +531,6 @@ describe('getRouteHandlers', () => { 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId', 'DELETE /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId', 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/:suggestionId/fixes', - 'POST /sites/:siteId/page-relationships/search', 'GET /sites/:siteId/scraped-content/:type', 'GET /sites/:siteId/top-pages', 'GET /sites/:siteId/top-pages/:source', @@ -730,8 +729,6 @@ 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['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].handler) .to.equal(mockPageRelationshipsController.getForOpportunity); expect(dynamicRoutes['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].paramNames) From a458687fe3a31133fe01e53ee51abfcc1e5cb6ab Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 14:21:08 +0530 Subject: [PATCH 05/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 12 +- src/support/utils.js | 75 ++++++++++ test/controllers/page-relationships.test.js | 69 ++++++++- test/support/utils.test.js | 153 +++++++++++++++++++- 4 files changed, 303 insertions(+), 6 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index e0c049953..df258b9db 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -21,7 +21,9 @@ import { notFound, } from '@adobe/spacecat-shared-http-utils'; import AccessControlUtil from '../support/access-control-util.js'; -import { getImsUserToken } from '../support/utils.js'; +import { + resolveAemAccessToken, +} from '../support/utils.js'; import { isAEMAuthoredSite, resolvePageIds, @@ -274,9 +276,13 @@ function PageRelationshipsController(ctx) { let imsToken; try { - imsToken = getImsUserToken(context); + imsToken = await resolveAemAccessToken(context); } catch (e) { - return badRequest('Missing Authorization header'); + if (e?.status === 401) { + log.warn(`Failed to resolve AEM access token for opportunity ${opportunityId}: ${e.message}`); + return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); + } + return badRequest(e?.message || 'Missing Authorization header'); } const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { diff --git a/src/support/utils.js b/src/support/utils.js index b20e6677a..fe2777403 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -649,6 +649,81 @@ export async function exchangePromiseToken(context, promiseToken) { return accessToken; } +const X_PROMISE_TOKEN_HEADER = 'x-promise-token'; +const BEARER_PREFIX = 'Bearer '; + +function getHeaderCaseInsensitive(headers, headerName) { + if (!headers || typeof headers !== 'object') { + return ''; + } + const headerEntry = Object.entries(headers).find(([name, value]) => ( + name.toLowerCase() === headerName && hasText(value) + )); + return headerEntry ? headerEntry[1].trim() : ''; +} + +function getPromiseTokenFromContext(context) { + const dataPromiseToken = context?.data?.promiseToken?.promise_token; + if (hasText(dataPromiseToken)) { + return dataPromiseToken.trim(); + } + + const requestPromiseToken = context?.request?.headers?.get?.(X_PROMISE_TOKEN_HEADER); + if (hasText(requestPromiseToken)) { + return requestPromiseToken.trim(); + } + + return getHeaderCaseInsensitive(context?.pathInfo?.headers, X_PROMISE_TOKEN_HEADER); +} + +function extractBearerToken(authorizationHeader) { + if (!hasText(authorizationHeader) || !authorizationHeader.startsWith(BEARER_PREFIX)) { + throw new ErrorWithStatusCode('Missing Authorization header', STATUS_BAD_REQUEST); + } + return authorizationHeader.substring(BEARER_PREFIX.length); +} + +/** + * Resolve an access token for AEM API calls. + * Precedence follows autofix-worker behavior: + * 1) promise token (context.data.promiseToken.promise_token or x-promise-token) + * 2) Authorization bearer token (pathInfo headers) + * 3) Authorization bearer token (invocation event headers) + * + * @param {object} context - request context. + * @param {object} [options] - optional injected dependencies for testing. + * @param {Function} [options.exchangePromiseTokenFn] - promise token exchanger. + * @param {Function} [options.getImsUserTokenFn] - bearer token extractor. + * @returns {Promise} Access token without Bearer prefix. + * @throws {ErrorWithStatusCode} for auth failures. + */ +export async function resolveAemAccessToken( + context, + { + exchangePromiseTokenFn = exchangePromiseToken, + getImsUserTokenFn = getImsUserToken, + } = {}, +) { + const promiseToken = getPromiseTokenFromContext(context); + if (hasText(promiseToken)) { + try { + return await exchangePromiseTokenFn(context, promiseToken); + } catch (e) { + throw new ErrorWithStatusCode('Authentication failed with upstream IMS service', 401); + } + } + + try { + return getImsUserTokenFn(context); + } catch (e) { + const invocationAuthorization = getHeaderCaseInsensitive( + context?.invocation?.event?.headers, + 'authorization', + ); + return extractBearerToken(invocationAuthorization); + } +} + /** * Build an S3 prefix for site content files. * @param {string} type - The type of content (e.g., 'scrapes', 'imports', 'accessibility'). diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 1d15e2f59..eff55e431 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -37,6 +37,7 @@ describe('Page Relationships Controller', () => { let fetchRelationshipsStub; let isAEMAuthoredSiteStub; let buildCheckPathStub; + let resolveAemAccessTokenStub; let mockDataAccess; let mockSite; @@ -57,6 +58,7 @@ describe('Page Relationships Controller', () => { fetchRelationshipsStub = sandbox.stub(); isAEMAuthoredSiteStub = sandbox.stub(); buildCheckPathStub = sandbox.stub(); + resolveAemAccessTokenStub = sandbox.stub().resolves('test-ims-token'); mockSite = { getDeliveryType: sandbox.stub().returns('aem_cs'), @@ -106,6 +108,9 @@ describe('Page Relationships Controller', () => { fetchRelationships: fetchRelationshipsStub, buildCheckPath: buildCheckPathStub, }, + '../../src/support/utils.js': { + resolveAemAccessToken: (...args) => resolveAemAccessTokenStub(...args), + }, })).default; }); @@ -242,9 +247,9 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns 400 when Authorization header is missing', async () => { + it('returns 400 when AEM token cannot be resolved', async () => { isAEMAuthoredSiteStub.returns(true); - requestContext.pathInfo = { headers: {} }; + resolveAemAccessTokenStub.rejects(Object.assign(new Error('Missing Authorization header'), { status: 400 })); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -254,6 +259,66 @@ describe('Page Relationships Controller', () => { expect(body.message).to.equal('Missing Authorization header'); }); + it('returns default 400 message when AEM token error has no message', async () => { + isAEMAuthoredSiteStub.returns(true); + resolveAemAccessTokenStub.rejects(Object.assign(new Error(), { message: '', status: 400 })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Missing Authorization header'); + }); + + it('returns 401 when AEM token exchange fails', async () => { + isAEMAuthoredSiteStub.returns(true); + resolveAemAccessTokenStub.rejects(Object.assign(new Error('Authentication failed with upstream IMS service'), { status: 401 })); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(401); + expect(body.message).to.equal('Authentication failed with upstream IMS service'); + expect(resolvePageIdsStub).to.not.have.been.called; + expect(fetchRelationshipsStub).to.not.have.been.called; + }); + + it('uses resolved AEM token for relationship lookup', async () => { + isAEMAuthoredSiteStub.returns(true); + mockDataAccess.Suggestion.allByOpportunityId.resolves([ + createSuggestion({ + pageUrl: '/us/en/page1', + suggestionType: 'Missing Title', + }), + ]); + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', pageId: 'pg-1' }, + ]); + fetchRelationshipsStub.resolves({ + results: { + '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, + }, + errors: {}, + }); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(200); + expect(resolveAemAccessTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); + expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + }); + it('returns supported: false when site is not AEM-authored', async () => { isAEMAuthoredSiteStub.returns(false); const controller = PageRelationshipsController(controllerContext); diff --git a/test/support/utils.test.js b/test/support/utils.test.js index 2cff4f05c..68c99b3c2 100644 --- a/test/support/utils.test.js +++ b/test/support/utils.test.js @@ -18,7 +18,12 @@ import sinon from 'sinon'; import nock from 'nock'; import { - createProject, deriveProjectName, autoResolveAuthorUrl, updateCodeConfig, + createProject, + deriveProjectName, + autoResolveAuthorUrl, + updateCodeConfig, + resolveAemAccessToken, + ErrorWithStatusCode, } from '../../src/support/utils.js'; use(chaiAsPromised); @@ -371,6 +376,152 @@ describe('utils', () => { }); }); + describe('resolveAemAccessToken', () => { + let sandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('uses promise token from context.data before header sources', async () => { + const context = { + data: { promiseToken: { promise_token: 'data-token' } }, + request: { headers: { get: () => 'request-token' } }, + }; + const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-data'); + const getImsUserTokenFn = sandbox.stub(); + + const token = await resolveAemAccessToken(context, { + exchangePromiseTokenFn, + getImsUserTokenFn, + }); + + expect(token).to.equal('ims-token-from-data'); + expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'data-token'); + expect(getImsUserTokenFn).to.not.have.been.called; + }); + + it('uses promise token from request headers when data token is absent', async () => { + const context = { + request: { headers: { get: (name) => (name === 'x-promise-token' ? 'request-token' : null) } }, + pathInfo: { headers: { 'x-promise-token': 'path-token' } }, + }; + const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-request'); + + const token = await resolveAemAccessToken(context, { exchangePromiseTokenFn }); + + expect(token).to.equal('ims-token-from-request'); + expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'request-token'); + }); + + it('uses promise token from pathInfo headers with case-insensitive lookup', async () => { + const context = { + request: { headers: { get: () => '' } }, + pathInfo: { headers: { 'X-PROMISE-TOKEN': 'path-token' } }, + }; + const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-path'); + + const token = await resolveAemAccessToken(context, { exchangePromiseTokenFn }); + + expect(token).to.equal('ims-token-from-path'); + expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'path-token'); + }); + + it('falls back to getImsUserToken when no promise token is provided', async () => { + const context = {}; + const getImsUserTokenFn = sandbox.stub().returns('ims-token-from-auth-header'); + const exchangePromiseTokenFn = sandbox.stub(); + + const token = await resolveAemAccessToken(context, { + exchangePromiseTokenFn, + getImsUserTokenFn, + }); + + expect(token).to.equal('ims-token-from-auth-header'); + expect(getImsUserTokenFn).to.have.been.calledOnceWithExactly(context); + expect(exchangePromiseTokenFn).to.not.have.been.called; + }); + + it('uses invocation bearer header when getImsUserToken cannot resolve a token', async () => { + const context = { + invocation: { + event: { + headers: { + Authorization: 'Bearer invocation-token', + }, + }, + }, + }; + const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); + + const token = await resolveAemAccessToken(context, { getImsUserTokenFn }); + + expect(token).to.equal('invocation-token'); + }); + + it('throws 400 when no bearer token is available in any source', async () => { + const context = { + invocation: { + event: { + headers: {}, + }, + }, + }; + const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); + + try { + await resolveAemAccessToken(context, { getImsUserTokenFn }); + expect.fail('expected resolveAemAccessToken to throw'); + } catch (e) { + expect(e).to.be.instanceOf(ErrorWithStatusCode); + expect(e.status).to.equal(400); + expect(e.message).to.equal('Missing Authorization header'); + } + }); + + it('throws 400 when invocation authorization header is not Bearer', async () => { + const context = { + invocation: { + event: { + headers: { + authorization: 'Basic token', + }, + }, + }, + }; + const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); + + try { + await resolveAemAccessToken(context, { getImsUserTokenFn }); + expect.fail('expected resolveAemAccessToken to throw'); + } catch (e) { + expect(e).to.be.instanceOf(ErrorWithStatusCode); + expect(e.status).to.equal(400); + expect(e.message).to.equal('Missing Authorization header'); + } + }); + + it('throws 401 when promise token exchange fails', async () => { + const context = { + data: { promiseToken: { promise_token: 'data-token' } }, + }; + const exchangePromiseTokenFn = sandbox.stub().rejects(new Error('exchange failed')); + + try { + await resolveAemAccessToken(context, { exchangePromiseTokenFn }); + expect.fail('expected resolveAemAccessToken to throw'); + } catch (e) { + expect(e).to.be.instanceOf(ErrorWithStatusCode); + expect(e.status).to.equal(401); + expect(e.message).to.equal('Authentication failed with upstream IMS service'); + } + }); + }); + describe('updateCodeConfig', () => { let sandbox; let log; From 36a34565d916324834197cdaf083a86e17b4571e Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 15:03:31 +0530 Subject: [PATCH 06/13] chore: trigger dev redeploy From 7863fb1b9c120141d9499a0a786b19b643ac18ae Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 15:37:21 +0530 Subject: [PATCH 07/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 15 +- src/support/utils.js | 75 ---------- test/controllers/page-relationships.test.js | 34 +++-- test/support/utils.test.js | 148 -------------------- 4 files changed, 31 insertions(+), 241 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index df258b9db..9c3685322 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -22,7 +22,9 @@ import { } from '@adobe/spacecat-shared-http-utils'; import AccessControlUtil from '../support/access-control-util.js'; import { - resolveAemAccessToken, + getIMSPromiseToken, + exchangePromiseToken, + ErrorWithStatusCode, } from '../support/utils.js'; import { isAEMAuthoredSite, @@ -276,13 +278,14 @@ function PageRelationshipsController(ctx) { let imsToken; try { - imsToken = await resolveAemAccessToken(context); + const promiseTokenResponse = await getIMSPromiseToken(context); + imsToken = await exchangePromiseToken(context, promiseTokenResponse.promise_token); } catch (e) { - if (e?.status === 401) { - log.warn(`Failed to resolve AEM access token for opportunity ${opportunityId}: ${e.message}`); - return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); + if (e instanceof ErrorWithStatusCode) { + return badRequest(e.message); } - return badRequest(e?.message || 'Missing Authorization header'); + log.warn(`Failed to resolve AEM access token for opportunity ${opportunityId}: ${e.message}`); + return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); } const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { diff --git a/src/support/utils.js b/src/support/utils.js index fe2777403..b20e6677a 100644 --- a/src/support/utils.js +++ b/src/support/utils.js @@ -649,81 +649,6 @@ export async function exchangePromiseToken(context, promiseToken) { return accessToken; } -const X_PROMISE_TOKEN_HEADER = 'x-promise-token'; -const BEARER_PREFIX = 'Bearer '; - -function getHeaderCaseInsensitive(headers, headerName) { - if (!headers || typeof headers !== 'object') { - return ''; - } - const headerEntry = Object.entries(headers).find(([name, value]) => ( - name.toLowerCase() === headerName && hasText(value) - )); - return headerEntry ? headerEntry[1].trim() : ''; -} - -function getPromiseTokenFromContext(context) { - const dataPromiseToken = context?.data?.promiseToken?.promise_token; - if (hasText(dataPromiseToken)) { - return dataPromiseToken.trim(); - } - - const requestPromiseToken = context?.request?.headers?.get?.(X_PROMISE_TOKEN_HEADER); - if (hasText(requestPromiseToken)) { - return requestPromiseToken.trim(); - } - - return getHeaderCaseInsensitive(context?.pathInfo?.headers, X_PROMISE_TOKEN_HEADER); -} - -function extractBearerToken(authorizationHeader) { - if (!hasText(authorizationHeader) || !authorizationHeader.startsWith(BEARER_PREFIX)) { - throw new ErrorWithStatusCode('Missing Authorization header', STATUS_BAD_REQUEST); - } - return authorizationHeader.substring(BEARER_PREFIX.length); -} - -/** - * Resolve an access token for AEM API calls. - * Precedence follows autofix-worker behavior: - * 1) promise token (context.data.promiseToken.promise_token or x-promise-token) - * 2) Authorization bearer token (pathInfo headers) - * 3) Authorization bearer token (invocation event headers) - * - * @param {object} context - request context. - * @param {object} [options] - optional injected dependencies for testing. - * @param {Function} [options.exchangePromiseTokenFn] - promise token exchanger. - * @param {Function} [options.getImsUserTokenFn] - bearer token extractor. - * @returns {Promise} Access token without Bearer prefix. - * @throws {ErrorWithStatusCode} for auth failures. - */ -export async function resolveAemAccessToken( - context, - { - exchangePromiseTokenFn = exchangePromiseToken, - getImsUserTokenFn = getImsUserToken, - } = {}, -) { - const promiseToken = getPromiseTokenFromContext(context); - if (hasText(promiseToken)) { - try { - return await exchangePromiseTokenFn(context, promiseToken); - } catch (e) { - throw new ErrorWithStatusCode('Authentication failed with upstream IMS service', 401); - } - } - - try { - return getImsUserTokenFn(context); - } catch (e) { - const invocationAuthorization = getHeaderCaseInsensitive( - context?.invocation?.event?.headers, - 'authorization', - ); - return extractBearerToken(invocationAuthorization); - } -} - /** * Build an S3 prefix for site content files. * @param {string} type - The type of content (e.g., 'scrapes', 'imports', 'accessibility'). diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index eff55e431..112ca7a7d 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -19,6 +19,7 @@ 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); @@ -37,7 +38,8 @@ describe('Page Relationships Controller', () => { let fetchRelationshipsStub; let isAEMAuthoredSiteStub; let buildCheckPathStub; - let resolveAemAccessTokenStub; + let getIMSPromiseTokenStub; + let exchangePromiseTokenStub; let mockDataAccess; let mockSite; @@ -58,7 +60,8 @@ describe('Page Relationships Controller', () => { fetchRelationshipsStub = sandbox.stub(); isAEMAuthoredSiteStub = sandbox.stub(); buildCheckPathStub = sandbox.stub(); - resolveAemAccessTokenStub = sandbox.stub().resolves('test-ims-token'); + getIMSPromiseTokenStub = sandbox.stub().resolves({ promise_token: 'test-promise-token' }); + exchangePromiseTokenStub = sandbox.stub().resolves('test-ims-token'); mockSite = { getDeliveryType: sandbox.stub().returns('aem_cs'), @@ -109,7 +112,9 @@ describe('Page Relationships Controller', () => { buildCheckPath: buildCheckPathStub, }, '../../src/support/utils.js': { - resolveAemAccessToken: (...args) => resolveAemAccessTokenStub(...args), + getIMSPromiseToken: (...args) => getIMSPromiseTokenStub(...args), + exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), + ErrorWithStatusCode, }, })).default; }); @@ -247,9 +252,9 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns 400 when AEM token cannot be resolved', async () => { + it('returns 400 when getIMSPromiseToken throws ErrorWithStatusCode', async () => { isAEMAuthoredSiteStub.returns(true); - resolveAemAccessTokenStub.rejects(Object.assign(new Error('Missing Authorization header'), { status: 400 })); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header', 400)); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -257,23 +262,24 @@ describe('Page Relationships Controller', () => { expect(response.status).to.equal(400); expect(body.message).to.equal('Missing Authorization header'); + expect(exchangePromiseTokenStub).to.not.have.been.called; }); - it('returns default 400 message when AEM token error has no message', async () => { + it('returns 400 when exchangePromiseToken throws ErrorWithStatusCode', async () => { isAEMAuthoredSiteStub.returns(true); - resolveAemAccessTokenStub.rejects(Object.assign(new Error(), { message: '', status: 400 })); + exchangePromiseTokenStub.rejects(new ErrorWithStatusCode('Missing promise token', 400)); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(400); - expect(body.message).to.equal('Missing Authorization header'); + expect(body.message).to.equal('Missing promise token'); }); - it('returns 401 when AEM token exchange fails', async () => { + it('returns 401 when token exchange fails with generic error', async () => { isAEMAuthoredSiteStub.returns(true); - resolveAemAccessTokenStub.rejects(Object.assign(new Error('Authentication failed with upstream IMS service'), { status: 401 })); + exchangePromiseTokenStub.rejects(new Error('IMS exchange failed')); mockDataAccess.Suggestion.allByOpportunityId.resolves([ createSuggestion({ pageUrl: '/us/en/page1', @@ -291,7 +297,7 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('uses resolved AEM token for relationship lookup', async () => { + it('uses exchanged AEM token for relationship lookup', async () => { isAEMAuthoredSiteStub.returns(true); mockDataAccess.Suggestion.allByOpportunityId.resolves([ createSuggestion({ @@ -314,7 +320,11 @@ describe('Page Relationships Controller', () => { const body = await response.json(); expect(response.status).to.equal(200); - expect(resolveAemAccessTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(getIMSPromiseTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(exchangePromiseTokenStub).to.have.been.calledOnceWithExactly( + requestContext, + 'test-promise-token', + ); expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); }); diff --git a/test/support/utils.test.js b/test/support/utils.test.js index 68c99b3c2..55123b558 100644 --- a/test/support/utils.test.js +++ b/test/support/utils.test.js @@ -22,8 +22,6 @@ import { deriveProjectName, autoResolveAuthorUrl, updateCodeConfig, - resolveAemAccessToken, - ErrorWithStatusCode, } from '../../src/support/utils.js'; use(chaiAsPromised); @@ -376,152 +374,6 @@ describe('utils', () => { }); }); - describe('resolveAemAccessToken', () => { - let sandbox; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - }); - - afterEach(() => { - sandbox.restore(); - }); - - it('uses promise token from context.data before header sources', async () => { - const context = { - data: { promiseToken: { promise_token: 'data-token' } }, - request: { headers: { get: () => 'request-token' } }, - }; - const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-data'); - const getImsUserTokenFn = sandbox.stub(); - - const token = await resolveAemAccessToken(context, { - exchangePromiseTokenFn, - getImsUserTokenFn, - }); - - expect(token).to.equal('ims-token-from-data'); - expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'data-token'); - expect(getImsUserTokenFn).to.not.have.been.called; - }); - - it('uses promise token from request headers when data token is absent', async () => { - const context = { - request: { headers: { get: (name) => (name === 'x-promise-token' ? 'request-token' : null) } }, - pathInfo: { headers: { 'x-promise-token': 'path-token' } }, - }; - const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-request'); - - const token = await resolveAemAccessToken(context, { exchangePromiseTokenFn }); - - expect(token).to.equal('ims-token-from-request'); - expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'request-token'); - }); - - it('uses promise token from pathInfo headers with case-insensitive lookup', async () => { - const context = { - request: { headers: { get: () => '' } }, - pathInfo: { headers: { 'X-PROMISE-TOKEN': 'path-token' } }, - }; - const exchangePromiseTokenFn = sandbox.stub().resolves('ims-token-from-path'); - - const token = await resolveAemAccessToken(context, { exchangePromiseTokenFn }); - - expect(token).to.equal('ims-token-from-path'); - expect(exchangePromiseTokenFn).to.have.been.calledOnceWithExactly(context, 'path-token'); - }); - - it('falls back to getImsUserToken when no promise token is provided', async () => { - const context = {}; - const getImsUserTokenFn = sandbox.stub().returns('ims-token-from-auth-header'); - const exchangePromiseTokenFn = sandbox.stub(); - - const token = await resolveAemAccessToken(context, { - exchangePromiseTokenFn, - getImsUserTokenFn, - }); - - expect(token).to.equal('ims-token-from-auth-header'); - expect(getImsUserTokenFn).to.have.been.calledOnceWithExactly(context); - expect(exchangePromiseTokenFn).to.not.have.been.called; - }); - - it('uses invocation bearer header when getImsUserToken cannot resolve a token', async () => { - const context = { - invocation: { - event: { - headers: { - Authorization: 'Bearer invocation-token', - }, - }, - }, - }; - const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); - - const token = await resolveAemAccessToken(context, { getImsUserTokenFn }); - - expect(token).to.equal('invocation-token'); - }); - - it('throws 400 when no bearer token is available in any source', async () => { - const context = { - invocation: { - event: { - headers: {}, - }, - }, - }; - const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); - - try { - await resolveAemAccessToken(context, { getImsUserTokenFn }); - expect.fail('expected resolveAemAccessToken to throw'); - } catch (e) { - expect(e).to.be.instanceOf(ErrorWithStatusCode); - expect(e.status).to.equal(400); - expect(e.message).to.equal('Missing Authorization header'); - } - }); - - it('throws 400 when invocation authorization header is not Bearer', async () => { - const context = { - invocation: { - event: { - headers: { - authorization: 'Basic token', - }, - }, - }, - }; - const getImsUserTokenFn = sandbox.stub().throws(new ErrorWithStatusCode('Missing Authorization header', 400)); - - try { - await resolveAemAccessToken(context, { getImsUserTokenFn }); - expect.fail('expected resolveAemAccessToken to throw'); - } catch (e) { - expect(e).to.be.instanceOf(ErrorWithStatusCode); - expect(e.status).to.equal(400); - expect(e.message).to.equal('Missing Authorization header'); - } - }); - - it('throws 401 when promise token exchange fails', async () => { - const context = { - data: { promiseToken: { promise_token: 'data-token' } }, - }; - const exchangePromiseTokenFn = sandbox.stub().rejects(new Error('exchange failed')); - - try { - await resolveAemAccessToken(context, { exchangePromiseTokenFn }); - expect.fail('expected resolveAemAccessToken to throw'); - } catch (e) { - expect(e).to.be.instanceOf(ErrorWithStatusCode); - expect(e.status).to.equal(401); - expect(e.message).to.equal('Authentication failed with upstream IMS service'); - } - }); - }); - describe('updateCodeConfig', () => { let sandbox; let log; From 7ea555ae7939a1439cd8f78e2e9c047e00984b3a Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 15:49:56 +0530 Subject: [PATCH 08/13] chore: trigger dev redeploy From deaa127a05294d1aa061d6af7e9f13f70a04dc84 Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 16:03:40 +0530 Subject: [PATCH 09/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 12 ++---- test/controllers/page-relationships.test.js | 48 +++++---------------- 2 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index 9c3685322..dc851fd3b 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -21,11 +21,7 @@ import { notFound, } from '@adobe/spacecat-shared-http-utils'; import AccessControlUtil from '../support/access-control-util.js'; -import { - getIMSPromiseToken, - exchangePromiseToken, - ErrorWithStatusCode, -} from '../support/utils.js'; +import { getImsUserToken, ErrorWithStatusCode } from '../support/utils.js'; import { isAEMAuthoredSite, resolvePageIds, @@ -278,14 +274,12 @@ function PageRelationshipsController(ctx) { let imsToken; try { - const promiseTokenResponse = await getIMSPromiseToken(context); - imsToken = await exchangePromiseToken(context, promiseTokenResponse.promise_token); + imsToken = getImsUserToken(context); } catch (e) { if (e instanceof ErrorWithStatusCode) { return badRequest(e.message); } - log.warn(`Failed to resolve AEM access token for opportunity ${opportunityId}: ${e.message}`); - return createResponse({ message: 'Authentication failed with upstream IMS service' }, 401); + return badRequest('Missing Authorization header'); } const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 112ca7a7d..73b6c7754 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -38,8 +38,7 @@ describe('Page Relationships Controller', () => { let fetchRelationshipsStub; let isAEMAuthoredSiteStub; let buildCheckPathStub; - let getIMSPromiseTokenStub; - let exchangePromiseTokenStub; + let getImsUserTokenStub; let mockDataAccess; let mockSite; @@ -60,8 +59,7 @@ describe('Page Relationships Controller', () => { fetchRelationshipsStub = sandbox.stub(); isAEMAuthoredSiteStub = sandbox.stub(); buildCheckPathStub = sandbox.stub(); - getIMSPromiseTokenStub = sandbox.stub().resolves({ promise_token: 'test-promise-token' }); - exchangePromiseTokenStub = sandbox.stub().resolves('test-ims-token'); + getImsUserTokenStub = sandbox.stub().returns('test-ims-token'); mockSite = { getDeliveryType: sandbox.stub().returns('aem_cs'), @@ -112,8 +110,7 @@ describe('Page Relationships Controller', () => { buildCheckPath: buildCheckPathStub, }, '../../src/support/utils.js': { - getIMSPromiseToken: (...args) => getIMSPromiseTokenStub(...args), - exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), + getImsUserToken: (...args) => getImsUserTokenStub(...args), ErrorWithStatusCode, }, })).default; @@ -252,9 +249,9 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns 400 when getIMSPromiseToken throws ErrorWithStatusCode', async () => { + it('returns 400 when Authorization header is missing', async () => { isAEMAuthoredSiteStub.returns(true); - getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header', 400)); + getImsUserTokenStub.throws(new ErrorWithStatusCode('Missing Authorization header', 400)); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -262,42 +259,23 @@ describe('Page Relationships Controller', () => { expect(response.status).to.equal(400); expect(body.message).to.equal('Missing Authorization header'); - expect(exchangePromiseTokenStub).to.not.have.been.called; + expect(resolvePageIdsStub).to.not.have.been.called; }); - it('returns 400 when exchangePromiseToken throws ErrorWithStatusCode', async () => { + it('returns 400 with default message when non-ErrorWithStatusCode is thrown', async () => { isAEMAuthoredSiteStub.returns(true); - exchangePromiseTokenStub.rejects(new ErrorWithStatusCode('Missing promise token', 400)); + getImsUserTokenStub.throws(new Error('unexpected')); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); const body = await response.json(); expect(response.status).to.equal(400); - expect(body.message).to.equal('Missing promise token'); - }); - - it('returns 401 when token exchange fails with generic error', async () => { - isAEMAuthoredSiteStub.returns(true); - exchangePromiseTokenStub.rejects(new Error('IMS exchange failed')); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - const controller = PageRelationshipsController(controllerContext); - - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(401); - expect(body.message).to.equal('Authentication failed with upstream IMS service'); + expect(body.message).to.equal('Missing Authorization header'); expect(resolvePageIdsStub).to.not.have.been.called; - expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('uses exchanged AEM token for relationship lookup', async () => { + it('passes user IMS token to relationship lookup', async () => { isAEMAuthoredSiteStub.returns(true); mockDataAccess.Suggestion.allByOpportunityId.resolves([ createSuggestion({ @@ -320,11 +298,7 @@ describe('Page Relationships Controller', () => { 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(getImsUserTokenStub).to.have.been.calledOnceWithExactly(requestContext); expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); }); From 85045f3b650e8a12332daef673f8824643fbcafe Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 19:30:44 +0530 Subject: [PATCH 10/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 5 +++-- test/controllers/page-relationships.test.js | 25 +++++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index dc851fd3b..edcc4923f 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -21,7 +21,7 @@ import { notFound, } from '@adobe/spacecat-shared-http-utils'; import AccessControlUtil from '../support/access-control-util.js'; -import { getImsUserToken, ErrorWithStatusCode } from '../support/utils.js'; +import { getIMSPromiseToken, exchangePromiseToken, ErrorWithStatusCode } from '../support/utils.js'; import { isAEMAuthoredSite, resolvePageIds, @@ -274,7 +274,8 @@ function PageRelationshipsController(ctx) { let imsToken; try { - imsToken = getImsUserToken(context); + const promiseTokenResponse = await getIMSPromiseToken(context); + imsToken = await exchangePromiseToken(context, promiseTokenResponse.promise_token); } catch (e) { if (e instanceof ErrorWithStatusCode) { return badRequest(e.message); diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 73b6c7754..42bc4a4fb 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -38,7 +38,8 @@ describe('Page Relationships Controller', () => { let fetchRelationshipsStub; let isAEMAuthoredSiteStub; let buildCheckPathStub; - let getImsUserTokenStub; + let getIMSPromiseTokenStub; + let exchangePromiseTokenStub; let mockDataAccess; let mockSite; @@ -59,7 +60,12 @@ describe('Page Relationships Controller', () => { fetchRelationshipsStub = sandbox.stub(); isAEMAuthoredSiteStub = sandbox.stub(); buildCheckPathStub = sandbox.stub(); - getImsUserTokenStub = sandbox.stub().returns('test-ims-token'); + 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'), @@ -110,7 +116,8 @@ describe('Page Relationships Controller', () => { buildCheckPath: buildCheckPathStub, }, '../../src/support/utils.js': { - getImsUserToken: (...args) => getImsUserTokenStub(...args), + getIMSPromiseToken: (...args) => getIMSPromiseTokenStub(...args), + exchangePromiseToken: (...args) => exchangePromiseTokenStub(...args), ErrorWithStatusCode, }, })).default; @@ -251,7 +258,7 @@ describe('Page Relationships Controller', () => { it('returns 400 when Authorization header is missing', async () => { isAEMAuthoredSiteStub.returns(true); - getImsUserTokenStub.throws(new ErrorWithStatusCode('Missing Authorization header', 400)); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header', 400)); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -264,7 +271,7 @@ describe('Page Relationships Controller', () => { it('returns 400 with default message when non-ErrorWithStatusCode is thrown', async () => { isAEMAuthoredSiteStub.returns(true); - getImsUserTokenStub.throws(new Error('unexpected')); + getIMSPromiseTokenStub.rejects(new Error('unexpected')); const controller = PageRelationshipsController(controllerContext); const response = await controller.getForOpportunity(requestContext); @@ -275,7 +282,7 @@ describe('Page Relationships Controller', () => { expect(resolvePageIdsStub).to.not.have.been.called; }); - it('passes user IMS token to relationship lookup', async () => { + it('passes AEM access token (from promise-token exchange) to relationship lookup', async () => { isAEMAuthoredSiteStub.returns(true); mockDataAccess.Suggestion.allByOpportunityId.resolves([ createSuggestion({ @@ -298,7 +305,11 @@ describe('Page Relationships Controller', () => { const body = await response.json(); expect(response.status).to.equal(200); - expect(getImsUserTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(getIMSPromiseTokenStub).to.have.been.calledOnceWithExactly(requestContext); + expect(exchangePromiseTokenStub).to.have.been.calledOnceWithExactly( + requestContext, + 'test-promise-token', + ); expect(resolvePageIdsStub.firstCall.args[3]).to.equal('test-ims-token'); expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); }); From 724dc347fde8833ed87ee645302451df478d00bc Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 19:41:32 +0530 Subject: [PATCH 11/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index edcc4923f..ddf745820 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -280,7 +280,7 @@ function PageRelationshipsController(ctx) { if (e instanceof ErrorWithStatusCode) { return badRequest(e.message); } - return badRequest('Missing Authorization header'); + return badRequest('Missing Authorization header - 2'); } const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { From 7c196b9415c0fabac86711de165528f898110add Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Fri, 27 Feb 2026 19:50:34 +0530 Subject: [PATCH 12/13] feat: page relationships api in case of MSM and lang copies --- src/controllers/page-relationships.js | 3 ++- test/controllers/page-relationships.test.js | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/controllers/page-relationships.js b/src/controllers/page-relationships.js index ddf745820..fc50298c9 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -280,7 +280,8 @@ function PageRelationshipsController(ctx) { if (e instanceof ErrorWithStatusCode) { return badRequest(e.message); } - return badRequest('Missing Authorization header - 2'); + const detail = [e.statusCode, e.status, e.message].filter(Boolean).join(' ') || e.message || 'Unknown error'; + return badRequest(`Problem getting IMS token: ${detail}`); } const { relationships, errors } = await lookupRelationships(site, pages, imsToken, { diff --git a/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 42bc4a4fb..3c2d61955 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -269,7 +269,7 @@ describe('Page Relationships Controller', () => { expect(resolvePageIdsStub).to.not.have.been.called; }); - it('returns 400 with default message when non-ErrorWithStatusCode is thrown', async () => { + it('returns 400 with IMS token problem message when non-ErrorWithStatusCode is thrown', async () => { isAEMAuthoredSiteStub.returns(true); getIMSPromiseTokenStub.rejects(new Error('unexpected')); const controller = PageRelationshipsController(controllerContext); @@ -278,7 +278,20 @@ describe('Page Relationships Controller', () => { const body = await response.json(); expect(response.status).to.equal(400); - expect(body.message).to.equal('Missing Authorization header'); + expect(body.message).to.equal('Problem getting IMS token: unexpected'); + expect(resolvePageIdsStub).to.not.have.been.called; + }); + + it('returns 400 with Unknown error when thrown error has no message', async () => { + isAEMAuthoredSiteStub.returns(true); + getIMSPromiseTokenStub.rejects(Object.assign(new Error(), { message: '' })); + const controller = PageRelationshipsController(controllerContext); + + const response = await controller.getForOpportunity(requestContext); + const body = await response.json(); + + expect(response.status).to.equal(400); + expect(body.message).to.equal('Problem getting IMS token: Unknown error'); expect(resolvePageIdsStub).to.not.have.been.called; }); From 15eb267fa4bb0e311d17ebfeb1162f657c97e32b Mon Sep 17 00:00:00 2001 From: Sagar Miglani Date: Sat, 28 Feb 2026 13:41:40 +0530 Subject: [PATCH 13/13] feat: page relationships api in case of MSM and lang copies --- docs/openapi/api.yaml | 2 + docs/openapi/examples.yaml | 83 ++- docs/openapi/schemas.yaml | 122 ++++ docs/openapi/site-api.yaml | 52 ++ src/controllers/page-relationships.js | 201 +++--- src/routes/index.js | 2 +- test/controllers/page-relationships.test.js | 741 +++++++++++--------- test/routes/index.test.js | 11 +- 8 files changed, 770 insertions(+), 444 deletions(-) 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 index fc50298c9..7eec6190b 100644 --- a/src/controllers/page-relationships.js +++ b/src/controllers/page-relationships.js @@ -12,6 +12,7 @@ import { hasText, + isNonEmptyArray, isValidUUID, } from '@adobe/spacecat-shared-utils'; import { @@ -19,6 +20,7 @@ import { 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'; @@ -30,7 +32,6 @@ import { } from '../support/aem-content-api.js'; const MAX_PAGES = 50; -const SUPPORTED_OPPORTUNITY_TYPES = new Set(['meta-tags', 'alt-text']); const EMPTY_RELATIONSHIPS_RESPONSE = { supported: false, relationships: {}, @@ -45,39 +46,6 @@ function chunkPages(pages, chunkSize) { return chunks; } -function getSuggestionType(suggestion) { - const data = suggestion?.getData?.() || {}; - const rawType = [ - data.suggestionType, - data.issue, - ].find((value) => hasText(value)); - return hasText(rawType) ? rawType.trim() : ''; -} - -function getSuggestionPageUrls(suggestion) { - const data = suggestion?.getData?.() || {}; - const urls = new Set(); - const directUrlValues = [data.url, data.pageUrl]; - - directUrlValues.forEach((value) => { - if (hasText(value)) { - urls.add(value.trim()); - } - }); - - const { recommendations } = data; - if (Array.isArray(recommendations)) { - recommendations.forEach((recommendation) => { - const recommendationUrl = recommendation?.pageUrl; - if (hasText(recommendationUrl)) { - urls.add(recommendationUrl.trim()); - } - }); - } - - return Array.from(urls); -} - function normalizePageUrlForLookup(pageUrl, siteBaseURL) { const trimmed = pageUrl.trim(); if (!/^https?:\/\//i.test(trimmed)) { @@ -96,29 +64,80 @@ function normalizePageUrlForLookup(pageUrl, siteBaseURL) { } } -function extractPagesFromSuggestions(suggestions, options = {}) { - const { siteBaseURL = '' } = options; - const uniquePages = new Map(); - const suggestionList = Array.isArray(suggestions) ? suggestions : []; - suggestionList.forEach((suggestion) => { - const suggestionType = getSuggestionType(suggestion); - const pageUrls = getSuggestionPageUrls(suggestion); - pageUrls.forEach((pageUrl) => { - const normalizedPageUrl = normalizePageUrlForLookup(pageUrl, siteBaseURL); - const dedupeKey = `${normalizedPageUrl}:${suggestionType}`; - if (!uniquePages.has(dedupeKey)) { - uniquePages.set(dedupeKey, { pageUrl: normalizedPageUrl, suggestionType }); +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; } - }); - }); - return Array.from(uniquePages.values()); + 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 list-time enrichment (metatags/alt-text) so the UI can show fix targets. + * Used for on-demand popup lookups with caller-provided pages. * @param {object} ctx - Context with dataAccess, log. - * @returns {object} Controller with getForOpportunity. + * @returns {object} Controller with search. */ function PageRelationshipsController(ctx) { const { dataAccess, log } = ctx; @@ -182,17 +201,23 @@ function PageRelationshipsController(ctx) { const items = []; const resolveErrors = {}; + const relationshipContextByKey = {}; for (let i = 0; i < normalizedBatch.length; i += 1) { const pageSpec = normalizedBatch[i]; const r = resolved[i] || {}; - const responseUrl = pageSpec.normalizedPageUrl; + const suggestionType = hasText(pageSpec.suggestionType) ? pageSpec.suggestionType : ''; + const responseKey = pageSpec.key; if (r.error || !r.pageId) { - resolveErrors[responseUrl] = { error: r.error || 'Could not resolve page' }; + resolveErrors[responseKey] = { error: r.error || 'Could not resolve page' }; } else { - const checkPath = buildCheckPath(pageSpec.suggestionType, deliveryConfig); + relationshipContextByKey[responseKey] = { + pagePath: pageSpec.normalizedPageUrl, + pageId: r.pageId, + }; + const checkPath = buildCheckPath(suggestionType, deliveryConfig); const item = { - key: `${responseUrl}:${pageSpec.suggestionType}`, + key: pageSpec.key, pageId: r.pageId, include: ['upstream'], }; @@ -208,7 +233,22 @@ function PageRelationshipsController(ctx) { if (items.length > 0) { // eslint-disable-next-line no-await-in-loop const aemResponse = await fetchRelationships(authorURL, items, imsToken, log); - Object.assign(allRelationships, aemResponse.results); + 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); } } @@ -220,19 +260,15 @@ function PageRelationshipsController(ctx) { } /** - * GET /sites/:siteId/opportunities/:opportunityId/page-relationships - * Resolves page relationships from all opportunity suggestions. + * POST /sites/:siteId/page-relationships/search + * Resolves page relationships for caller-provided pages. * Returns { supported, relationships, errors }. */ - async function getForOpportunity(context) { + async function search(context) { const siteId = context.params?.siteId; - const opportunityId = context.params?.opportunityId; if (!isValidUUID(siteId)) { return badRequest('Site ID required'); } - if (!isValidUUID(opportunityId)) { - return badRequest('Opportunity ID required'); - } const site = await dataAccess.Site.findById(siteId); if (!site) { @@ -243,22 +279,15 @@ function PageRelationshipsController(ctx) { return forbidden('Only users belonging to the organization can access this site'); } - const opportunity = await dataAccess.Opportunity.findById(opportunityId); - if (!opportunity || opportunity.getSiteId() !== siteId) { - return notFound('Opportunity not found'); + const pages = context.data?.pages; + if (!isNonEmptyArray(pages)) { + return badRequest('pages array required'); } - const opportunityType = opportunity.getType?.(); - if (!SUPPORTED_OPPORTUNITY_TYPES.has(opportunityType)) { - log.warn(`Unsupported opportunity type for page relationships: ${opportunityType || 'unknown'} (opportunityId=${opportunityId})`); - return createResponse({ - supported: false, - relationships: {}, - errors: { - _opportunity: { - error: `Unsupported opportunity type: ${opportunityType || 'unknown'}`, - }, - }, - }); + 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); @@ -267,21 +296,21 @@ function PageRelationshipsController(ctx) { } const { deliveryConfig, authorURL } = supportState; - const suggestions = await dataAccess.Suggestion.allByOpportunityId(opportunityId); - const pages = extractPagesFromSuggestions(suggestions, { - siteBaseURL: site.getBaseURL(), - }); - let imsToken; try { const promiseTokenResponse = await getIMSPromiseToken(context); imsToken = await exchangePromiseToken(context, promiseTokenResponse.promise_token); } catch (e) { if (e instanceof ErrorWithStatusCode) { - return badRequest(e.message); + 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'; - return badRequest(`Problem getting IMS token: ${detail}`); + 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, { @@ -296,7 +325,7 @@ function PageRelationshipsController(ctx) { }); } - return { getForOpportunity }; + return { search }; } export default PageRelationshipsController; diff --git a/src/routes/index.js b/src/routes/index.js index cb51e530f..112b61139 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -211,7 +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, - 'GET /sites/:siteId/opportunities/:opportunityId/page-relationships': pageRelationshipsController.getForOpportunity, + '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/test/controllers/page-relationships.test.js b/test/controllers/page-relationships.test.js index 3c2d61955..89b69f500 100644 --- a/test/controllers/page-relationships.test.js +++ b/test/controllers/page-relationships.test.js @@ -28,10 +28,7 @@ describe('Page Relationships Controller', () => { const sandbox = sinon.createSandbox(); const SITE_ID = 'f964a7f8-5402-4b01-bd5b-1ab499bcf797'; - const OPPORTUNITY_ID = '71b8f3a4-8c5f-4966-bc67-a933760de5c9'; const SITE_ID_INVALID = 'not-a-uuid'; - const OPPORTUNITY_ID_INVALID = 'invalid-opportunity-id'; - const ANOTHER_SITE_ID = '3a7ef7f6-ae34-4ec4-94f0-4e9707a406da'; let PageRelationshipsController; let resolvePageIdsStub; @@ -43,7 +40,6 @@ describe('Page Relationships Controller', () => { let mockDataAccess; let mockSite; - let mockOpportunity; let controllerContext; let requestContext; let log; @@ -76,21 +72,10 @@ describe('Page Relationships Controller', () => { getBaseURL: sandbox.stub().returns('https://example.com'), }; - mockOpportunity = { - getSiteId: sandbox.stub().returns(SITE_ID), - getType: sandbox.stub().returns('meta-tags'), - }; - mockDataAccess = { Site: { findById: sandbox.stub().resolves(mockSite), }, - Opportunity: { - findById: sandbox.stub().resolves(mockOpportunity), - }, - Suggestion: { - allByOpportunityId: sandbox.stub().resolves([]), - }, }; controllerContext = { @@ -99,8 +84,8 @@ describe('Page Relationships Controller', () => { }; requestContext = { - params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, - data: { pages: [{ pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }] }, + params: { siteId: SITE_ID }, + data: { pages: [{ key: 'row-1', pageUrl: '/us/en/page1', suggestionType: 'Missing Title' }] }, pathInfo: { headers: { authorization: 'Bearer test-ims-token' }, }, @@ -132,23 +117,18 @@ describe('Page Relationships Controller', () => { expect(() => PageRelationshipsController({ log })).to.throw('Data access required'); }); - it('returns controller with getForOpportunity function', () => { + it('returns controller with search function', () => { const controller = PageRelationshipsController(controllerContext); - expect(controller).to.have.property('getForOpportunity').that.is.a('function'); + expect(controller).to.have.property('search').that.is.a('function'); }); }); - describe('getForOpportunity', () => { - const createSuggestion = (data = {}, type = 'CONTENT_UPDATE') => ({ - getData: sandbox.stub().returns(data), - getType: sandbox.stub().returns(type), - }); - + describe('search', () => { it('returns 400 for invalid siteId', async () => { const controller = PageRelationshipsController(controllerContext); requestContext.params.siteId = SITE_ID_INVALID; - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(400); @@ -156,104 +136,101 @@ describe('Page Relationships Controller', () => { expect(mockDataAccess.Site.findById).to.not.have.been.called; }); - it('returns 400 for invalid opportunityId', async () => { - const controller = PageRelationshipsController(controllerContext); - requestContext.params.opportunityId = OPPORTUNITY_ID_INVALID; - - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(400); - expect(body.message).to.equal('Opportunity 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.getForOpportunity(requestContext); + 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.Opportunity.findById).to.not.have.been.called; + expect(mockDataAccess.Site.findById).to.have.been.calledOnceWith(SITE_ID); }); - it('returns 403 when user does not have access to the site', async () => { + 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.getForOpportunity(requestContext); + 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'); - expect(mockDataAccess.Opportunity.findById).to.not.have.been.called; }); - it('returns 404 when opportunity is not found', async () => { + it('returns 400 when pages is missing or empty', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Opportunity.findById.resolves(null); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); + 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'); - expect(response.status).to.equal(404); - expect(body.message).to.equal('Opportunity not found'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + 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 404 when opportunity belongs to another site', async () => { + it('returns 400 when pages entry has missing pageUrl', async () => { isAEMAuthoredSiteStub.returns(true); - mockOpportunity.getSiteId.returns(ANOTHER_SITE_ID); + requestContext.data = { pages: [{ key: 'row-1' }] }; const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); - expect(response.status).to.equal(404); - expect(body.message).to.equal('Opportunity not found'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + 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 supported: false for unsupported opportunity type', async () => { + it('returns 400 when pages entry has missing key', async () => { isAEMAuthoredSiteStub.returns(true); - mockOpportunity.getType.returns('broken-backlinks'); + 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.getForOpportunity(requestContext); + 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.have.property('_opportunity'); - const { _opportunity: unsupportedTypeError } = body.errors; - expect(unsupportedTypeError.error).to.equal('Unsupported opportunity type: broken-backlinks'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(body.errors).to.deep.equal({}); + expect(getIMSPromiseTokenStub).to.not.have.been.called; expect(resolvePageIdsStub).to.not.have.been.called; - expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns supported: false with unknown type when opportunity type is missing', async () => { + it('returns supported: false when authorURL is missing', async () => { isAEMAuthoredSiteStub.returns(true); - mockOpportunity.getType.returns(undefined); + mockSite.getDeliveryConfig.returns({}); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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.have.property('_opportunity'); - const { _opportunity: unknownTypeError } = body.errors; - expect(unknownTypeError.error).to.equal('Unsupported opportunity type: unknown'); - expect(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + expect(body.errors).to.deep.equal({}); + expect(getIMSPromiseTokenStub).to.not.have.been.called; expect(resolvePageIdsStub).to.not.have.been.called; - expect(fetchRelationshipsStub).to.not.have.been.called; }); it('returns 400 when Authorization header is missing', async () => { @@ -261,7 +238,7 @@ describe('Page Relationships Controller', () => { getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header', 400)); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(400); @@ -269,123 +246,96 @@ describe('Page Relationships Controller', () => { expect(resolvePageIdsStub).to.not.have.been.called; }); - it('returns 400 with IMS token problem message when non-ErrorWithStatusCode is thrown', async () => { + it('uses status from ErrorWithStatusCode for token-flow errors', async () => { isAEMAuthoredSiteStub.returns(true); - getIMSPromiseTokenStub.rejects(new Error('unexpected')); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Authentication failed', 401)); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); - expect(response.status).to.equal(400); - expect(body.message).to.equal('Problem getting IMS token: unexpected'); + expect(response.status).to.equal(401); + expect(body.message).to.equal('Authentication failed'); expect(resolvePageIdsStub).to.not.have.been.called; }); - it('returns 400 with Unknown error when thrown error has no message', async () => { + it('falls back to 400 when ErrorWithStatusCode has no status', async () => { isAEMAuthoredSiteStub.returns(true); - getIMSPromiseTokenStub.rejects(Object.assign(new Error(), { message: '' })); + getIMSPromiseTokenStub.rejects(new ErrorWithStatusCode('Missing Authorization header')); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(400); - expect(body.message).to.equal('Problem getting IMS token: Unknown error'); + expect(body.message).to.equal('Missing Authorization header'); expect(resolvePageIdsStub).to.not.have.been.called; }); - it('passes AEM access token (from promise-token exchange) to relationship lookup', async () => { + it('returns 4xx with IMS token problem details when generic error contains a 4xx status', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1', pageId: 'pg-1' }, - ]); - fetchRelationshipsStub.resolves({ - results: { - '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, - }, - errors: {}, - }); + getIMSPromiseTokenStub.rejects(Object.assign(new Error('exchange failed'), { status: 401 })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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[3]).to.equal('test-ims-token'); - expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + 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('returns supported: false when site is not AEM-authored', async () => { - isAEMAuthoredSiteStub.returns(false); + 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.getForOpportunity(requestContext); + 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(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 supported: false when authorURL is missing', async () => { + it('returns 500 when generic token error has no client status', async () => { isAEMAuthoredSiteStub.returns(true); - mockSite.getDeliveryConfig.returns({}); + getIMSPromiseTokenStub.rejects(Object.assign(new Error(), { message: '' })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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(mockDataAccess.Suggestion.allByOpportunityId).to.not.have.been.called; + 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; - expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns empty relationships when opportunity has no suggestions', async () => { + it('returns 500 when generic token error has non-4xx status', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([]); + getIMSPromiseTokenStub.rejects(Object.assign(new Error('upstream unavailable'), { status: 503 })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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.deep.equal({}); + 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; - expect(fetchRelationshipsStub).to.not.have.been.called; }); it('returns _config error when site has no baseURL', async () => { isAEMAuthoredSiteStub.returns(true); mockSite.getBaseURL.returns(''); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg1' }]); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(200); @@ -398,166 +348,213 @@ describe('Page Relationships Controller', () => { expect(fetchRelationshipsStub).to.not.have.been.called; }); - it('returns empty relationships when suggestions payload is not an array', async () => { + it('passes exchanged IMS token to resolver and normalizes same-host absolute URL for lookup', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves({ suggestions: [] }); - const controller = PageRelationshipsController(controllerContext); + 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 response = await controller.getForOpportunity(requestContext); + 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.deep.equal({}); - expect(resolvePageIdsStub).to.not.have.been.called; - expect(fetchRelationshipsStub).to.not.have.been.called; + 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('skips suggestion entries that do not implement getData', async () => { + it('maps upstream relationship payload to sourceType and minimal chain shape', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - null, - {}, - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), + 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' }, ]); - resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); fetchRelationshipsStub.resolves({ results: { - '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, + '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.getForOpportunity(requestContext); + 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(['/us/en/page1']); - expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); - expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + 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('returns resolve error details when a page cannot be resolved', async () => { + it('infers sourceType from edge.type and maps pagePath from edge.page', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1', error: 'HTTP 404' }, - ]); + 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.getForOpportunity(requestContext); + 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['/us/en/page1'].error).to.equal('HTTP 404'); - expect(fetchRelationshipsStub).to.not.have.been.called; + 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('returns default resolve error when no pageId and no resolve error are returned', async () => { + it('infers sourceType from edge.sourceType when relation and type are absent', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1' }, - ]); + 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.getForOpportunity(requestContext); + 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['/us/en/page1'].error).to.equal('Could not resolve page'); - expect(fetchRelationshipsStub).to.not.have.been.called; + 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('returns default resolve error when resolver returns fewer entries than requested pages', async () => { + it('handles missing relationship results payload by returning an empty relationships map', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - resolvePageIdsStub.resolves([]); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); + fetchRelationshipsStub.resolves({ errors: {} }); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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['/us/en/page1'].error).to.equal('Could not resolve page'); - expect(fetchRelationshipsStub).to.not.have.been.called; + expect(body.errors).to.deep.equal({}); }); - it('extracts unique pageUrl + suggestionType pairs from suggestions', async () => { + it('ignores relationship results that do not match requested page keys', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ url: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }), - createSuggestion({ url: 'https://example.com/us/en/page1', suggestionType: 'Missing Title' }), - createSuggestion({ - issue: 'Missing Description', - recommendations: [{ pageUrl: 'https://example.com/us/en/page2' }], - }), - ]); - resolvePageIdsStub.resolves([ - { url: '/us/en/page1', pageId: 'pg-1' }, - { url: '/us/en/page2', pageId: 'pg-2' }, - ]); + resolvePageIdsStub.resolves([{ url: '/us/en/page1', pageId: 'pg-1' }]); fetchRelationshipsStub.resolves({ results: { - '/us/en/page1:Missing Title': { pageId: 'pg-1', upstream: { chain: [] } }, - '/us/en/page2:Missing Description': { pageId: 'pg-2', upstream: { chain: [] } }, + 'unknown-key': { + pageId: 'pg-external', + upstream: { chain: [] }, + }, }, errors: {}, }); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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.been.calledOnce; - expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page1', '/us/en/page2']); - expect(fetchRelationshipsStub).to.have.been.calledOnce; - expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(2); - expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:Missing Title'); - expect(fetchRelationshipsStub.firstCall.args[1][1].key).to.equal('/us/en/page2:Missing Description'); - expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); - expect(body.relationships).to.have.property('/us/en/page2:Missing Description'); + expect(body.relationships).to.deep.equal({}); + expect(body.errors).to.deep.equal({}); }); - it('includes derived checkPath when buildCheckPath returns a non-empty value', async () => { + it('uses caller key for fetch item keys and resolve errors', async () => { isAEMAuthoredSiteStub.returns(true); - buildCheckPathStub.returns('/properties/jcr:title'); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: '/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); + 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: { @@ -568,55 +565,25 @@ describe('Page Relationships Controller', () => { }, errors: {}, })); - const controller = PageRelationshipsController(controllerContext); - - const response = await controller.getForOpportunity(requestContext); - const body = await response.json(); - - expect(response.status).to.equal(200); - expect(fetchRelationshipsStub.firstCall.args[1][0].checkPath).to.equal('/properties/jcr:title'); - expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); - }); - it('uses normalized path key when suggestion URL is absolute', async () => { - isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - 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.getForOpportunity(requestContext); + 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(['/us/en/page1']); - expect(fetchRelationshipsStub.firstCall.args[1][0].key).to.equal('/us/en/page1:Missing Title'); - expect(body.relationships).to.have.property('/us/en/page1:Missing Title'); + 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); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: 'https://external.example.com/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); + 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' }, ]); @@ -631,28 +598,24 @@ describe('Page Relationships Controller', () => { })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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('https://external.example.com/us/en/page1:Missing Title'); - expect(body.relationships).to.have.property('https://external.example.com/us/en/page1:Missing Title'); + 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'); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: 'https://example.com/us/en/page1', - suggestionType: 'Missing Title', - }), - ]); - resolvePageIdsStub.resolves([ - { url: 'https://example.com/us/en/page1', pageId: 'pg-1' }, - ]); + 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]: { @@ -664,33 +627,29 @@ describe('Page Relationships Controller', () => { })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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('https://example.com/us/en/page1:Missing Title'); - expect(body.relationships).to.have.property('https://example.com/us/en/page1:Missing Title'); + 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); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - pageUrl: 'https://example.com', - suggestionType: 'Missing Title', - }), - ]); + 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' }, - ]); + resolvePageIdsStub.resolves([{ url: '/', pageId: 'pg-root' }]); fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ results: { [items[0].key]: { @@ -702,86 +661,164 @@ describe('Page Relationships Controller', () => { })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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('/:Missing Title'); - expect(body.relationships).to.have.property('/:Missing Title'); + 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('does not derive suggestion type from title or opportunity type', async () => { + it('includes checkPath when buildCheckPath returns non-empty value', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion( - { - pageUrl: '/us/en/page1', - title: 'Marketing Page', - }, - 'CONTENT_UPDATE', - ), + 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.callsFake(async (authorURL, items) => ({ + fetchRelationshipsStub.resolves({ results: { - [items[0].key]: { - pageId: items[0].pageId, - upstream: { chain: [] }, - }, + 'row-1': { pageId: 'pg-1', upstream: { chain: [] } }, }, errors: {}, - })); + }); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + 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('/us/en/page1:'); - expect(body.relationships).to.have.property('/us/en/page1:'); + 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('deduplicates suggestions by normalized pageUrl + suggestionType', async () => { + it('skips fetchRelationships when all pages fail to resolve', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves([ - createSuggestion({ - url: 'https://example.com/us/en/page-dup', - suggestionType: 'Missing Title', - }), - createSuggestion({ - pageUrl: '/us/en/page-dup', - suggestionType: 'Missing Title', - }), + resolvePageIdsStub.resolves([ + { url: '/us/en/page1', error: 'No content-page-id or content-page-ref' }, ]); - resolvePageIdsStub.resolves([{ url: '/us/en/page-dup', pageId: 'pg-dup' }]); - fetchRelationshipsStub.resolves({ + 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: { - '/us/en/page-dup:Missing Title': { pageId: 'pg-dup', upstream: { chain: [] } }, + [items[0].key]: { pageId: 'pg-1', upstream: { chain: [] } }, }, errors: {}, - }); + })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(200); - expect(resolvePageIdsStub).to.have.been.calledOnce; - expect(resolvePageIdsStub.firstCall.args[2]).to.deep.equal(['/us/en/page-dup']); - expect(fetchRelationshipsStub.firstCall.args[1]).to.have.lengthOf(1); - expect(body.relationships).to.have.property('/us/en/page-dup:Missing Title'); + 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 unique pages', async () => { + it('batches requests in chunks of 50 for more than 50 pages', async () => { isAEMAuthoredSiteStub.returns(true); - mockDataAccess.Suggestion.allByOpportunityId.resolves( - Array.from({ length: 120 }, (_, i) => createSuggestion({ - url: `/us/en/page-${i}`, + 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) => ( + }; + resolvePageIdsStub.callsFake(async (baseURL, authorURL, pageUrls) => ( pageUrls.map((pageUrl) => ({ url: pageUrl, pageId: `pg-${pageUrl}` })) )); fetchRelationshipsStub.callsFake(async (authorURL, items) => ({ @@ -792,7 +829,7 @@ describe('Page Relationships Controller', () => { })); const controller = PageRelationshipsController(controllerContext); - const response = await controller.getForOpportunity(requestContext); + const response = await controller.search(requestContext); const body = await response.json(); expect(response.status).to.equal(200); @@ -806,6 +843,10 @@ describe('Page Relationships Controller', () => { 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 249165fc1..1e4a3e4d8 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -340,7 +340,6 @@ describe('getRouteHandlers', () => { const mockPageRelationshipsController = { search: sinon.stub(), - getForOpportunity: sinon.stub(), }; it('segregates static and dynamic routes', () => { @@ -510,7 +509,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/opportunities/top-paid', 'GET /sites/:siteId/opportunities/by-status/:status', 'GET /sites/:siteId/opportunities/:opportunityId', - 'GET /sites/:siteId/opportunities/:opportunityId/page-relationships', + 'POST /sites/:siteId/page-relationships/search', 'POST /sites/:siteId/opportunities', 'PATCH /sites/:siteId/opportunities/:opportunityId', 'DELETE /sites/:siteId/opportunities/:opportunityId', @@ -729,10 +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['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].handler) - .to.equal(mockPageRelationshipsController.getForOpportunity); - expect(dynamicRoutes['GET /sites/:siteId/opportunities/:opportunityId/page-relationships'].paramNames) - .to.deep.equal(['siteId', 'opportunityId']); + 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);