diff --git a/src/controllers/opportunities.js b/src/controllers/opportunities.js index f0336c232..928664870 100644 --- a/src/controllers/opportunities.js +++ b/src/controllers/opportunities.js @@ -27,7 +27,6 @@ import { } from '@adobe/spacecat-shared-utils'; import { ValidationError } from '@adobe/spacecat-shared-data-access'; import { OpportunityDto } from '../dto/opportunity.js'; -import { OpportunitySummaryDto } from '../dto/opportunity-summary.js'; import AccessControlUtil from '../support/access-control-util.js'; /** @@ -44,7 +43,7 @@ function OpportunitiesController(ctx) { if (!isNonEmptyObject(dataAccess)) { throw new Error('Data access required'); } - const { Opportunity, Suggestion } = dataAccess; + const { Opportunity } = dataAccess; if (!isObject(Opportunity)) { throw new Error('Opportunity Collection not available'); } @@ -312,70 +311,11 @@ function OpportunitiesController(ctx) { } }; - /** - * Gets top opportunities for paid media with 'NEW' or 'IN_PROGRESS' status for a site. - * @param {Object} context of the request - * @returns {Promise} Array of opportunity summaries. - */ - const getTopPaidOpportunities = async (context) => { - const siteId = context.params?.siteId; - - if (!isValidUUID(siteId)) { - return badRequest('Site ID required'); - } - - const site = await Site.findById(siteId); - if (!site) { - return notFound('Site not found'); - } - if (!await accessControlUtil.hasAccess(site)) { - return forbidden('Only users belonging to the organization of the site can view its opportunities'); - } - - const newOpportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'NEW'); - const inProgressOpportunities = await Opportunity.allBySiteIdAndStatus(siteId, 'IN_PROGRESS'); - const allOpportunities = [...newOpportunities, ...inProgressOpportunities]; - - // temp using all these tags for testing but evenutally we will just use 'paid media' - const targetTags = ['paid media', 'traffic acquisition', 'engagement', 'content optimization']; - const filteredOpportunities = allOpportunities.filter((oppty) => { - const tags = oppty.getTags() || []; - const title = oppty.getTitle() || ''; - const description = oppty.getDescription(); - - if (!description) { - return false; - } - - if (title.toLowerCase().includes('report')) { - return false; - } - - return tags.some((tag) => targetTags.includes(tag.toLowerCase())); - }); - - const opportunitySummaries = await Promise.all( - filteredOpportunities.map(async (oppty) => { - const suggestions = await Suggestion.allByOpportunityId(oppty.getId()); - return OpportunitySummaryDto.toJSON(oppty, suggestions); - }), - ); - - const validSummaries = opportunitySummaries.filter( - (summary) => summary.projectedTrafficValue > 0, - ); - - validSummaries.sort((a, b) => b.projectedTrafficValue - a.projectedTrafficValue); - - return ok(validSummaries); - }; - return { createOpportunity, getAllForSite, getByID, getByStatus, - getTopPaidOpportunities, patchOpportunity, removeOpportunity, }; diff --git a/src/controllers/paid/top-paid-opportunities.js b/src/controllers/paid/top-paid-opportunities.js new file mode 100644 index 000000000..6ceaf7f98 --- /dev/null +++ b/src/controllers/paid/top-paid-opportunities.js @@ -0,0 +1,369 @@ +/* + * 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 { + notFound, + ok, + forbidden, +} from '@adobe/spacecat-shared-http-utils'; +import { getWeekInfo } from '@adobe/spacecat-shared-utils'; +import { + AWSAthenaClient, + TrafficDataWithCWVDto, + getTrafficAnalysisQuery, + getTrafficAnalysisQueryPlaceholdersFilled, +} from '@adobe/spacecat-shared-athena-client'; +import { OpportunitySummaryDto } from '../../dto/opportunity-summary.js'; +import AccessControlUtil from '../../support/access-control-util.js'; + +async function validateSiteAndPermissions(siteId, Site, accessControlUtil) { + const site = await Site.findById(siteId); + if (!site) { + return { ok: false, response: notFound('Site not found') }; + } + + if (!await accessControlUtil.hasAccess(site)) { + return { + ok: false, + response: forbidden('Only users belonging to the organization of the site can view its opportunities'), + }; + } + + return { ok: true, site }; +} + +function getCwvThresholds(cwvThresholds, log) { + if (!cwvThresholds) { + return {}; + } + + try { + return typeof cwvThresholds === 'string' + ? JSON.parse(cwvThresholds) + : cwvThresholds; + } catch (e) { + log.warn(`Failed to parse CWV_THRESHOLDS: ${e.message}`); + return {}; + } +} + +async function fetchPaidTrafficData(athenaClient, siteId, baseURL, temporal, config, log) { + const { + rumMetricsDatabase, + rumMetricsCompactTable, + pageViewThreshold, + thresholdConfig, + } = config; + const { yearInt, weekInt, monthInt } = temporal; + + const tableName = `${rumMetricsDatabase}.${rumMetricsCompactTable}`; + const description = `Top Paid Opportunities - Site: ${siteId}, Year: ${yearInt}, Week: ${weekInt}, Month: ${monthInt}`; + + const queryParams = getTrafficAnalysisQueryPlaceholdersFilled({ + week: weekInt, + month: monthInt, + year: yearInt, + siteId, + dimensions: ['path'], + tableName, + pageTypes: null, + pageTypeMatchColumn: 'path', + trfTypes: ['paid'], + pageViewThreshold, + numTemporalSeries: 1, + }); + + const query = getTrafficAnalysisQuery(queryParams); + + log.debug(`Executing Athena query for site ${siteId}: database=${rumMetricsDatabase}, query=${query}`); + + const results = await athenaClient.query(query, rumMetricsDatabase, description); + + log.info(`Athena query returned ${results.length} rows`); + + return results.map((row) => TrafficDataWithCWVDto.toJSON(row, thresholdConfig, baseURL)); +} + +function filterHighTrafficPoorCwv(trafficData, pageViewThreshold, log) { + const filtered = trafficData.filter((item) => { + const pageViews = item.pageviews; + const cwvScore = item.overall_cwv_score; + return pageViews >= pageViewThreshold && (cwvScore === 'poor' || cwvScore === 'needs improvement'); + }); + + if (filtered.length === 0) { + log.info(`No high-traffic paid URLs with poor or needs-improvement CWV (pageviews >= ${pageViewThreshold})`); + return []; + } + + const sorted = filtered + .sort((a, b) => (b.pageviews) - (a.pageviews)); + + log.info(`Found ${sorted.length} high-traffic paid URLs with poor or needs-improvement CWV (pageviews >= ${pageViewThreshold})`); + + return sorted; +} + +function shouldIncludeOpportunity(opportunity) { + const title = opportunity.getTitle(); + const description = opportunity.getDescription(); + const data = opportunity.getData() || {}; + const projectedTrafficValue = data.projectedTrafficValue || 0; + + if (!description || title.toLowerCase().includes('report')) { + return false; + } + + if (projectedTrafficValue <= 0) { + return false; + } + + return true; +} + +function normalizeUrl(url) { + return url + .replace(/^https?:\/\/www\./, 'https://') + .replace(/\/$/, ''); +} + +async function matchCwvOpportunitiesWithUrls( + cwvOpportunities, + topPoorCwvData, + Suggestion, + log, +) { + if (cwvOpportunities.length === 0 || topPoorCwvData.length === 0) { + log.info(`No matching needed: cwvOpportunities=${cwvOpportunities.length}, topPoorCwvData=${topPoorCwvData.length}`); + return { matched: [], paidUrlsMap: new Map() }; + } + + const topPoorCwvUrls = topPoorCwvData.map((item) => item.url); + log.info(`Matching ${cwvOpportunities.length} CWV opportunities against ${topPoorCwvUrls.length} poor CWV URLs from paid traffic`); + + // Create a map of normalized URL -> { url, pageviews } for fast lookup + const normalizedUrlToDataMap = new Map(); + topPoorCwvData.forEach((item) => { + const normalized = normalizeUrl(item.url); + const pageviews = parseInt(item.pageviews, 10); + normalizedUrlToDataMap.set(normalized, { url: item.url, pageviews }); + }); + + const suggestionsPromises = cwvOpportunities.map( + (opportunity) => Suggestion.allByOpportunityIdAndStatus(opportunity.getId(), 'NEW'), + ); + const allSuggestions = await Promise.all(suggestionsPromises); + + const matched = []; + const paidUrlsMap = new Map(); + + cwvOpportunities.forEach((opportunity, index) => { + const suggestions = allSuggestions[index]; + const opportunityId = opportunity.getId(); + + // Collect all URLs from paid traffic that match NEW suggestions only + const matchedPaidUrlsMap = new Map(); + const urlFields = ['url', 'url_from', 'urlFrom', 'url_to', 'urlTo']; + + suggestions.forEach((suggestion) => { + const suggestionData = suggestion.getData(); + urlFields.forEach((field) => { + if (suggestionData[field]) { + const suggestionUrl = suggestionData[field]; + const normalized = normalizeUrl(suggestionUrl); + if (normalizedUrlToDataMap.has(normalized)) { + const paidUrlData = normalizedUrlToDataMap.get(normalized); + // Store suggestion URL with pageviews from paid traffic + matchedPaidUrlsMap.set(suggestionUrl, paidUrlData.pageviews); + } + } + }); + }); + + if (matchedPaidUrlsMap.size > 0) { + // Sort by pageviews descending + const urlsWithPageviews = Array.from(matchedPaidUrlsMap.entries()) + .map(([url, pageviews]) => ({ url, pageviews })) + .sort((a, b) => b.pageviews - a.pageviews); + + const sortedUrls = urlsWithPageviews.map((item) => item.url); + const totalPageViews = urlsWithPageviews.reduce((sum, item) => sum + item.pageviews, 0); + + paidUrlsMap.set(opportunityId, { urls: sortedUrls, pageViews: totalPageViews }); + matched.push(opportunity); + } + }); + + log.info(`Matched ${matched.length} CWV opportunities with poor CWV URLs from paid traffic`); + return { matched, paidUrlsMap }; +} + +/** + * Top Paid Opportunities controller. + * @param {object} ctx - Context of the request. + * @param {object} env - Environment variables. + * @returns {object} Controller with getTopPaidOpportunities function. + * @constructor + */ +function TopPaidOpportunitiesController(ctx, env = {}) { + const { dataAccess, log } = ctx; + const { Opportunity, Suggestion, Site } = dataAccess; + const accessControlUtil = AccessControlUtil.fromContext(ctx); + + const getTopPaidOpportunities = async (context) => { + const siteId = context.params?.siteId; + + // Validate site and permissions + const validation = await validateSiteAndPermissions(siteId, Site, accessControlUtil); + if (!validation.ok) { + return validation.response; + } + const { site } = validation; + + const PAGE_VIEW_THRESHOLD = env.PAID_DATA_THRESHOLD ?? 1000; + const TOP_URLS_LIMIT = 20; + const TARGET_TAG = 'paid media'; + const CWV_TYPE = 'cwv'; + + // Fetch all opportunities with NEW or IN_PROGRESS status first + const [newOpportunities, inProgressOpportunities] = await Promise.all([ + Opportunity.allBySiteIdAndStatus(siteId, 'NEW'), + Opportunity.allBySiteIdAndStatus(siteId, 'IN_PROGRESS'), + ]); + + const allOpportunities = [...newOpportunities, ...inProgressOpportunities]; + + const paidMediaOpportunities = []; + const cwvOpportunities = []; + + for (const opportunity of allOpportunities) { + if (!shouldIncludeOpportunity(opportunity)) { + // eslint-disable-next-line no-continue + continue; + } + + const tags = opportunity.getTags() || []; + const type = opportunity.getType(); + const opportunityData = opportunity.getData(); + + // Check if has 'paid media' tag (case-insensitive) + const hasPaidMediaTag = tags.some((tag) => tag.toLowerCase() === TARGET_TAG); + + // Check if type is one that should be treated as paid media + const isPaidMediaType = type === 'consent-banner' + || opportunityData.opportunityType === 'no-cta-above-the-fold'; + + if (hasPaidMediaTag || isPaidMediaType) { + paidMediaOpportunities.push(opportunity); + } else if (type === CWV_TYPE) { + cwvOpportunities.push(opportunity); + } + } + + let topPoorCwvData = []; + + // if there are cwv opportunities, find which of them are from paid traffic by querying Athena + if (cwvOpportunities.length > 0) { + try { + // Get temporal parameters with defaults + const { month } = context.data || {}; + let { year, week } = context.data || {}; + + if (!year || (!week && !month)) { + const lastFullWeek = getWeekInfo(); + if (!year) { + year = lastFullWeek.year; + log.warn(`No year provided, using default: ${year}`); + } + if (!week && !month) { + week = lastFullWeek.week; + log.warn(`No week or month provided, using default week: ${week}`); + } + } + + const yearInt = year; + const weekInt = week || 0; + const monthInt = month || 0; + const baseURL = await site.getBaseURL(); + const resultLocation = `s3://${env.S3_BUCKET_NAME}/athena-results/`; + const thresholdConfig = getCwvThresholds(env.CWV_THRESHOLDS, log); + + const athenaClient = AWSAthenaClient.fromContext(context, resultLocation); + + const trafficData = await fetchPaidTrafficData( + athenaClient, + siteId, + baseURL, + { yearInt, weekInt, monthInt }, + { + rumMetricsDatabase: env.RUM_METRICS_DATABASE, + rumMetricsCompactTable: env.RUM_METRICS_COMPACT_TABLE, + pageViewThreshold: PAGE_VIEW_THRESHOLD, + thresholdConfig, + }, + log, + ); + + topPoorCwvData = filterHighTrafficPoorCwv( + trafficData, + PAGE_VIEW_THRESHOLD, + log, + ); + } catch (error) { + log.error(`Failed to query Athena for paid traffic CWV data: ${error.message}`); + // Continue without CWV filtering - will only return 'paid media' tagged opportunities + } + } else { + log.info(`No CWV opportunities found for site ${siteId}, skipping Athena query for paid traffic`); + } + + // Match CWV opportunities with poor CWV URLs from paid traffic + const matchResult = await matchCwvOpportunitiesWithUrls( + cwvOpportunities, + topPoorCwvData, + Suggestion, + log, + ); + const { matched: matchedCwvOpportunities, paidUrlsMap } = matchResult; + + // Combine all filtered opportunities: paid media tag OR matched CWV from paid traffic + const filteredOpportunities = [...paidMediaOpportunities, ...matchedCwvOpportunities]; + + // Sort by projectedTrafficValue descending + filteredOpportunities.sort((a, b) => { + const aValue = a.getData().projectedTrafficValue; + const bValue = b.getData().projectedTrafficValue; + return bValue - aValue; + }); + + // Convert to DTOs + const opportunitySummaries = await Promise.all( + filteredOpportunities.map(async (opportunity) => { + const opportunityId = opportunity.getId(); + const paidUrlsData = paidUrlsMap.get(opportunityId); + // Only fetch NEW suggestions if not a CWV opportunity (no paidUrlsData) + const suggestions = paidUrlsData + ? [] + : await Suggestion.allByOpportunityIdAndStatus(opportunityId, 'NEW'); + return OpportunitySummaryDto.toJSON(opportunity, suggestions, paidUrlsData, TOP_URLS_LIMIT); + }), + ); + + return ok(opportunitySummaries); + }; + + return { + getTopPaidOpportunities, + }; +} + +export default TopPaidOpportunitiesController; diff --git a/src/dto/opportunity-summary.js b/src/dto/opportunity-summary.js index a14710ba4..df92d3a63 100644 --- a/src/dto/opportunity-summary.js +++ b/src/dto/opportunity-summary.js @@ -20,6 +20,7 @@ export const OpportunitySummaryDto = { * Converts an Opportunity object with its suggestions into a summary JSON object. * @param {Readonly} opportunity - Opportunity object. * @param {Array>} suggestions - Array of suggestion objects. + * @param {Object} paidUrlsData - Optional object with urls and pageViews for CWV opportunities. * @returns {{ * opportunityId: string, * urls: Array, @@ -34,34 +35,45 @@ export const OpportunitySummaryDto = { * projectedTrafficValue: number * }} JSON object. */ - toJSON: (opportunity, suggestions = []) => { + toJSON: (opportunity, suggestions = [], paidUrlsData = null, topUrlsLimit = 20) => { // Extract unique URLs from suggestions const urls = new Set(); let totalPageViews = 0; - suggestions.forEach((suggestion) => { - const data = suggestion.getData(); - // Handle different URL field names in suggestion data - if (data.url_from) urls.add(data.url_from); - if (data.url_to) urls.add(data.url_to); - if (data.urlFrom) urls.add(data.urlFrom); - if (data.urlTo) urls.add(data.urlTo); - if (data.url) urls.add(data.url); + // If paidUrlsData provided, use those URLs and pageViews + // (for CWV opportunities from paid traffic) + if (paidUrlsData && paidUrlsData.urls) { + paidUrlsData.urls.forEach((url) => urls.add(url)); + totalPageViews = paidUrlsData.pageViews || 0; + } else { + // Otherwise, extract all URLs from suggestion data (for paid media opportunities) + suggestions.forEach((suggestion) => { + const data = suggestion.getData(); + // Handle different URL field names in suggestion data + if (data.url_from) urls.add(data.url_from); + if (data.url_to) urls.add(data.url_to); + if (data.urlFrom) urls.add(data.urlFrom); + if (data.urlTo) urls.add(data.urlTo); + if (data.url) urls.add(data.url); + }); // Aggregate page views from rank (which often represents traffic) - const rank = suggestion.getRank(); - if (rank && typeof rank === 'number') { - totalPageViews += rank; - } + suggestions.forEach((suggestion) => { + const data = suggestion.getData(); + const rank = suggestion.getRank(); + if (rank && typeof rank === 'number') { + totalPageViews += rank; + } - // Also check for traffic_domain or trafficDomain in data - if (data.traffic_domain && typeof data.traffic_domain === 'number') { - totalPageViews += data.traffic_domain; - } - if (data.trafficDomain && typeof data.trafficDomain === 'number') { - totalPageViews += data.trafficDomain; - } - }); + // Also check for traffic_domain or trafficDomain in data + if (data.traffic_domain && typeof data.traffic_domain === 'number') { + totalPageViews += data.traffic_domain; + } + if (data.trafficDomain && typeof data.trafficDomain === 'number') { + totalPageViews += data.trafficDomain; + } + }); + } // Get projected traffic data from opportunity data const opportunityData = opportunity.getData() || {}; @@ -70,7 +82,7 @@ export const OpportunitySummaryDto = { return { opportunityId: opportunity.getId(), - urls: Array.from(urls).slice(0, 10), + urls: Array.from(urls).slice(0, topUrlsLimit), name: opportunity.getTitle(), type: null, description: null, diff --git a/src/index.js b/src/index.js index 29dfa0ce9..0cc097b99 100644 --- a/src/index.js +++ b/src/index.js @@ -59,6 +59,7 @@ import { multipartFormData } from './support/multipart-form-data.js'; import ApiKeyController from './controllers/api-key.js'; import OpportunitiesController from './controllers/opportunities.js'; import PaidController from './controllers/paid.js'; +import TopPaidOpportunitiesController from './controllers/paid/top-paid-opportunities.js'; import TrafficController from './controllers/paid/traffic.js'; import SuggestionsController from './controllers/suggestions.js'; import BrandsController from './controllers/brands.js'; @@ -126,6 +127,7 @@ async function run(request, context) { const suggestionsController = SuggestionsController(context, context.sqs, context.env); const brandsController = BrandsController(context, log, context.env); const paidController = PaidController(context); + const topPaidOpportunitiesController = TopPaidOpportunitiesController(context, context.env); const trafficController = TrafficController(context, log, context.env); const preflightController = PreflightController(context, log, context.env); const demoController = DemoController(context); @@ -167,6 +169,7 @@ async function run(request, context) { scrapeController, scrapeJobController, paidController, + topPaidOpportunitiesController, trafficController, fixesController, llmoController, diff --git a/src/routes/index.js b/src/routes/index.js index dddd73e49..81c19cfe5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -69,6 +69,7 @@ function isStaticRoute(routePattern) { * @param {Object} scrapeJobController - The scrape job controller. * @param {Object} mcpController - The MCP controller. * @param {Object} paidController - The paid controller. + * @param {Object} topPaidOpportunitiesController - The top paid opportunities controller. * @param {Object} trafficController - The traffic controller. * @param {FixesController} fixesController - The fixes controller. * @param {Object} llmoController - The LLMO controller. @@ -106,6 +107,7 @@ export default function getRouteHandlers( scrapeController, scrapeJobController, paidController, + topPaidOpportunitiesController, trafficController, fixesController, llmoController, @@ -185,7 +187,7 @@ export default function getRouteHandlers( 'GET /sites/by-delivery-type/:deliveryType': sitesController.getAllByDeliveryType, 'GET /sites/with-latest-audit/:auditType': sitesController.getAllWithLatestAudit, 'GET /sites/:siteId/opportunities': opportunitiesController.getAllForSite, - 'GET /sites/:siteId/opportunities/top-paid': opportunitiesController.getTopPaidOpportunities, + 'GET /sites/:siteId/opportunities/top-paid': topPaidOpportunitiesController.getTopPaidOpportunities, 'GET /sites/:siteId/opportunities/by-status/:status': opportunitiesController.getByStatus, 'GET /sites/:siteId/opportunities/:opportunityId': opportunitiesController.getByID, 'POST /sites/:siteId/opportunities': opportunitiesController.createOpportunity, diff --git a/test/controllers/opportunities.test.js b/test/controllers/opportunities.test.js index 39bacc234..1f0966e4a 100644 --- a/test/controllers/opportunities.test.js +++ b/test/controllers/opportunities.test.js @@ -177,7 +177,6 @@ describe('Opportunities Controller', () => { 'createOpportunity', 'patchOpportunity', 'removeOpportunity', - 'getTopPaidOpportunities', ]; let mockOpportunityDataAccess; @@ -1034,586 +1033,4 @@ describe('Opportunities Controller', () => { }); }); }); - - describe('getTopPaidOpportunities', () => { - let mockSuggestion; - - beforeEach(() => { - mockSuggestion = { - allByOpportunityId: sandbox.stub(), - }; - - mockOpportunityDataAccess.Suggestion = mockSuggestion; - opportunitiesController = OpportunitiesController(mockContext); - }); - - it('returns top paid opportunities with NEW and IN_PROGRESS status', async () => { - const paidOppty1 = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Paid Media Opportunity', - getDescription: () => 'Description for paid media', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - const paidOppty2 = { - getId: () => 'oppty-2', - getSiteId: () => SITE_ID, - getTitle: () => 'Traffic Acquisition Opportunity', - getDescription: () => 'Description for traffic acquisition', - getType: () => 'content-optimization', - getStatus: () => 'IN_PROGRESS', - getTags: () => ['traffic acquisition'], - getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([paidOppty1]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([paidOppty2]); - - const mockSuggestions = [ - { - getData: () => ({ url_from: 'https://example.com/page1' }), - getRank: () => 100, - }, - ]; - - mockSuggestion.allByOpportunityId.resolves(mockSuggestions); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(2); - // Should be sorted by projectedTrafficValue descending - expect(opportunities[0].projectedTrafficValue).to.equal(8000); - expect(opportunities[1].projectedTrafficValue).to.equal(5000); - expect(opportunities[0]).to.have.property('status', 'IN_PROGRESS'); - expect(opportunities[1]).to.have.property('status', 'NEW'); - }); - - it('filters out opportunities without description', async () => { - const withDescription = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Valid Opportunity', - getDescription: () => 'Has description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - const withoutDescription = { - getId: () => 'oppty-2', - getSiteId: () => SITE_ID, - getTitle: () => 'Invalid Opportunity', - getDescription: () => '', - getType: () => 'content-optimization', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([withDescription, withoutDescription]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0].name).to.equal('Valid Opportunity'); - }); - - it('filters out opportunities with "report" in title', async () => { - const validOppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Valid Opportunity', - getDescription: () => 'Has description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - const reportOppty = { - getId: () => 'oppty-2', - getSiteId: () => SITE_ID, - getTitle: () => 'Monthly Report Opportunity', - getDescription: () => 'Has description', - getType: () => 'content-optimization', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([validOppty, reportOppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0].name).to.equal('Valid Opportunity'); - }); - - it('filters out opportunities with 0 projected traffic value', async () => { - const validOppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Valid Opportunity', - getDescription: () => 'Has description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - const zeroValueOppty = { - getId: () => 'oppty-2', - getSiteId: () => SITE_ID, - getTitle: () => 'Zero Value Opportunity', - getDescription: () => 'Has description', - getType: () => 'content-optimization', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 0, projectedTrafficValue: 0 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([validOppty, zeroValueOppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0].name).to.equal('Valid Opportunity'); - expect(opportunities[0].projectedTrafficValue).to.equal(5000); - }); - - it('filters by target tags (case-insensitive)', async () => { - const paidMediaOppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Paid Media Opportunity', - getDescription: () => 'Description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['Paid Media'], // Different case - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - const engagementOppty = { - getId: () => 'oppty-2', - getSiteId: () => SITE_ID, - getTitle: () => 'Engagement Opportunity', - getDescription: () => 'Description', - getType: () => 'content-optimization', - getStatus: () => 'NEW', - getTags: () => ['ENGAGEMENT'], // Different case - getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), - }; - - const otherOppty = { - getId: () => 'oppty-3', - getSiteId: () => SITE_ID, - getTitle: () => 'Other Opportunity', - getDescription: () => 'Description', - getType: () => 'other', - getStatus: () => 'NEW', - getTags: () => ['other-tag'], - getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([paidMediaOppty, engagementOppty, otherOppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(2); - expect(opportunities.map((o) => o.name)).to.include('Paid Media Opportunity'); - expect(opportunities.map((o) => o.name)).to.include('Engagement Opportunity'); - expect(opportunities.map((o) => o.name)).to.not.include('Other Opportunity'); - }); - - it('limits URLs to 10', async () => { - const oppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Opportunity with many URLs', - getDescription: () => 'Description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([oppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - // Create 15 suggestions with different URLs - const manySuggestions = Array.from({ length: 15 }, (_, i) => ({ - getData: () => ({ url_from: `https://example.com/page${i + 1}` }), - getRank: () => 100, - })); - - mockSuggestion.allByOpportunityId.resolves(manySuggestions); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0].urls).to.be.an('array').with.lengthOf(10); - }); - - it('returns 400 for invalid site ID', async () => { - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: 'invalid-uuid' }, - }); - - expect(response.status).to.equal(400); - const error = await response.json(); - expect(error).to.have.property('message', 'Site ID required'); - }); - - it('returns 404 when site does not exist', async () => { - mockSite.findById.resolves(null); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(404); - const error = await response.json(); - expect(error).to.have.property('message', 'Site not found'); - }); - - it('includes type, description, status, system_type, and system_description in response', async () => { - const oppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Test Opportunity', - getDescription: () => 'System description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([oppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0]).to.have.property('type', null); - expect(opportunities[0]).to.have.property('description', null); - expect(opportunities[0]).to.have.property('status', 'NEW'); - expect(opportunities[0]).to.have.property('system_type', 'broken-backlinks'); - expect(opportunities[0]).to.have.property('system_description', 'System description'); - }); - - it('aggregates page views from traffic_domain field', async () => { - const oppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Test Opportunity', - getDescription: () => 'Description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([oppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - const suggestionsWithTrafficDomain = [ - { - getData: () => ({ url_from: 'https://example.com/page1', traffic_domain: 500 }), - getRank: () => 100, - }, - { - getData: () => ({ url_from: 'https://example.com/page2', traffic_domain: 300 }), - getRank: () => 50, - }, - ]; - - mockSuggestion.allByOpportunityId.resolves(suggestionsWithTrafficDomain); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - // Should aggregate: rank (100 + 50) + traffic_domain (500 + 300) = 950 - expect(opportunities[0].pageViews).to.equal(950); - }); - - it('aggregates page views from trafficDomain field', async () => { - const oppty = { - getId: () => 'oppty-1', - getSiteId: () => SITE_ID, - getTitle: () => 'Test Opportunity', - getDescription: () => 'Description', - getType: () => 'broken-backlinks', - getStatus: () => 'NEW', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), - }; - - mockOpportunity.allBySiteIdAndStatus - .withArgs(SITE_ID, 'NEW').resolves([oppty]) - .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - - const suggestionsWithTrafficDomain = [ - { - getData: () => ({ url_from: 'https://example.com/page1', trafficDomain: 400 }), - getRank: () => 100, - }, - { - getData: () => ({ url_from: 'https://example.com/page2', trafficDomain: 200 }), - getRank: () => 50, - }, - ]; - - mockSuggestion.allByOpportunityId.resolves(suggestionsWithTrafficDomain); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - // Should aggregate: rank (100 + 50) + trafficDomain (400 + 200) = 750 - expect(opportunities[0].pageViews).to.equal(750); - }); - - it('returns 403 when user does not have access to site', async () => { - // Mock Site with Organization - const mockOrg = { - getImsOrgId: () => 'test-org-id', - }; - - const mockSiteWithOrg = { - id: SITE_ID, - getOrganization: async () => mockOrg, - }; - Object.setPrototypeOf(mockSiteWithOrg, Site.prototype); - mockSite.findById.resolves(mockSiteWithOrg); - - // Create a restricted auth context - const restrictedAuthInfo = new AuthInfo() - .withType('jwt') - .withScopes([{ name: 'user' }]) - .withProfile({ is_admin: false }) - .withAuthenticated(true); - - // Set organizations claim to empty array (no access) - restrictedAuthInfo.claims = { - organizations: [], - }; - - const restrictedContext = { - dataAccess: mockOpportunityDataAccess, - log: mockContext.log, - pathInfo: { - headers: { 'x-product': 'abcd' }, - }, - attributes: { - authInfo: restrictedAuthInfo, - }, - }; - - const restrictedController = OpportunitiesController(restrictedContext); - - const response = await restrictedController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(403); - const error = await response.json(); - expect(error).to.have.property('message', 'Only users belonging to the organization of the site can view its opportunities'); - expect(mockSite.findById).to.have.been.calledWith(SITE_ID); - }); - - it('handles opportunities with null tags and title', async () => { - const opptyWithNulls = { - getId: () => 'oppty-null-1', - getTitle: () => null, - getDescription: () => 'Valid description', - getStatus: () => 'NEW', - getType: () => 'paid-media', - getTags: () => null, - getData: () => ({ projectedTrafficLost: 100, projectedTrafficValue: 500 }), - }; - - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'NEW').resolves([opptyWithNulls]); - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - // Should be filtered out because null tags means no matching tags - expect(opportunities).to.be.an('array').with.lengthOf(0); - }); - - it('handles suggestions with all URL field variations', async () => { - const oppty = { - getId: () => 'oppty-urls-1', - getTitle: () => 'URL Test Opportunity', - getDescription: () => 'Testing all URL fields', - getStatus: () => 'NEW', - getType: () => 'broken-backlinks', - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 100, projectedTrafficValue: 500 }), - }; - - const suggestionsWithAllUrlFields = [ - { - getId: () => 'sugg-1', - getRank: () => 0, - getData: () => ({ url_from: 'https://example.com/from1' }), - }, - { - getId: () => 'sugg-2', - getRank: () => 0, - getData: () => ({ url_to: 'https://example.com/to1' }), - }, - { - getId: () => 'sugg-3', - getRank: () => 0, - getData: () => ({ urlFrom: 'https://example.com/from2' }), - }, - { - getId: () => 'sugg-4', - getRank: () => 0, - getData: () => ({ urlTo: 'https://example.com/to2' }), - }, - { - getId: () => 'sugg-5', - getRank: () => 0, - getData: () => ({ url: 'https://example.com/url1' }), - }, - ]; - - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'NEW').resolves([oppty]); - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - mockSuggestion.allByOpportunityId.resolves(suggestionsWithAllUrlFields); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - expect(opportunities).to.be.an('array').with.lengthOf(1); - expect(opportunities[0].urls).to.be.an('array').with.lengthOf(5); - expect(opportunities[0].urls).to.include('https://example.com/from1'); - expect(opportunities[0].urls).to.include('https://example.com/to1'); - expect(opportunities[0].urls).to.include('https://example.com/from2'); - expect(opportunities[0].urls).to.include('https://example.com/to2'); - expect(opportunities[0].urls).to.include('https://example.com/url1'); - }); - - it('handles opportunity with null type and description', async () => { - const opptyWithNullTypeDesc = { - getId: () => 'oppty-null-type-1', - getTitle: () => 'Null Type Test', - getDescription: () => null, - getStatus: () => 'NEW', - getType: () => null, - getTags: () => ['paid media'], - getData: () => ({ projectedTrafficLost: 100, projectedTrafficValue: 500 }), - }; - - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'NEW').resolves([opptyWithNullTypeDesc]); - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - // Should be filtered out because description is null - expect(opportunities).to.be.an('array').with.lengthOf(0); - }); - - it('handles opportunity with null getData', async () => { - const opptyWithNullData = { - getId: () => 'oppty-null-data-1', - getTitle: () => 'Null Data Test', - getDescription: () => 'Valid description', - getStatus: () => 'NEW', - getType: () => 'paid-media', - getTags: () => ['paid media'], - getData: () => null, - }; - - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'NEW').resolves([opptyWithNullData]); - mockOpportunity.allBySiteIdAndStatus.withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); - mockSuggestion.allByOpportunityId.resolves([]); - - const response = await opportunitiesController.getTopPaidOpportunities({ - params: { siteId: SITE_ID }, - }); - - expect(response.status).to.equal(200); - const opportunities = await response.json(); - // Should be filtered out because projectedTrafficValue is 0 - expect(opportunities).to.be.an('array').with.lengthOf(0); - }); - }); }); diff --git a/test/controllers/paid/top-paid-opportunities.test.js b/test/controllers/paid/top-paid-opportunities.test.js new file mode 100644 index 000000000..3526d714f --- /dev/null +++ b/test/controllers/paid/top-paid-opportunities.test.js @@ -0,0 +1,1568 @@ +/* + * 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 { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import sinon from 'sinon'; + +import AuthInfo from '@adobe/spacecat-shared-http-utils/src/auth/auth-info.js'; +import { AWSAthenaClient } from '@adobe/spacecat-shared-athena-client'; +import { Site } from '@adobe/spacecat-shared-data-access'; +import TopPaidOpportunitiesController from '../../../src/controllers/paid/top-paid-opportunities.js'; + +use(chaiAsPromised); +use(sinonChai); + +const SITE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +describe('TopPaidOpportunitiesController', () => { + let sandbox; + let mockContext; + let mockEnv; + let mockOpportunity; + let mockSuggestion; + let mockSite; + let topPaidController; + let mockLogger; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Stub AWSAthenaClient.fromContext + sandbox.stub(AWSAthenaClient, 'fromContext'); + + mockLogger = { + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + debug: sandbox.stub(), + }; + + mockOpportunity = { + allBySiteIdAndStatus: sandbox.stub().resolves([]), + }; + + mockSuggestion = { + allByOpportunityId: sandbox.stub().resolves([]), + allByOpportunityIdAndStatus: sandbox.stub().resolves([]), + }; + + mockSite = { + findById: sandbox.stub().resolves({ + id: SITE_ID, + getBaseURL: async () => 'https://example.com', + }), + }; + + mockContext = { + dataAccess: { + Opportunity: mockOpportunity, + Suggestion: mockSuggestion, + Site: mockSite, + }, + log: mockLogger, + s3: {}, + pathInfo: { + headers: { 'x-product': 'abcd' }, + }, + attributes: { + authInfo: new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'admin' }]) + .withProfile({ is_admin: true }) + .withAuthenticated(true), + }, + }; + + mockEnv = { + RUM_METRICS_DATABASE: 'test_db', + RUM_METRICS_COMPACT_TABLE: 'test_table', + S3_BUCKET_NAME: 'test-bucket', + PAID_DATA_THRESHOLD: 1000, + CWV_THRESHOLDS: {}, + }; + + topPaidController = TopPaidOpportunitiesController(mockContext, mockEnv); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getTopPaidOpportunities', () => { + it('returns 404 when site is not found', async () => { + const nonExistentSiteId = '00000000-0000-0000-0000-000000000000'; + mockSite.findById.resolves(null); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: nonExistentSiteId }, + data: {}, + }); + + expect(response.status).to.equal(404); + }); + + it('returns 403 when user does not have access to site', async () => { + const mockOrg = { + getImsOrgId: () => 'test-org-id', + }; + + const mockSiteWithOrg = { + id: SITE_ID, + getOrganization: async () => mockOrg, + getBaseURL: async () => 'https://example.com', + }; + Object.setPrototypeOf(mockSiteWithOrg, Site.prototype); + mockSite.findById.resolves(mockSiteWithOrg); + + const restrictedAuthInfo = new AuthInfo() + .withType('jwt') + .withScopes([{ name: 'user' }]) + .withProfile({ is_admin: false }) + .withAuthenticated(true); + + restrictedAuthInfo.claims = { + organizations: [], + }; + + const restrictedContext = { + ...mockContext, + attributes: { + authInfo: restrictedAuthInfo, + }, + }; + + const controller = TopPaidOpportunitiesController(restrictedContext, mockEnv); + + const response = await controller.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(403); + }); + + it('filters out opportunities with zero projectedTrafficValue', async () => { + const validOppty = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Valid Opportunity', + getDescription: () => 'Valid Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 1000 }), + }; + + const zeroValueOppty = { + getId: () => 'oppty-2', + getSiteId: () => SITE_ID, + getTitle: () => 'Zero Value', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 0 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([validOppty, zeroValueOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('oppty-1'); + }); + + it('returns paid media opportunities (with paid media tag)', async () => { + const paidOppty1 = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Paid Media Opportunity', + getDescription: () => 'Description for paid media', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([paidOppty1]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url_from: 'https://example.com/page1' }), + getRank: () => 100, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('oppty-1'); + expect(opportunities[0].pageViews).to.equal(100); + }); + + it('returns consent-banner opportunities as paid media', async () => { + const consentBannerOppty = { + getId: () => 'consent-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Consent Banner', + getDescription: () => 'Fix consent banner', + getType: () => 'consent-banner', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 500, projectedTrafficValue: 2000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([consentBannerOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('consent-1'); + expect(opportunities[0].system_type).to.equal('consent-banner'); + }); + + it('returns no-cta-above-the-fold opportunities as paid media', async () => { + const noctaOppty = { + getId: () => 'nocta-1', + getSiteId: () => SITE_ID, + getTitle: () => 'No CTA Above Fold', + getDescription: () => 'Fix CTA placement', + getType: () => 'generic-opportunity', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ + opportunityType: 'no-cta-above-the-fold', + projectedTrafficLost: 300, + projectedTrafficValue: 1500, + }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([noctaOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('nocta-1'); + }); + }); + + describe('CWV opportunity filtering', () => { + it('returns CWV opportunities only when URLs match poor CWV from paid traffic', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('cwv-1'); + expect(opportunities[0].pageViews).to.equal(5000); + expect(opportunities[0].urls).to.deep.equal(['https://example.com/page1']); + }); + + it('excludes CWV opportunities when Athena returns no poor CWV URLs', async () => { + const cwvOppty = { + getId: () => 'cwv-good', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + // Athena returns empty array (no poor CWV URLs) + const mockAthenaClient = { + query: sandbox.stub().resolves([]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + // Should be empty because Athena returned no poor CWV URLs + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('excludes CWV opportunities when URLs do not match paid traffic', async () => { + const cwvOppty = { + getId: () => 'cwv-2', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/different-page' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('sums pageviews correctly for CWV opportunities with multiple matching URLs', async () => { + const cwvOppty = { + getId: () => 'cwv-3', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + { + getData: () => ({ url: 'https://example.com/page2' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '3000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + { + url: 'https://example.com/page2', + pageviews: '2000', + overall_cwv_score: 'poor', + lcp_score: 'good', + inp_score: 'poor', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].pageViews).to.equal(5000); + }); + + it('does not fetch suggestions twice for CWV opportunities', async () => { + const cwvOppty = { + getId: () => 'cwv-4', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + // Should only be called once during matching, not again during DTO conversion + expect(mockSuggestion.allByOpportunityIdAndStatus.callCount).to.equal(1); + }); + + it('continues without CWV filtering when Athena query fails', async () => { + const cwvOppty = { + getId: () => 'cwv-5', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + const paidOppty = { + getId: () => 'paid-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Paid Media Opportunity', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficLost: 1000, projectedTrafficValue: 5000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty, paidOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const mockAthenaClient = { + query: sandbox.stub().rejects(new Error('Athena query failed')), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + // Should only return paid media opportunity, not CWV + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('paid-1'); + }); + + it('sorts opportunities by projectedTrafficValue descending', async () => { + const oppty1 = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Low Value', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 1000 }), + }; + + const oppty2 = { + getId: () => 'oppty-2', + getSiteId: () => SITE_ID, + getTitle: () => 'High Value', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 5000 }), + }; + + const oppty3 = { + getId: () => 'oppty-3', + getSiteId: () => SITE_ID, + getTitle: () => 'Medium Value', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 3000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([oppty1, oppty2, oppty3]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(3); + // Should be sorted: 5000, 3000, 1000 + expect(opportunities[0].opportunityId).to.equal('oppty-2'); + expect(opportunities[1].opportunityId).to.equal('oppty-3'); + expect(opportunities[2].opportunityId).to.equal('oppty-1'); + }); + + it('uses default year and week when not provided', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + expect(mockLogger.warn).to.have.been.calledWith(sinon.match(/No year provided/)); + expect(mockLogger.warn).to.have.been.calledWith(sinon.match(/No week or month provided/)); + }); + + it('uses default week when only year is provided', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025 }, + }); + + expect(response.status).to.equal(200); + expect(mockLogger.warn).to.have.been.calledWith(sinon.match(/No week or month provided/)); + expect(mockLogger.warn).to.not.have.been.calledWith(sinon.match(/No year provided/)); + }); + + it('filters out opportunities without description', async () => { + const validOppty = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Valid Opportunity', + getDescription: () => 'Valid Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 1000 }), + }; + + const noDescOppty = { + getId: () => 'oppty-2', + getSiteId: () => SITE_ID, + getTitle: () => 'No Description', + getDescription: () => null, + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 2000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([validOppty, noDescOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('oppty-1'); + }); + + it('handles invalid CWV_THRESHOLDS gracefully', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + // Create controller with invalid CWV_THRESHOLDS + const envWithInvalidThresholds = { + ...mockEnv, + CWV_THRESHOLDS: 'invalid-json{', + }; + + const controller = TopPaidOpportunitiesController(mockContext, envWithInvalidThresholds); + + const response = await controller.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + expect(mockLogger.warn).to.have.been.calledWith(sinon.match(/Failed to parse CWV_THRESHOLDS/)); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + }); + + it('handles null CWV_THRESHOLDS gracefully', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + // Create controller with null CWV_THRESHOLDS + const envWithNullThresholds = { + ...mockEnv, + CWV_THRESHOLDS: null, + }; + + const controller = TopPaidOpportunitiesController(mockContext, envWithNullThresholds); + + const response = await controller.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + }); + + it('filters out opportunities with "report" in title', async () => { + const validOppty = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Valid Opportunity', + getDescription: () => 'Valid Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 1000 }), + }; + + const reportOppty = { + getId: () => 'oppty-2', + getSiteId: () => SITE_ID, + getTitle: () => 'Monthly Report', + getDescription: () => 'Report Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => ({ projectedTrafficValue: 2000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([validOppty, reportOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('oppty-1'); + }); + + it('handles opportunities with null getTags()', async () => { + const opptyWithNullTags = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Opportunity', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => null, + getData: () => ({ projectedTrafficValue: 1000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([opptyWithNullTags]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('handles opportunities with null getData()', async () => { + const opptyWithNullData = { + getId: () => 'oppty-1', + getSiteId: () => SITE_ID, + getTitle: () => 'Opportunity', + getDescription: () => 'Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getTags: () => ['paid media'], + getData: () => null, + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([opptyWithNullData]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + mockSuggestion.allByOpportunityIdAndStatus.resolves([]); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: {}, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('handles context.data being null', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: null, + }); + + expect(response.status).to.equal(200); + }); + + it('uses month parameter when provided', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, month: 6 }, + }); + + expect(response.status).to.equal(200); + }); + + it('uses provided week when year and week are both provided', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 10 }, + }); + + expect(response.status).to.equal(200); + expect(mockLogger.warn).to.not.have.been.calledWith(sinon.match(/No year provided/)); + expect(mockLogger.warn).to.not.have.been.calledWith(sinon.match(/No week or month provided/)); + }); + + it('uses default PAGE_VIEW_THRESHOLD when PAID_DATA_THRESHOLD is not set', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '1500', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const envWithoutThreshold = { + ...mockEnv, + PAID_DATA_THRESHOLD: undefined, + }; + + const controller = TopPaidOpportunitiesController(mockContext, envWithoutThreshold); + const response = await controller.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + }); + + it('includes CWV opportunities with "needs improvement" score', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '3000', + overall_cwv_score: 'needs improvement', + lcp_score: 'needs improvement', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('cwv-1'); + expect(opportunities[0].pageViews).to.equal(3000); + }); + + it('excludes CWV URLs below pageview threshold even if poor score', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '500', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('excludes CWV URLs with good score even if high traffic', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 0, + }, + { + getData: () => ({ url: 'https://example.com/page2' }), + getRank: () => 1, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + path: '/page1', + pageviews: '5000', + p70_lcp: 1500, + p70_cls: 0.05, + p70_inp: 100, + }, + { + path: '/page2', + pageviews: '3000', + p70_lcp: 5000, + p70_cls: 0.3, + p70_inp: 600, + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + expect(opportunities).to.be.an('array').with.lengthOf(1); + expect(opportunities[0].opportunityId).to.equal('cwv-1'); + // Should only include page2 (poor score), not page1 (good score) + expect(opportunities[0].urls).to.deep.equal(['https://example.com/page2']); + expect(opportunities[0].urls).to.not.include('https://example.com/page1'); + }); + }); + + describe('URL normalization for matching', () => { + it('does not match partial URLs (exact match required)', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 2000, projectedTrafficValue: 8000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://www.bulk.com/de/products/pure-whey-protein-de' }), + getRank: () => 0, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + path: '/de/products/pure-whey-protein-de/bpb-wpc8-0000', + pageviews: '5000', + p70_lcp: 5000, + p70_cls: 0.3, + p70_inp: 600, + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + const opportunities = await response.json(); + // Should return 0 opportunities because URLs don't match exactly + expect(opportunities).to.be.an('array').with.lengthOf(0); + }); + + it('matches URLs with www prefix differences', async () => { + const cwvOppty = { + getId: () => 'cwv-1', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://www.example.com/page1' }), + getRank: () => 100, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + }); + + it('matches URLs with trailing slash differences', async () => { + const cwvOppty = { + getId: () => 'cwv-2', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://example.com/page1/' }), + getRank: () => 100, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + }); + + it('matches URLs with both www and trailing slash differences', async () => { + const cwvOppty = { + getId: () => 'cwv-3', + getSiteId: () => SITE_ID, + getTitle: () => 'CWV Opportunity', + getDescription: () => 'Fix CWV issues', + getType: () => 'cwv', + getStatus: () => 'NEW', + getTags: () => [], + getData: () => ({ projectedTrafficLost: 3000, projectedTrafficValue: 10000 }), + }; + + mockOpportunity.allBySiteIdAndStatus + .withArgs(SITE_ID, 'NEW').resolves([cwvOppty]) + .withArgs(SITE_ID, 'IN_PROGRESS').resolves([]); + + const mockSuggestions = [ + { + getData: () => ({ url: 'https://www.example.com/page1/' }), + getRank: () => 100, + }, + ]; + + mockSuggestion.allByOpportunityIdAndStatus.resolves(mockSuggestions); + + const mockAthenaClient = { + query: sandbox.stub().resolves([ + { + url: 'https://example.com/page1', + pageviews: '5000', + overall_cwv_score: 'poor', + lcp_score: 'poor', + inp_score: 'good', + cls_score: 'good', + }, + ]), + }; + + AWSAthenaClient.fromContext.returns(mockAthenaClient); + + const response = await topPaidController.getTopPaidOpportunities({ + params: { siteId: SITE_ID }, + data: { year: 2025, week: 1 }, + }); + + expect(response.status).to.equal(200); + }); + }); +}); diff --git a/test/dto/opportunity-summary.test.js b/test/dto/opportunity-summary.test.js new file mode 100644 index 000000000..1e44f2d7b --- /dev/null +++ b/test/dto/opportunity-summary.test.js @@ -0,0 +1,199 @@ +/* + * 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 { expect } from 'chai'; +import { OpportunitySummaryDto } from '../../src/dto/opportunity-summary.js'; + +describe('OpportunitySummaryDto', () => { + describe('toJSON', () => { + it('calculates pageViews from suggestion rank', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const suggestions = [ + { + getData: () => ({ url: 'https://example.com/page1' }), + getRank: () => 100, + }, + { + getData: () => ({ url: 'https://example.com/page2' }), + getRank: () => 200, + }, + ]; + + const result = OpportunitySummaryDto.toJSON(opportunity, suggestions); + + expect(result.pageViews).to.equal(300); + }); + + it('calculates pageViews from traffic_domain field', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const suggestions = [ + { + getData: () => ({ url: 'https://example.com/page1', traffic_domain: 150 }), + getRank: () => 0, + }, + ]; + + const result = OpportunitySummaryDto.toJSON(opportunity, suggestions); + + expect(result.pageViews).to.equal(150); + }); + + it('calculates pageViews from trafficDomain field', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const suggestions = [ + { + getData: () => ({ url: 'https://example.com/page1', trafficDomain: 250 }), + getRank: () => 0, + }, + ]; + + const result = OpportunitySummaryDto.toJSON(opportunity, suggestions); + + expect(result.pageViews).to.equal(250); + }); + + it('uses paidUrlsData when provided', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'cwv', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const paidUrlsData = { + urls: ['https://example.com/page1', 'https://example.com/page2'], + pageViews: 3000, + }; + + const result = OpportunitySummaryDto.toJSON(opportunity, [], paidUrlsData); + + expect(result.urls).to.deep.equal(['https://example.com/page1', 'https://example.com/page2']); + expect(result.pageViews).to.equal(3000); + }); + + it('extracts URLs from different suggestion fields', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const suggestions = [ + { + getData: () => ({ url_from: 'https://example.com/from' }), + getRank: () => 0, + }, + { + getData: () => ({ url_to: 'https://example.com/to' }), + getRank: () => 0, + }, + { + getData: () => ({ urlFrom: 'https://example.com/urlFrom' }), + getRank: () => 0, + }, + { + getData: () => ({ urlTo: 'https://example.com/urlTo' }), + getRank: () => 0, + }, + ]; + + const result = OpportunitySummaryDto.toJSON(opportunity, suggestions); + + expect(result.urls).to.include('https://example.com/from'); + expect(result.urls).to.include('https://example.com/to'); + expect(result.urls).to.include('https://example.com/urlFrom'); + expect(result.urls).to.include('https://example.com/urlTo'); + }); + + it('handles opportunity with null getData', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'broken-backlinks', + getStatus: () => 'NEW', + getData: () => null, + }; + + const result = OpportunitySummaryDto.toJSON(opportunity, []); + + expect(result.projectedTrafficLost).to.equal(0); + expect(result.projectedTrafficValue).to.equal(0); + }); + + it('defaults pageViews to 0 when paidUrlsData has no pageViews', () => { + const opportunity = { + getId: () => 'oppty-1', + getTitle: () => 'Test Opportunity', + getDescription: () => 'Test Description', + getType: () => 'cwv', + getStatus: () => 'NEW', + getData: () => ({ + projectedTrafficLost: 1000, + projectedTrafficValue: 5000, + }), + }; + + const paidUrlsData = { + urls: ['https://example.com/page1'], + }; + + const result = OpportunitySummaryDto.toJSON(opportunity, [], paidUrlsData); + + expect(result.pageViews).to.equal(0); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index d7d722c8e..909e4d257 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -237,6 +237,10 @@ describe('getRouteHandlers', () => { removeFix: () => null, }; + const mockTopPaidOpportunitiesController = { + getTopPaidOpportunities: sinon.stub(), + }; + const mockConsentBannerController = { getScreenshots: () => null, takeScreenshots: () => null, @@ -321,6 +325,7 @@ describe('getRouteHandlers', () => { mockScrapeController, mockScrapeJobController, mockPaidController, + mockTopPaidOpportunitiesController, mockTrafficController, mockFixesController, mockLlmoController, @@ -635,7 +640,7 @@ describe('getRouteHandlers', () => { expect(dynamicRoutes['DELETE /tools/api-keys/:id'].handler).to.equal(mockApiKeyController.deleteApiKey); expect(dynamicRoutes['GET /sites/:siteId/opportunities'].handler).to.equal(mockOpportunitiesController.getAllForSite); expect(dynamicRoutes['GET /sites/:siteId/opportunities'].paramNames).to.deep.equal(['siteId']); - expect(dynamicRoutes['GET /sites/:siteId/opportunities/top-paid'].handler).to.equal(mockOpportunitiesController.getTopPaidOpportunities); + expect(dynamicRoutes['GET /sites/:siteId/opportunities/top-paid'].handler).to.equal(mockTopPaidOpportunitiesController.getTopPaidOpportunities); expect(dynamicRoutes['GET /sites/:siteId/opportunities/top-paid'].paramNames).to.deep.equal(['siteId']); expect(dynamicRoutes['GET /sites/:siteId/opportunities/by-status/:status'].handler).to.equal(mockOpportunitiesController.getByStatus); expect(dynamicRoutes['GET /sites/:siteId/opportunities/by-status/:status'].paramNames).to.deep.equal(['siteId', 'status']);