diff --git a/package-lock.json b/package-lock.json index 23c53442..c3bc92c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -531,6 +531,7 @@ "resolved": "https://registry.npmjs.org/@adobe/helix-universal/-/helix-universal-5.4.0.tgz", "integrity": "sha512-3ZfFdjYtpv7RCgul9yyOBsRVsxLNapwt0YjASBhyzJGNjnPxrWDlqDtbpBdwAgA1Nuh9nmjzFDFu8CJWv6BMKw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@adobe/fetch": "4.2.3", "aws4": "1.13.2" @@ -4848,6 +4849,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.940.0.tgz", "integrity": "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -8826,6 +8828,7 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -8997,6 +9000,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -11354,6 +11358,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -11624,6 +11629,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11671,6 +11677,7 @@ "integrity": "sha512-F+LMD2IDIXuHxgpLJh3nkLj9+tSaEzoUWd+7fONGq5pe2169FUDjpEkOfEpoGLz1sbZni/69p07OsecNfAOpqA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12123,6 +12130,7 @@ "resolved": "https://registry.npmjs.org/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz", "integrity": "sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-sdk/types": "^3.4.1", "@smithy/service-error-classification": "^2.0.4", @@ -12724,6 +12732,7 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -14637,6 +14646,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -18184,6 +18194,7 @@ "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -18452,6 +18463,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -21216,6 +21228,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -22783,6 +22796,7 @@ "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -22793,6 +22807,7 @@ "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -23391,6 +23406,7 @@ "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -24087,6 +24103,7 @@ "integrity": "sha512-0x8TQFr8EjADhSME01u1ZK31yv2+bd6Z5NrBCHVM+n4qL1wFqbxftmeyi3bwlr49FbbzRfrqSFOpyHCOh/YmYA==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1", "@sinonjs/fake-timers": "^15.1.1", @@ -24604,6 +24621,7 @@ "integrity": "sha512-J72R4ltw0UBVUlEjTzI0gg2STOqlI9JBhQOL4Dxt7aJOnnSesy0qJDn4PYfMCafk9cWOaVg129Pesl5o+DIh0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -25479,6 +25497,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/tasks/opportunity-status-processor/audit-opportunity-map.js b/src/tasks/opportunity-status-processor/audit-opportunity-map.js deleted file mode 100644 index f937a17b..00000000 --- a/src/tasks/opportunity-status-processor/audit-opportunity-map.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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. - */ - -/** - * Maps audit types to their corresponding opportunity types - * This represents the superset of opportunities that can be generated from each audit - * - * Key: Audit type - * Value: Array of opportunity types that can be generated from this audit - */ -export const AUDIT_OPPORTUNITY_MAP = { - cwv: ['cwv'], - 'forms-opportunities': ['form-accessibility', 'forms-opportunities'], - 'meta-tags': ['meta-tags'], - 'experimentation-opportunities': ['high-organic-low-ctr'], - 'broken-backlinks': ['broken-backlinks'], - 'broken-internal-links': ['broken-internal-links'], - sitemap: ['sitemap'], - 'alt-text': ['alt-text'], - accessibility: ['accessibility'], -}; - -/** - * Gets all opportunity types for a given audit type - * @param {string} auditType - The audit type - * @returns {Array} Array of opportunity types, or empty array if audit type not found - */ -export function getOpportunitiesForAudit(auditType) { - return AUDIT_OPPORTUNITY_MAP[auditType] || []; -} - -/** - * Gets all audit types that can generate a specific opportunity type - * @param {string} opportunityType - The opportunity type - * @returns {Array} Array of audit types that can generate this opportunity - */ -export function getAuditsForOpportunity(opportunityType) { - return Object.entries(AUDIT_OPPORTUNITY_MAP) - .filter(([, opportunities]) => opportunities.includes(opportunityType)) - .map(([auditType]) => auditType); -} - -/** - * Gets all unique opportunity types across all audits - * @returns {Array} Array of all unique opportunity types - */ -export function getAllOpportunityTypes() { - const allOpportunities = Object.values(AUDIT_OPPORTUNITY_MAP).flat(); - return [...new Set(allOpportunities)]; -} - -/** - * Gets all audit types defined in the map - * @returns {Array} Array of all audit types - */ -export function getAllAuditTypes() { - return Object.keys(AUDIT_OPPORTUNITY_MAP); -} diff --git a/src/tasks/opportunity-status-processor/handler.js b/src/tasks/opportunity-status-processor/handler.js index 659958da..d852d29e 100644 --- a/src/tasks/opportunity-status-processor/handler.js +++ b/src/tasks/opportunity-status-processor/handler.js @@ -14,12 +14,17 @@ import { ok } from '@adobe/spacecat-shared-http-utils'; import RUMAPIClient from '@adobe/spacecat-shared-rum-api-client'; import GoogleClient from '@adobe/spacecat-shared-google-client'; import { ScrapeClient } from '@adobe/spacecat-shared-scrape-client'; -import { resolveCanonicalUrl } from '@adobe/spacecat-shared-utils'; +import { + resolveCanonicalUrl, + getAuditsForOpportunity, + getOpportunityTitle, + OPPORTUNITY_DEPENDENCY_MAP, + getOpportunitiesForAudit, + computeAuditCompletion, +} from '@adobe/spacecat-shared-utils'; import { getAuditStatus } from '../../utils/cloudwatch-utils.js'; import { checkAndAlertBotProtection } from '../../utils/bot-detection.js'; import { say } from '../../utils/slack-utils.js'; -import { getOpportunitiesForAudit } from './audit-opportunity-map.js'; -import { OPPORTUNITY_DEPENDENCY_MAP } from './opportunity-dependency-map.js'; const TASK_TYPE = 'opportunity-status-processor'; @@ -94,33 +99,6 @@ async function isGSCConfigured(siteUrl, context) { } } -/** - * Gets the opportunity title from the opportunity type - * @param {string} opportunityType - The opportunity type - * @returns {string} The opportunity title - */ -function getOpportunityTitle(opportunityType) { - const opportunityTitles = { - cwv: 'Core Web Vitals', - 'meta-tags': 'SEO Meta Tags', - 'broken-backlinks': 'Broken Backlinks', - 'broken-internal-links': 'Broken Internal Links', - 'alt-text': 'Alt Text', - sitemap: 'Sitemap', - }; - - // Check if the opportunity type exists in our map - if (opportunityTitles[opportunityType]) { - return opportunityTitles[opportunityType]; - } - - // Convert kebab-case to Title Case (e.g., "first-second" -> "First Second") - return opportunityType - .split('-') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); -} - /** * Filters scrape jobs to only include those created after onboardStartTime * This ensures we only check jobs from the CURRENT onboarding session, @@ -240,12 +218,6 @@ async function isScrapingAvailable(baseUrl, context, onboardStartTime) { } } -/** - * Checks scrape results for bot protection blocking - * @param {Array} scrapeResults - Array of scrape URL results - * @param {object} context - The context object with log - * @returns {object|null} Bot protection details if detected, null otherwise - */ /** * Analyzes missing opportunities and determines the root cause * @param {Array} missingOpportunities - Array of missing opportunity types @@ -558,6 +530,23 @@ export async function runOpportunityStatusProcessor(message, context) { statusMessages.push(`GSC ${gscStatus}`); statusMessages.push(`Scraping ${scrapingStatus}`); + // Determine which audits are still pending so opportunity statuses can reflect + // in-progress state (⏳) rather than showing stale data as ✅/❌. + // Only meaningful when we have an onboardStartTime anchor to compare against. + let pendingAuditTypes = []; + if (auditTypes && auditTypes.length > 0 && onboardStartTime) { + try { + const { Audit } = dataAccess; + const latestAudits = await Audit.allLatestForSite(siteId); + const completion = computeAuditCompletion(auditTypes, onboardStartTime, latestAudits); + pendingAuditTypes = completion.pendingAuditTypes; + } catch (auditErr) { + log.warn(`Could not check audit completion from DB for site ${siteId}: ${auditErr.message}`); + // Conservative fallback: mark all as pending so disclaimer is always shown on error + pendingAuditTypes = [...auditTypes]; + } + } + // Process opportunities by type to avoid duplicates // Only process opportunities that are expected based on the profile's audit types const processedTypes = new Set(); @@ -586,23 +575,28 @@ export async function runOpportunityStatusProcessor(message, context) { } processedTypes.add(opportunityType); - // eslint-disable-next-line no-await-in-loop - const suggestions = await opportunity.getSuggestions(); - const opportunityTitle = getOpportunityTitle(opportunityType); - const hasSuggestions = suggestions && suggestions.length > 0; - const status = hasSuggestions ? ':white_check_mark:' : ':x:'; - statusMessages.push(`${opportunityTitle} ${status}`); - - // Track failed opportunities (no suggestions) - if (!hasSuggestions) { - // Use informational message for opportunities with zero suggestions - const reason = 'Audit executed successfully, opportunity added, but found no suggestions'; - - failedOpportunities.push({ - title: opportunityTitle, - reason, - }); + + // If the source audit is still running, show ⏳ instead of stale ✅/❌ + const sourceAuditIsPending = getAuditsForOpportunity(opportunityType) + .some((auditType) => pendingAuditTypes.includes(auditType)); + + if (sourceAuditIsPending) { + statusMessages.push(`${opportunityTitle} :hourglass_flowing_sand:`); + } else { + // eslint-disable-next-line no-await-in-loop + const suggestions = await opportunity.getSuggestions(); + const hasSuggestions = suggestions && suggestions.length > 0; + const status = hasSuggestions ? ':white_check_mark:' : ':x:'; + statusMessages.push(`${opportunityTitle} ${status}`); + + // Track failed opportunities (no suggestions) + if (!hasSuggestions) { + failedOpportunities.push({ + title: opportunityTitle, + reason: 'Audit executed successfully, opportunity added, but found no suggestions', + }); + } } } @@ -680,6 +674,39 @@ export async function runOpportunityStatusProcessor(message, context) { } else { await say(env, log, slackContext, 'No audit errors found'); } + + // Audit completion disclaimer — reuse pendingAuditTypes already computed above. + // Only list audit types that have known opportunity mappings; infrastructure audits + // (auto-suggest, auto-fix, scrape, etc.) are not shown since they don't affect + // the displayed opportunity statuses. + if (auditTypes.length > 0) { + const isRecheck = taskContext?.isRecheck === true; + const relevantPendingTypes = pendingAuditTypes.filter( + (t) => getOpportunitiesForAudit(t).length > 0, + ); + if (relevantPendingTypes.length > 0) { + const pendingOpportunityNames = relevantPendingTypes + .flatMap((t) => getOpportunitiesForAudit(t)) + .map(getOpportunityTitle); + const pendingList = [...new Set(pendingOpportunityNames)].join(', '); + await say( + env, + log, + slackContext, + `:warning: *Heads-up:* The following audit${relevantPendingTypes.length > 1 ? 's' : ''} ` + + `may still be in progress: *${pendingList}*.\n` + + 'The statuses above reflect data available at this moment and may be incomplete. ' + + `Run \`onboard status ${siteUrl}\` to re-check once all audits have completed.`, + ); + } else if (isRecheck && onboardStartTime) { + await say( + env, + log, + slackContext, + ':white_check_mark: All audits have completed. The statuses above are up to date.', + ); + } + } } log.info(`Processed ${opportunities.length} opportunities for site ${siteId}`); diff --git a/src/tasks/opportunity-status-processor/opportunity-dependency-map.js b/src/tasks/opportunity-status-processor/opportunity-dependency-map.js deleted file mode 100644 index efd03bd8..00000000 --- a/src/tasks/opportunity-status-processor/opportunity-dependency-map.js +++ /dev/null @@ -1,38 +0,0 @@ -/* - * 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. - */ - -/** - * Maps opportunity types to their required dependencies - * Dependencies can be data sources (RUM, AHREFSImport, GSC, scraping) - * - * Key: Opportunity type - * Value: Array of required dependencies for this opportunity to be generated - */ -export const OPPORTUNITY_DEPENDENCY_MAP = { - cwv: ['RUM'], - 'high-organic-low-ctr': ['RUM'], - 'broken-internal-links': ['RUM', 'AHREFSImport'], - 'meta-tags': ['AHREFSImport', 'scraping'], // meta-tags audit uses scraping - 'broken-backlinks': ['AHREFSImport', 'scraping'], // broken-backlinks audit uses scraping - 'alt-text': ['AHREFSImport', 'scraping'], // alt-text audit uses scraping - 'form-accessibility': ['RUM', 'scraping'], // forms audit uses scraping - 'forms-opportunities': ['RUM', 'scraping'], // forms audit uses scraping -}; - -/** - * Gets all dependencies for a given opportunity type - * @param {string} opportunityType - The opportunity type - * @returns {Array} Array of dependency names, or empty array if no dependencies - */ -export function getDependenciesForOpportunity(opportunityType) { - return OPPORTUNITY_DEPENDENCY_MAP[opportunityType] || []; -} diff --git a/test/tasks/opportunity-status-processor/audit-opportunity-map.test.js b/test/tasks/opportunity-status-processor/audit-opportunity-map.test.js deleted file mode 100644 index c1de92c4..00000000 --- a/test/tasks/opportunity-status-processor/audit-opportunity-map.test.js +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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 } from 'chai'; -import { - AUDIT_OPPORTUNITY_MAP, - getOpportunitiesForAudit, - getAllAuditTypes, - getAuditsForOpportunity, - getAllOpportunityTypes, -} from '../../../src/tasks/opportunity-status-processor/audit-opportunity-map.js'; - -describe('Audit Opportunity Map', () => { - describe('AUDIT_OPPORTUNITY_MAP', () => { - it('should contain all expected audit types', () => { - expect(AUDIT_OPPORTUNITY_MAP).to.be.an('object'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('cwv'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('forms-opportunities'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('meta-tags'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('experimentation-opportunities'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('broken-backlinks'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('broken-internal-links'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('sitemap'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('alt-text'); - expect(AUDIT_OPPORTUNITY_MAP).to.have.property('accessibility'); - }); - - it('should map audits to opportunities correctly', () => { - expect(AUDIT_OPPORTUNITY_MAP.cwv).to.deep.equal(['cwv']); - expect(AUDIT_OPPORTUNITY_MAP['forms-opportunities']).to.deep.equal([ - 'form-accessibility', - 'forms-opportunities', - ]); - expect(AUDIT_OPPORTUNITY_MAP['meta-tags']).to.deep.equal(['meta-tags']); - expect(AUDIT_OPPORTUNITY_MAP['experimentation-opportunities']).to.deep.equal([ - 'high-organic-low-ctr', - ]); - }); - }); - - describe('getOpportunitiesForAudit', () => { - it('should return opportunities for known audit type', () => { - const opportunities = getOpportunitiesForAudit('cwv'); - expect(opportunities).to.be.an('array'); - expect(opportunities).to.deep.equal(['cwv']); - }); - - it('should return multiple opportunities for forms audit', () => { - const opportunities = getOpportunitiesForAudit('forms-opportunities'); - expect(opportunities).to.be.an('array'); - expect(opportunities).to.have.lengthOf(2); - expect(opportunities).to.include('form-accessibility'); - expect(opportunities).to.include('forms-opportunities'); - }); - - it('should return empty array for unknown audit type', () => { - const opportunities = getOpportunitiesForAudit('unknown-audit'); - expect(opportunities).to.be.an('array'); - expect(opportunities).to.have.lengthOf(0); - }); - - it('should return empty array for null audit type', () => { - const opportunities = getOpportunitiesForAudit(null); - expect(opportunities).to.be.an('array'); - expect(opportunities).to.have.lengthOf(0); - }); - - it('should return empty array for undefined audit type', () => { - const opportunities = getOpportunitiesForAudit(undefined); - expect(opportunities).to.be.an('array'); - expect(opportunities).to.have.lengthOf(0); - }); - - it('should handle all defined audit types', () => { - const auditTypes = [ - 'cwv', - 'forms-opportunities', - 'meta-tags', - 'experimentation-opportunities', - 'broken-backlinks', - 'broken-internal-links', - 'sitemap', - 'alt-text', - 'accessibility', - ]; - - auditTypes.forEach((auditType) => { - const opportunities = getOpportunitiesForAudit(auditType); - expect(opportunities).to.be.an('array'); - expect(opportunities.length).to.be.greaterThan(0); - }); - }); - }); - - describe('getAllAuditTypes', () => { - it('should return all audit types', () => { - const auditTypes = getAllAuditTypes(); - expect(auditTypes).to.be.an('array'); - expect(auditTypes).to.have.lengthOf(9); - }); - - it('should include all expected audit types', () => { - const auditTypes = getAllAuditTypes(); - expect(auditTypes).to.include('cwv'); - expect(auditTypes).to.include('forms-opportunities'); - expect(auditTypes).to.include('meta-tags'); - expect(auditTypes).to.include('experimentation-opportunities'); - expect(auditTypes).to.include('broken-backlinks'); - expect(auditTypes).to.include('broken-internal-links'); - expect(auditTypes).to.include('sitemap'); - expect(auditTypes).to.include('alt-text'); - expect(auditTypes).to.include('accessibility'); - }); - }); -}); - -describe('Audit Opportunity Map - Additional Coverage', () => { - describe('getAuditsForOpportunity', () => { - it('should return audits that generate cwv opportunity', () => { - const audits = getAuditsForOpportunity('cwv'); - expect(audits).to.be.an('array'); - expect(audits).to.deep.equal(['cwv']); - }); - - it('should return multiple audits for form-accessibility opportunity', () => { - const audits = getAuditsForOpportunity('form-accessibility'); - expect(audits).to.be.an('array'); - expect(audits).to.include('forms-opportunities'); - }); - - it('should return empty array for unknown opportunity', () => { - const audits = getAuditsForOpportunity('unknown-opportunity'); - expect(audits).to.be.an('array'); - expect(audits).to.have.lengthOf(0); - }); - - it('should return audits for high-organic-low-ctr opportunity', () => { - const audits = getAuditsForOpportunity('high-organic-low-ctr'); - expect(audits).to.be.an('array'); - expect(audits).to.deep.equal(['experimentation-opportunities']); - }); - }); - - describe('getAllOpportunityTypes', () => { - it('should return all unique opportunity types', () => { - const opportunities = getAllOpportunityTypes(); - expect(opportunities).to.be.an('array'); - expect(opportunities.length).to.be.greaterThan(0); - }); - - it('should not contain duplicates', () => { - const opportunities = getAllOpportunityTypes(); - const uniqueOpportunities = [...new Set(opportunities)]; - expect(opportunities).to.have.lengthOf(uniqueOpportunities.length); - }); - - it('should include all defined opportunity types', () => { - const opportunities = getAllOpportunityTypes(); - expect(opportunities).to.include('cwv'); - expect(opportunities).to.include('meta-tags'); - expect(opportunities).to.include('broken-backlinks'); - expect(opportunities).to.include('high-organic-low-ctr'); - }); - }); -}); diff --git a/test/tasks/opportunity-status-processor/opportunity-dependency-map.test.js b/test/tasks/opportunity-status-processor/opportunity-dependency-map.test.js deleted file mode 100644 index f86cd6ed..00000000 --- a/test/tasks/opportunity-status-processor/opportunity-dependency-map.test.js +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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 } from 'chai'; -import { - OPPORTUNITY_DEPENDENCY_MAP, - getDependenciesForOpportunity, -} from '../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'; - -describe('Opportunity Dependency Map', () => { - describe('OPPORTUNITY_DEPENDENCY_MAP', () => { - it('should contain all expected opportunity types', () => { - expect(OPPORTUNITY_DEPENDENCY_MAP).to.be.an('object'); - expect(OPPORTUNITY_DEPENDENCY_MAP).to.have.property('cwv'); - expect(OPPORTUNITY_DEPENDENCY_MAP).to.have.property('high-organic-low-ctr'); - expect(OPPORTUNITY_DEPENDENCY_MAP).to.have.property('broken-internal-links'); - expect(OPPORTUNITY_DEPENDENCY_MAP).to.have.property('meta-tags'); - expect(OPPORTUNITY_DEPENDENCY_MAP).to.have.property('broken-backlinks'); - }); - - it('should map opportunities to dependencies correctly', () => { - expect(OPPORTUNITY_DEPENDENCY_MAP.cwv).to.deep.equal(['RUM']); - expect(OPPORTUNITY_DEPENDENCY_MAP['high-organic-low-ctr']).to.deep.equal(['RUM']); - expect(OPPORTUNITY_DEPENDENCY_MAP['broken-internal-links']).to.deep.equal(['RUM', 'AHREFSImport']); - expect(OPPORTUNITY_DEPENDENCY_MAP['meta-tags']).to.deep.equal(['AHREFSImport', 'scraping']); - expect(OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']).to.deep.equal(['AHREFSImport', 'scraping']); - expect(OPPORTUNITY_DEPENDENCY_MAP['alt-text']).to.deep.equal(['AHREFSImport', 'scraping']); - expect(OPPORTUNITY_DEPENDENCY_MAP['form-accessibility']).to.deep.equal(['RUM', 'scraping']); - expect(OPPORTUNITY_DEPENDENCY_MAP['forms-opportunities']).to.deep.equal(['RUM', 'scraping']); - }); - }); - - describe('getDependenciesForOpportunity', () => { - it('should return dependencies for known opportunity type', () => { - const dependencies = getDependenciesForOpportunity('cwv'); - expect(dependencies).to.be.an('array'); - expect(dependencies).to.deep.equal(['RUM']); - }); - - it('should return multiple dependencies for broken-internal-links', () => { - const dependencies = getDependenciesForOpportunity('broken-internal-links'); - expect(dependencies).to.be.an('array'); - expect(dependencies).to.have.lengthOf(2); - expect(dependencies).to.include('RUM'); - expect(dependencies).to.include('AHREFSImport'); - }); - - it('should return empty array for opportunity with no dependencies', () => { - const dependencies = getDependenciesForOpportunity('accessibility'); - expect(dependencies).to.be.an('array'); - expect(dependencies).to.have.lengthOf(0); - }); - - it('should return empty array for unknown opportunity type', () => { - const dependencies = getDependenciesForOpportunity('unknown-opportunity'); - expect(dependencies).to.be.an('array'); - expect(dependencies).to.have.lengthOf(0); - }); - - it('should return empty array for null opportunity type', () => { - const dependencies = getDependenciesForOpportunity(null); - expect(dependencies).to.be.an('array'); - expect(dependencies).to.have.lengthOf(0); - }); - }); -}); diff --git a/test/tasks/opportunity-status-processor/opportunity-status-processor.test.js b/test/tasks/opportunity-status-processor/opportunity-status-processor.test.js index 8434c86a..692625b1 100644 --- a/test/tasks/opportunity-status-processor/opportunity-status-processor.test.js +++ b/test/tasks/opportunity-status-processor/opportunity-status-processor.test.js @@ -75,6 +75,9 @@ describe('Opportunity Status Processor', () => { SiteTopPage: { allBySiteIdAndSourceAndGeo: sandbox.stub().resolves([]), }, + Audit: { + allLatestForSite: sandbox.stub().resolves([]), + }, }) .withOverrides({ s3Client: mockS3Client, @@ -2032,7 +2035,7 @@ describe('Opportunity Status Processor', () => { it('should cover scraping dependency when checked (lines 330-331, 454-457, 595-596, 628-638)', async () => { // Temporarily modify OPPORTUNITY_DEPENDENCY_MAP to include a scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalScraping = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks'] = ['scraping']; @@ -2066,7 +2069,7 @@ describe('Opportunity Status Processor', () => { it('should cover GSC dependency when checked (lines 450-451, 592-593, 646-647)', async () => { // Temporarily modify OPPORTUNITY_DEPENDENCY_MAP to include GSC - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalCwv = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP.cwv; dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP.cwv = ['GSC']; @@ -2113,7 +2116,7 @@ describe('Opportunity Status Processor', () => { const scrapeClientStub = sinon.stub(ScrapeClient, 'createFrom').returns(mockScrapeClient); // Temporarily add scraping dependency to trigger scraping check - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2140,7 +2143,7 @@ describe('Opportunity Status Processor', () => { it('should handle no scrape jobs found (line 149-150)', async () => { // Temporarily add scraping dependency to trigger scraping check - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const mockScrapeClient = { @@ -2193,7 +2196,7 @@ describe('Opportunity Status Processor', () => { const { ScrapeClient } = scrapeModule; // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2246,7 +2249,7 @@ describe('Opportunity Status Processor', () => { const { ScrapeClient } = scrapeModule; // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2299,7 +2302,7 @@ describe('Opportunity Status Processor', () => { const { ScrapeClient } = scrapeModule; // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2366,7 +2369,7 @@ describe('Opportunity Status Processor', () => { const { ScrapeClient } = scrapeModule; // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2427,7 +2430,7 @@ describe('Opportunity Status Processor', () => { const { ScrapeClient } = scrapeModule; // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2491,13 +2494,22 @@ describe('Opportunity Status Processor', () => { message.siteUrl = 'https://example.com'; message.taskContext.auditTypes = ['cwv', 'broken-backlinks']; - message.taskContext.onboardStartTime = Date.now() - 3600000; + const onboardStartTime = Date.now() - 3600000; + message.taskContext.onboardStartTime = onboardStartTime; message.taskContext.slackContext = { channelId: 'test-channel', threadTs: 'test-thread', }; context.env.AWS_REGION = 'us-east-1'; + // Provide fresh audit records so audits are "complete" (not pending) + context.dataAccess.Audit = { + allLatestForSite: sinon.stub().resolves([ + { getAuditType: () => 'cwv', getAuditedAt: () => new Date(onboardStartTime + 1000).toISOString() }, + { getAuditType: () => 'broken-backlinks', getAuditedAt: () => new Date(onboardStartTime + 1000).toISOString() }, + ]), + }; + // Mock TWO opportunities with no suggestions to ensure loop executes multiple times const mockOpportunity1 = { getType: () => 'cwv', @@ -2549,7 +2561,7 @@ describe('Opportunity Status Processor', () => { const scrapeClientStub = sinon.stub(ScrapeClient, 'createFrom').returns(mockScrapeClient); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -2621,7 +2633,7 @@ describe('Opportunity Status Processor', () => { }); it('should check GSC listSites when GSC is needed (lines 84-86)', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalCwv = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP.cwv; const GoogleClientModule = await import('@adobe/spacecat-shared-google-client'); @@ -2739,7 +2751,7 @@ describe('Opportunity Status Processor', () => { it('should detect bot protection from database and send Slack alert', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; // Make broken-backlinks require scraping @@ -2849,7 +2861,7 @@ describe('Opportunity Status Processor', () => { it('should use dev IPs when AWS_REGION is not us-east', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; // Make broken-backlinks require scraping @@ -2946,7 +2958,7 @@ describe('Opportunity Status Processor', () => { it('should not send bot protection alert when no bot protection logs found', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3015,7 +3027,7 @@ describe('Opportunity Status Processor', () => { it('should handle partial bot protection blocking', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -3115,7 +3127,7 @@ describe('Opportunity Status Processor', () => { it('should not send alert when no bot protection detected', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3202,7 +3214,7 @@ describe('Opportunity Status Processor', () => { }); it('should handle scrapes without bot protection metadata', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3262,7 +3274,7 @@ describe('Opportunity Status Processor', () => { }); it('should not check bot protection when slackContext is missing', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3318,7 +3330,7 @@ describe('Opportunity Status Processor', () => { }); it('should not send alert when all S3 files exist (no missing files)', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3414,7 +3426,7 @@ describe('Opportunity Status Processor', () => { }); it('should use fallback stats when no scrape job ID is available', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; const scrapeModule = await import('@adobe/spacecat-shared-scrape-client'); @@ -3507,7 +3519,7 @@ describe('Opportunity Status Processor', () => { }); it('should handle bot protection without job ID (fallback stats)', async () => { - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks'] = ['scraping']; @@ -3586,7 +3598,7 @@ describe('Opportunity Status Processor', () => { it('should detect bot protection when abortInfo is present', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -3659,7 +3671,7 @@ describe('Opportunity Status Processor', () => { it('should not detect bot protection when abortInfo is null', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -3717,7 +3729,7 @@ describe('Opportunity Status Processor', () => { it('should not detect bot protection when job is complete with no abortInfo', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -3776,7 +3788,7 @@ describe('Opportunity Status Processor', () => { it('should handle errors gracefully when checking bot protection', async function () { this.timeout(5000); - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalBrokenBacklinks = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['broken-backlinks']; try { @@ -3854,7 +3866,7 @@ describe('Opportunity Status Processor', () => { }); // Temporarily add scraping dependency - const dependencyMapModule = await import('../../../src/tasks/opportunity-status-processor/opportunity-dependency-map.js'); + const dependencyMapModule = await import('@adobe/spacecat-shared-utils'); const originalAltText = dependencyMapModule.OPPORTUNITY_DEPENDENCY_MAP['alt-text']; try { @@ -4002,6 +4014,8 @@ describe('Opportunity Status Processor', () => { const handler = await esmock('../../../src/tasks/opportunity-status-processor/handler.js', { '@adobe/spacecat-shared-utils': { resolveCanonicalUrl: sinon.stub().resolves('https://example.com'), + getOpportunitiesForAudit: mockGetOpportunitiesForAudit, + AUDIT_OPPORTUNITY_MAP: {}, }, '../../../src/utils/bot-detection.js': { checkAndAlertBotProtection: sinon.stub().resolves(null), @@ -4009,10 +4023,6 @@ describe('Opportunity Status Processor', () => { '../../../src/utils/cloudwatch-utils.js': { getAuditStatus: sinon.stub().resolves({ executed: true, failureReason: null }), }, - '../../../src/tasks/opportunity-status-processor/audit-opportunity-map.js': { - getOpportunitiesForAudit: mockGetOpportunitiesForAudit, - AUDIT_OPPORTUNITY_MAP: {}, - }, }); // Create a scenario where an opportunity type exists but no audits in auditTypes @@ -4122,4 +4132,289 @@ describe('Opportunity Status Processor', () => { ); }); }); + + describe('Audit Completion Disclaimer', () => { + let sayStub; + let disclaimerHandler; + + beforeEach(async () => { + sayStub = sinon.stub().resolves(); + const esmockLocal = (await import('esmock')).default; + disclaimerHandler = await esmockLocal('../../../src/tasks/opportunity-status-processor/handler.js', { + '../../../src/utils/slack-utils.js': { say: sayStub }, + '@adobe/spacecat-shared-utils': { + resolveCanonicalUrl: sinon.stub().callsFake(async (url) => url), + }, + '@adobe/spacecat-shared-rum-api-client': { + default: { + createFrom: sinon.stub().returns({ retrieveDomainkey: sinon.stub().rejects() }), + }, + }, + '@adobe/spacecat-shared-google-client': { + default: { createFrom: sinon.stub().rejects() }, + }, + '@adobe/spacecat-shared-scrape-client': { + ScrapeClient: { + createFrom: sinon.stub().returns({ getScrapeJobsByBaseURL: sinon.stub().resolves([]) }), + }, + }, + '../../../src/utils/cloudwatch-utils.js': { + getAuditStatus: sinon.stub().resolves({ executed: true, failureReason: null }), + }, + '../../../src/utils/bot-detection.js': { + checkAndAlertBotProtection: sinon.stub().resolves(null), + }, + }); + }); + + function makeDisclaimerMessage(onboardStartTime, auditTypes, isRecheck = false) { + return { + siteId: 'test-site-id', + siteUrl: 'https://example.com', + organizationId: 'test-org-id', + taskContext: { + auditTypes, + slackContext: { channelId: 'test-channel', threadTs: 'test-thread' }, + onboardStartTime, + isRecheck, + }, + }; + } + + function makeDisclaimerContext(auditRecords) { + return { + ...context, + dataAccess: { + Site: { + findById: sinon.stub().resolves({ + getOpportunities: sinon.stub().resolves([]), + getBaseURL: sinon.stub().returns('https://example.com'), + }), + }, + SiteTopPage: { + allBySiteIdAndSourceAndGeo: sinon.stub().resolves([]), + }, + Audit: { + allLatestForSite: sinon.stub().resolves(auditRecords), + }, + }, + }; + } + + it('sends pending audit warning when audit record predates onboardStartTime', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv']); + const staleAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(onboardStartTime - 1000).toISOString(), + }; + const testContext = makeDisclaimerContext([staleAudit]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match(/may still be in progress.*Core Web Vitals/), + ); + }); + + it('sends pending warning when no audit record exists for an expected type', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv']); + // No audit records at all → cwv is pending + const testContext = makeDisclaimerContext([]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match(/may still be in progress/), + ); + }); + + it('sends "all complete" confirmation when isRecheck=true and all audits have run', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv'], true); + const freshAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(onboardStartTime + 1000).toISOString(), + }; + const testContext = makeDisclaimerContext([freshAudit]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + ':white_check_mark: All audits have completed. The statuses above are up to date.', + ); + }); + + it('sends no disclaimer when all audits complete and isRecheck=false', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv'], false); + const freshAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(onboardStartTime + 1000).toISOString(), + }; + const testContext = makeDisclaimerContext([freshAudit]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + const disclaimerCalls = sayStub.args.map((a) => a[3]).filter(Boolean); + expect(disclaimerCalls.some((m) => m.includes('may still be in progress'))).to.be.false; + expect(disclaimerCalls.some((m) => m.includes('All audits have completed'))).to.be.false; + }); + + it('skips disclaimer and pending check when auditTypes is empty', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, []); + const testContext = makeDisclaimerContext([]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + // No audit completion DB call when auditTypes is empty + expect(testContext.dataAccess.Audit.allLatestForSite).to.not.have.been.called; + const disclaimerCalls = sayStub.args.map((a) => a[3]).filter(Boolean); + expect(disclaimerCalls.some((m) => m.includes('may still be in progress'))).to.be.false; + }); + + it('shows hourglass in opportunity status when source audit is pending', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv']); + const staleAudit = { + getAuditType: () => 'cwv', + getAuditedAt: () => new Date(onboardStartTime - 1000).toISOString(), + }; + const cwvOpp = { + getType: sinon.stub().returns('cwv'), + getSuggestions: sinon.stub().resolves([{ id: 'sug-1' }]), + }; + const testContext = { + ...context, + dataAccess: { + Site: { + findById: sinon.stub().resolves({ + getOpportunities: sinon.stub().resolves([cwvOpp]), + getBaseURL: sinon.stub().returns('https://example.com'), + }), + }, + SiteTopPage: { + allBySiteIdAndSourceAndGeo: sinon.stub().resolves([]), + }, + Audit: { + allLatestForSite: sinon.stub().resolves([staleAudit]), + }, + }, + }; + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + // cwv audit is pending → opportunity shows ⏳, getSuggestions is NOT called + expect(cwvOpp.getSuggestions).to.not.have.been.called; + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match(/Core Web Vitals :hourglass_flowing_sand:/), + ); + }); + + it('falls back conservatively when Audit.allLatestForSite throws in disclaimer check', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['cwv']); + const testContext = { + ...context, + dataAccess: { + Site: { + findById: sinon.stub().resolves({ + getOpportunities: sinon.stub().resolves([]), + getBaseURL: sinon.stub().returns('https://example.com'), + }), + }, + SiteTopPage: { + allBySiteIdAndSourceAndGeo: sinon.stub().resolves([]), + }, + Audit: { + allLatestForSite: sinon.stub().rejects(new Error('DB unavailable')), + }, + }, + }; + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + expect(testContext.log.warn).to.have.been.calledWith( + sinon.match(/Could not check audit completion from DB for site test-site-id: DB unavailable/), + ); + // Conservative fallback: pending warning sent + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match(/may still be in progress/), + ); + }); + + it('includes siteUrl in the "run onboard status" hint within pending warning', async () => { + const onboardStartTime = Date.now() - 3600000; + const testMessage = makeDisclaimerMessage(onboardStartTime, ['broken-backlinks']); + const staleAudit = { + getAuditType: () => 'broken-backlinks', + getAuditedAt: () => new Date(onboardStartTime - 500).toISOString(), + }; + const testContext = makeDisclaimerContext([staleAudit]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + expect(sayStub).to.have.been.calledWith( + sinon.match.any, + sinon.match.any, + sinon.match.any, + sinon.match(/onboard status https:\/\/example\.com/), + ); + }); + + it('excludes infrastructure audit types not in AUDIT_OPPORTUNITY_MAP from disclaimer', async () => { + const onboardStartTime = Date.now() - 3600000; + // cwv is in the map; scrape-top-pages is not + const testMessage = makeDisclaimerMessage( + onboardStartTime, + ['cwv', 'scrape-top-pages'], + ); + const staleAudits = [ + { getAuditType: () => 'cwv', getAuditedAt: () => new Date(onboardStartTime - 500).toISOString() }, + { getAuditType: () => 'scrape-top-pages', getAuditedAt: () => new Date(onboardStartTime - 500).toISOString() }, + ]; + const testContext = makeDisclaimerContext(staleAudits); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + const calls = sayStub.args.map((a) => a[3]).filter(Boolean); + const disclaimer = calls.find((m) => m.includes('may still be in progress')); + expect(disclaimer).to.exist; + expect(disclaimer).to.include('Core Web Vitals'); + expect(disclaimer).to.not.include('Scrape Top Pages'); + }); + + it('does not send "all complete" when isRecheck=true but onboardStartTime is absent (legacy site)', async () => { + // onboardStartTime is undefined — audit completion check is skipped entirely. + // pendingAuditTypes stays [] but we must NOT send "All audits have completed" + // because no check was performed. + const testMessage = makeDisclaimerMessage(undefined, ['cwv'], true); + const testContext = makeDisclaimerContext([]); + + await disclaimerHandler.runOpportunityStatusProcessor(testMessage, testContext); + + // DB should not have been queried (no onboardStartTime to compare against) + expect(testContext.dataAccess.Audit.allLatestForSite).to.not.have.been.called; + const disclaimerCalls = sayStub.args.map((a) => a[3]).filter(Boolean); + expect(disclaimerCalls.some((m) => m.includes('All audits have completed'))).to.be.false; + expect(disclaimerCalls.some((m) => m.includes('may still be in progress'))).to.be.false; + }); + }); });