diff --git a/src/accessibility/auto-optimization-handlers/codefix-handler.js b/src/accessibility/auto-optimization-handlers/codefix-handler.js new file mode 100644 index 000000000..1e3ba1519 --- /dev/null +++ b/src/accessibility/auto-optimization-handlers/codefix-handler.js @@ -0,0 +1,254 @@ +/* + * 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 { createHash } from 'crypto'; +import { + ok, badRequest, notFound, internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { isNonEmptyArray } from '@adobe/spacecat-shared-utils'; +import { getObjectFromKey } from '../../utils/s3-utils.js'; + +/** + * Generates a hash for the given URL and source combination. + * @param {string} url - The URL to hash + * @param {string} source - The source to hash + * @returns {string} - The generated hash (first 16 characters of MD5) + */ +function generateUrlSourceHash(url, source) { + const combined = `${url}_${source}`; + return createHash('md5').update(combined).digest('hex').substring(0, 16); +} + +/** + * Reads code change report from S3 bucket + * @param {Object} s3Client - The S3 client instance + * @param {string} bucketName - The S3 bucket name + * @param {string} siteId - The site ID + * @param {string} url - The page URL + * @param {string} source - The source (optional) + * @param {string} type - The issue type (e.g., 'color-contrast') + * @param {Object} log - Logger instance + * @returns {Promise} - The report data or null if not found + */ +async function readCodeChangeReport(s3Client, bucketName, siteId, url, source, type, log) { + try { + const urlSourceHash = generateUrlSourceHash(url, source || ''); + const reportKey = `fixes/${siteId}/${urlSourceHash}/${type}/report.json`; + + log.info(`Reading code change report from S3: ${reportKey}`); + + const reportData = await getObjectFromKey(s3Client, bucketName, reportKey, log); + + if (!reportData) { + log.warn(`No code change report found for key: ${reportKey}`); + return null; + } + + log.info(`Successfully read code change report from S3: ${reportKey}`); + return reportData; + } catch (error) { + log.error(`Error reading code change report from S3: ${error.message}`, error); + return null; + } +} + +/** + * Updates suggestions with code change data + * @param {Array} suggestions - Array of suggestion objects + * @param {string} url - The page URL to match + * @param {string} source - The source to match (optional) + * @param {string} ruleId - The WCAG rule ID to match + * @param {Object} reportData - The code change report data + * @param {Object} log - Logger instance + * @returns {Promise} - Array of updated suggestions + */ +async function updateSuggestionsWithCodeChange(suggestions, url, source, ruleId, reportData, log) { + const updatedSuggestions = []; + + try { + const promises = []; + for (const suggestion of suggestions) { + const suggestionData = suggestion.getData(); + + // Check if this suggestion matches the criteria + const suggestionUrl = suggestionData.url; + const suggestionSource = suggestionData.source; + const suggestionRuleId = suggestionData.issues[0]?.type; + + if (suggestionUrl === url + && (!source || suggestionSource === source) + && suggestionRuleId === ruleId + && !!reportData.diff) { + log.info(`Updating suggestion ${suggestion.getId()} with code change data`); + + // Update suggestion data with diff content and availability flag + const updatedData = { + ...suggestionData, + patchContent: reportData.diff, + isCodeChangeAvailable: true, + }; + + suggestion.setData(updatedData); + suggestion.setUpdatedBy('system'); + + promises.push(suggestion.save()); + updatedSuggestions.push(suggestion); + + log.info(`Successfully updated suggestion ${suggestion.getId()}`); + } + } + await Promise.all(promises); + } catch (error) { + log.error(`Error updating suggestions with code change data: ${error.message}`, error); + throw error; + } + + return updatedSuggestions; +} + +/** + * AccessibilityCodeFixHandler - Updates suggestions with code changes from S3 + * + * Expected message format: + * { + * "siteId": "", + * "type": "codefix:accessibility", + * "data": { + * "opportunityId": "", + * "updates": [ + * { + * "url": "", + * "source": "", // optional + * "type": ["color-contrast", "select-name"] + * } + * ] + * } + * } + * + * @param {Object} message - The SQS message + * @param {Object} context - The context object containing dataAccess, log, s3Client, etc. + * @returns {Promise} - HTTP response + */ +export default async function accessibilityCodeFixHandler(message, context) { + const { + log, dataAccess, s3Client, env, + } = context; + const { Opportunity } = dataAccess; + const { siteId, data } = message; + + if (!data) { + log.error('AccessibilityCodeFixHandler: No data provided in message'); + return badRequest('No data provided in message'); + } + + const { opportunityId, updates } = data; + + if (!opportunityId) { + log.error('[AccessibilityCodeFixHandler] No opportunityId provided'); + return badRequest('No opportunityId provided'); + } + + if (!isNonEmptyArray(updates)) { + log.error('[AccessibilityCodeFixHandler] No updates provided or updates is empty'); + return badRequest('No updates provided or updates is empty'); + } + + log.info(`[AccessibilityCodeFixHandler] Processing message for siteId: ${siteId}, opportunityId: ${opportunityId}`); + + try { + // Find the opportunity + const opportunity = await Opportunity.findById(opportunityId); + + if (!opportunity) { + log.error(`[AccessibilityCodeFixHandler] Opportunity not found for ID: ${opportunityId}`); + return notFound('Opportunity not found'); + } + + // Verify the opportunity belongs to the correct site + if (opportunity.getSiteId() !== siteId) { + const errorMsg = `[AccessibilityCodeFixHandler] Site ID mismatch. Expected: ${siteId}, Found: ${opportunity.getSiteId()}`; + log.error(errorMsg); + return badRequest('Site ID mismatch'); + } + + // Get all suggestions for the opportunity + const suggestions = await opportunity.getSuggestions(); + + if (!isNonEmptyArray(suggestions)) { + log.warn(`[AccessibilityCodeFixHandler] No suggestions found for opportunity: ${opportunityId}`); + return ok('No suggestions found for opportunity'); + } + + const bucketName = env.S3_MYSTIQUE_BUCKET_NAME; + + if (!bucketName) { + log.error('AccessibilityCodeFixHandler: S3_MYSTIQUE_BUCKET_NAME environment variable not set'); + return internalServerError('S3 bucket name not configured'); + } + + let totalUpdatedSuggestions = 0; + + // Process each update + await Promise.all(updates.map(async (update) => { + const { url, source, type: types } = update; + + if (!url) { + log.warn('[AccessibilityCodeFixHandler] Skipping update without URL'); + return; + } + + if (!isNonEmptyArray(types)) { + log.warn(`[AccessibilityCodeFixHandler] Skipping update for URL ${url} without types`); + return; + } + + log.info(`[AccessibilityCodeFixHandler] Processing update for URL: ${url}, source: ${source || 'N/A'}, types: ${types.join(', ')}`); + + // For each type in the update, try to read the code change report + await Promise.all(types.map(async (ruleId) => { + let reportData = await readCodeChangeReport( + s3Client, + bucketName, + siteId, + url, + source, + ruleId, + log, + ); + + if (!reportData) { + log.warn(`[AccessibilityCodeFixHandler] No code change report found for URL: ${url}, source: ${source}, type: ${ruleId}`); + return; + } + + reportData = JSON.parse(reportData); + + // Update matching suggestions with the code change data + const updatedSuggestions = await updateSuggestionsWithCodeChange( + suggestions, + url, + source, + ruleId, + reportData, + log, + ); + totalUpdatedSuggestions += updatedSuggestions.length; + })); + })); + + log.info(`[AccessibilityCodeFixHandler] Successfully processed all updates. Total suggestions updated: ${totalUpdatedSuggestions}`); + return ok(); + } catch (error) { + log.error(`[AccessibilityCodeFixHandler] Error processing message: ${error.message}`, error); + return internalServerError(`Error processing message: ${error.message}`); + } +} diff --git a/src/accessibility/utils/data-processing.js b/src/accessibility/utils/data-processing.js index bb3e24a54..8538b35d8 100644 --- a/src/accessibility/utils/data-processing.js +++ b/src/accessibility/utils/data-processing.js @@ -754,3 +754,89 @@ export async function sendRunImportMessage( ...(data && { data }), }); } + +/** + * Groups suggestions by URL, source, and issue type, then sends messages + * to the importer worker for code-fix generation + * + * @param {Object} opportunity - The opportunity object containing suggestions + * @param {string} auditId - The audit ID + * @param {Object} context - The context object containing log, sqs, env, and site + * @returns {Promise} + */ +export async function sendCodeFixMessagesToImporter(opportunity, auditId, context) { + const { + log, sqs, env, site, + } = context; + + const siteId = opportunity.getSiteId(); + const baseUrl = site.getBaseURL(); + const opportunityType = opportunity.getType(); + + try { + // Get all suggestions from the opportunity + const suggestions = await opportunity.getSuggestions(); + if (!suggestions || suggestions.length === 0) { + log.info(`[${opportunityType}] [Site Id: ${siteId}] No suggestions found for code-fix generation`); + return; + } + + // Group suggestions by URL, source, and issueType + const groupedSuggestions = new Map(); + + suggestions.forEach((suggestion) => { + const suggestionData = suggestion.getData(); + const { url, source = 'default', issues } = suggestionData; + + // By design, data.issues will always have length 1 + if (issues && issues.length > 0) { + const issueType = issues[0].type; + const groupKey = `${url}|${source}|${issueType}`; + if (!groupedSuggestions.has(groupKey)) { + groupedSuggestions.set(groupKey, { + url, + source, + issueType, + suggestionIds: [], + }); + } + + // Add the suggestion ID to the group + groupedSuggestions.get(groupKey).suggestionIds.push(suggestion.getId()); + } + }); + + log.info(`[${opportunityType}] [Site Id: ${siteId}] Grouped suggestions into ${groupedSuggestions.size} groups for code-fix generation`); + + const messagePromises = Array.from(groupedSuggestions.values()).map(async (group) => { + const message = { + type: 'code', + siteId, + forward: { + queue: env.QUEUE_SPACECAT_TO_MYSTIQUE, + type: `codefix:${opportunityType}`, + siteId, + auditId, + url: baseUrl, + deliveryType: site.getDeliveryType(), + data: { + opportunityId: opportunity.getId(), + suggestionIds: group.suggestionIds, + }, + }, + }; + + try { + await sqs.sendMessage(env.IMPORT_WORKER_QUEUE_URL, message); + log.info(`[${opportunityType}] [Site Id: ${siteId}] Sent code-fix message to importer for URL: ${group.url}, source: ${group.source}, issueType: ${group.issueType}, suggestions: ${group.suggestionIds.length}`); + } catch (error) { + log.error(`[${opportunityType}] [Site Id: ${siteId}] Failed to send code-fix message for URL: ${group.url}, error: ${error.message}`); + } + }); + + await Promise.all(messagePromises); + log.info(`[${opportunityType}] [Site Id: ${siteId}] Completed sending ${messagePromises.length} code-fix messages to importer`); + } catch (error) { + log.error(`[${opportunityType}] [Site Id: ${siteId}] Error in sendCodeFixMessagesToImporter: ${error.message}`); + } +} diff --git a/src/forms-opportunities/oppty-handlers/accessibility-handler.js b/src/forms-opportunities/oppty-handlers/accessibility-handler.js index 95c717871..1b78b176d 100644 --- a/src/forms-opportunities/oppty-handlers/accessibility-handler.js +++ b/src/forms-opportunities/oppty-handlers/accessibility-handler.js @@ -13,14 +13,19 @@ import { ok } from '@adobe/spacecat-shared-http-utils'; import { Audit } from '@adobe/spacecat-shared-data-access'; import { FORM_OPPORTUNITY_TYPES, formOpportunitiesMap } from '../constants.js'; -import { getSuccessCriteriaDetails, sendMessageToFormsQualityAgent, sendMessageToMystiqueForGuidance } from '../utils.js'; +import { + getSuccessCriteriaDetails, + sendMessageToFormsQualityAgent, + sendMessageToMystiqueForGuidance, +} from '../utils.js'; import { updateStatusToIgnored } from '../../accessibility/utils/scrape-utils.js'; import { aggregateAccessibilityIssues, createIndividualOpportunitySuggestions, } from '../../accessibility/utils/generate-individual-opportunities.js'; -import { aggregateAccessibilityData, sendRunImportMessage } from '../../accessibility/utils/data-processing.js'; +import { aggregateAccessibilityData, sendRunImportMessage, sendCodeFixMessagesToImporter } from '../../accessibility/utils/data-processing.js'; import { URL_SOURCE_SEPARATOR, A11Y_METRICS_AGGREGATOR_IMPORT_TYPE, WCAG_CRITERIA_COUNTS } from '../../accessibility/utils/constants.js'; +import { isAuditEnabledForSite } from '../../common/audit-utils.js'; const filterAccessibilityOpportunities = (opportunities) => opportunities.filter((opportunity) => opportunity.getTags()?.includes('Forms Accessibility')); @@ -443,7 +448,7 @@ export async function createAccessibilityOpportunity(auditData, context) { } export default async function handler(message, context) { - const { log } = context; + const { log, site } = context; const { auditId, siteId, data } = message; const { opportunityId, a11y } = data; log.debug(`[Form Opportunity] [Site Id: ${siteId}] Received message in accessibility handler: ${JSON.stringify(message, null, 2)}`); @@ -463,6 +468,18 @@ export default async function handler(message, context) { // Create individual suggestions from Mystique data await createFormAccessibilitySuggestionsFromMystique(a11y, opportunity, context); + // send message to importer for code-fix generation + const isAutoFixEnabled = await isAuditEnabledForSite(`${opportunity.getType()}-auto-fix`, site, context); + if (isAutoFixEnabled) { + await sendCodeFixMessagesToImporter( + opportunity, + auditId, + context, + ); + } else { + log.info(`[Form Opportunity] [Site Id: ${siteId}] ${opportunity.getType()}-auto-fix is disabled for site, skipping code-fix generation`); + } + log.info(`[Form Opportunity] [Site Id: ${siteId}] a11y opportunity: ${JSON.stringify(opportunity, null, 2)}`); const opportunityData = opportunity.getData(); const a11yData = opportunityData.accessibility; diff --git a/src/index.js b/src/index.js index f2fe5618c..e4273b7ec 100644 --- a/src/index.js +++ b/src/index.js @@ -80,6 +80,7 @@ import prerender from './prerender/handler.js'; import { refreshGeoBrandPresenceSheetsHandler } from './geo-brand-presence/geo-brand-presence-refresh-handler.js'; import summarization from './summarization/handler.js'; import summarizationGuidance from './summarization/guidance-handler.js'; +import accessibilityCodeFixHandler from './accessibility/auto-optimization-handlers/codefix-handler.js'; const HANDLERS = { accessibility, @@ -146,6 +147,7 @@ const HANDLERS = { headings, prerender, 'security-vulnerabilities': vulnerabilities, + 'codefix:form-accessibility': accessibilityCodeFixHandler, dummy: (message) => ok(message), }; diff --git a/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js b/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js new file mode 100644 index 000000000..bb3665828 --- /dev/null +++ b/test/audits/accessibility/auto-optimization-handlers/codefix-handler.test.js @@ -0,0 +1,504 @@ +/* + * 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 sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import chaiAsPromised from 'chai-as-promised'; +import esmock from 'esmock'; +import { MockContextBuilder } from '../../../shared.js'; + +use(sinonChai); +use(chaiAsPromised); + +describe('AccessibilityCodeFixHandler', () => { + let sandbox; + let context; + let mockDataAccess; + let mockOpportunity; + let mockSuggestion; + let getObjectFromKeyStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + mockSuggestion = { + getId: sandbox.stub().returns('suggestion-123'), + getData: sandbox.stub(), + setData: sandbox.stub(), + setUpdatedBy: sandbox.stub(), + save: sandbox.stub().resolves(), + }; + + mockOpportunity = { + getId: sandbox.stub().returns('opportunity-123'), + getSiteId: sandbox.stub().returns('site-123'), + getSuggestions: sandbox.stub().resolves([mockSuggestion]), + }; + + mockDataAccess = { + Opportunity: { + findById: sandbox.stub().resolves(mockOpportunity), + }, + }; + + getObjectFromKeyStub = sandbox.stub(); + + context = new MockContextBuilder() + .withSandbox(sandbox) + .withOverrides({ + log: { + info: sandbox.spy(), + debug: sandbox.spy(), + warn: sandbox.spy(), + error: sandbox.spy(), + }, + dataAccess: mockDataAccess, + env: { + S3_MYSTIQUE_BUCKET_NAME: 'test-mystique-bucket', + }, + }) + .build(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + const validMessage = { + siteId: 'site-123', + type: 'codefix:accessibility', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + source: 'form', + type: ['color-contrast'], + }, + ], + }, + }; + + describe('Main Handler Function', () => { + it('should successfully process updates with matching suggestions', async () => { + const mockReportData = JSON.stringify({ + url: 'https://example.com/contact', + source: 'form', + type: 'color-contrast', + diff: 'mock diff content', + }); + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).to.have.been.calledWith({ + ...suggestionData, + patchContent: 'mock diff content', + isCodeChangeAvailable: true, + }); + expect(mockSuggestion.save).to.have.been.called; + }); + + it('should return badRequest when no data provided', async () => { + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const message = { siteId: 'site-123', type: 'codefix:accessibility' }; + const result = await handler.default(message, context); + + expect(result.status).to.equal(400); + expect(context.log.error).to.have.been.calledWith( + 'AccessibilityCodeFixHandler: No data provided in message', + ); + }); + + it('should return badRequest when no opportunityId provided', async () => { + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const message = { + siteId: 'site-123', + data: { updates: [] }, + }; + const result = await handler.default(message, context); + + expect(result.status).to.equal(400); + expect(context.log.error).to.have.been.calledWith( + '[AccessibilityCodeFixHandler] No opportunityId provided', + ); + }); + + it('should return badRequest when no updates provided', async () => { + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const message = { + siteId: 'site-123', + data: { opportunityId: 'opportunity-123' }, + }; + const result = await handler.default(message, context); + + expect(result.status).to.equal(400); + }); + + it('should return notFound when opportunity not found', async () => { + mockDataAccess.Opportunity.findById.resolves(null); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(404); + expect(context.log.error).to.have.been.calledWith( + '[AccessibilityCodeFixHandler] Opportunity not found for ID: opportunity-123', + ); + }); + + it('should return badRequest when site ID mismatch', async () => { + mockOpportunity.getSiteId.returns('different-site-id'); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(400); + }); + + it('should return ok when no suggestions found', async () => { + mockOpportunity.getSuggestions.resolves([]); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + }); + + it('should return internalServerError when S3 bucket not configured', async () => { + context.env.S3_MYSTIQUE_BUCKET_NAME = undefined; + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(500); + }); + + it('should handle missing S3 reports gracefully', async () => { + getObjectFromKeyStub.resolves(null); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + expect(context.log.warn).to.have.been.calledWith( + sinon.match(/No code change report found for URL/), + ); + }); + + it('should handle S3 errors gracefully', async () => { + getObjectFromKeyStub.rejects(new Error('S3 access denied')); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Error reading code change report from S3/), + ); + }); + + it('should not update suggestions without matching criteria', async () => { + const mockReportData = JSON.stringify({ + diff: 'mock diff content', + }); + + const suggestionData = { + url: 'https://different.com/test', // Different URL + source: 'form', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).not.to.have.been.called; + expect(mockSuggestion.save).not.to.have.been.called; + }); + + it('should not update suggestions without diff content', async () => { + const mockReportData = JSON.stringify({ + // No diff property + url: 'https://example.com/contact', + source: 'form', + }); + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).not.to.have.been.called; + }); + + it('should handle suggestion save errors', async () => { + const mockReportData = JSON.stringify({ + diff: 'mock diff content', + }); + + const suggestionData = { + url: 'https://example.com/contact', + source: 'form', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + mockSuggestion.save.rejects(new Error('Save failed')); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(500); + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Error updating suggestions with code change data/), + ); + }); + + it('should handle processing errors gracefully', async () => { + mockDataAccess.Opportunity.findById.rejects(new Error('Database error')); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const result = await handler.default(validMessage, context); + + expect(result.status).to.equal(500); + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Error processing message: Database error/), + ); + }); + + it('should process multiple updates with multiple types', async () => { + const mockReportData1 = JSON.stringify({ diff: 'diff for color-contrast' }); + const mockReportData2 = JSON.stringify({ diff: 'diff for select-name' }); + + const suggestionData1 = { + url: 'https://example.com/page1', + source: 'form1', + issues: [{ type: 'color-contrast' }], + }; + + const suggestionData2 = { + url: 'https://example.com/page1', + source: 'form1', + issues: [{ type: 'select-name' }], + }; + + const mockSuggestion2 = { + getId: sandbox.stub().returns('suggestion-456'), + getData: sandbox.stub().returns(suggestionData2), + setData: sandbox.stub(), + setUpdatedBy: sandbox.stub(), + save: sandbox.stub().resolves(), + }; + + mockSuggestion.getData.returns(suggestionData1); + mockOpportunity.getSuggestions.resolves([mockSuggestion, mockSuggestion2]); + + getObjectFromKeyStub.onFirstCall().resolves(mockReportData1); + getObjectFromKeyStub.onSecondCall().resolves(mockReportData2); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const multiUpdateMessage = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/page1', + source: 'form1', + type: ['color-contrast', 'select-name'], + }, + ], + }, + }; + + const result = await handler.default(multiUpdateMessage, context); + + expect(result.status).to.equal(200); + expect(getObjectFromKeyStub).to.have.been.calledTwice; + }); + + it('should skip updates without URL or types', async () => { + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const messageWithoutUrl = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + source: 'form', + type: ['color-contrast'], + }, + { + url: 'https://example.com/test', + source: 'form', + // No type + }, + ], + }, + }; + + const result = await handler.default(messageWithoutUrl, context); + + expect(result.status).to.equal(200); + expect(context.log.warn).to.have.been.calledWith( + '[AccessibilityCodeFixHandler] Skipping update without URL', + ); + expect(context.log.warn).to.have.been.calledWith( + sinon.match(/Skipping update for URL.*without types/), + ); + }); + + it('should work without source parameter', async () => { + const mockReportData = JSON.stringify({ + diff: 'mock diff content', + }); + + const suggestionData = { + url: 'https://example.com/contact', + source: 'any-source', + issues: [{ type: 'color-contrast' }], + }; + + mockSuggestion.getData.returns(suggestionData); + getObjectFromKeyStub.resolves(mockReportData); + + const handler = await esmock('../../../../src/accessibility/auto-optimization-handlers/codefix-handler.js', { + '../../../../src/utils/s3-utils.js': { + getObjectFromKey: getObjectFromKeyStub, + }, + }); + + const messageWithoutSource = { + siteId: 'site-123', + data: { + opportunityId: 'opportunity-123', + updates: [ + { + url: 'https://example.com/contact', + type: ['color-contrast'], + }, + ], + }, + }; + + const result = await handler.default(messageWithoutSource, context); + + expect(result.status).to.equal(200); + expect(mockSuggestion.setData).to.have.been.called; + }); + }); +}); \ No newline at end of file diff --git a/test/audits/accessibility/data-processing.test.js b/test/audits/accessibility/data-processing.test.js index 860deec79..c382a2eb8 100644 --- a/test/audits/accessibility/data-processing.test.js +++ b/test/audits/accessibility/data-processing.test.js @@ -34,6 +34,7 @@ import { aggregateAccessibilityData, getAuditPrefixes, sendRunImportMessage, + sendCodeFixMessagesToImporter, } from '../../../src/accessibility/utils/data-processing.js'; use(sinonChai); @@ -5110,4 +5111,385 @@ describe('data-processing utility functions', () => { }); }); }); + + describe('sendCodeFixMessagesToImporter', () => { + let sandbox; + let context; + let mockOpportunity; + let mockSuggestion1; + let mockSuggestion2; + let mockSuggestion3; + let mockSite; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + // Create mock suggestions + mockSuggestion1 = { + getId: sandbox.stub().returns('suggestion-123'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form1', + source: 'form', + issues: [{ type: 'color-contrast' }], + }), + }; + + mockSuggestion2 = { + getId: sandbox.stub().returns('suggestion-456'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form1', + source: 'form', + issues: [{ type: 'color-contrast' }], + }), + }; + + mockSuggestion3 = { + getId: sandbox.stub().returns('suggestion-789'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form2', + source: 'form2', + issues: [{ type: 'select-name' }], + }), + }; + + // Create mock opportunity + mockOpportunity = { + getId: sandbox.stub().returns('opportunity-123'), + getSiteId: sandbox.stub().returns('site-123'), + getType: sandbox.stub().returns('accessibility'), + getSuggestions: sandbox.stub().resolves([mockSuggestion1, mockSuggestion2, mockSuggestion3]), + }; + + // Create mock site + mockSite = { + getBaseURL: sandbox.stub().returns('https://example.com'), + getDeliveryType: sandbox.stub().returns('aem_cs'), + }; + + // Create context with stubs + context = { + log: { + info: sandbox.spy(), + debug: sandbox.spy(), + warn: sandbox.spy(), + error: sandbox.spy(), + }, + sqs: { + sendMessage: sandbox.stub().resolves(), + }, + env: { + IMPORT_WORKER_QUEUE_URL: 'test-import-worker-queue-url', + QUEUE_SPACECAT_TO_MYSTIQUE: 'test-mystique-queue', + }, + site: mockSite, + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('No suggestions', () => { + it('should skip code-fix generation when no suggestions exist', async () => { + mockOpportunity.getSuggestions.resolves([]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] No suggestions found for code-fix generation', + ); + expect(context.sqs.sendMessage).not.to.have.been.called; + }); + + it('should skip code-fix generation when suggestions is null', async () => { + mockOpportunity.getSuggestions.resolves(null); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] No suggestions found for code-fix generation', + ); + expect(context.sqs.sendMessage).not.to.have.been.called; + }); + }); + + describe('Successful message sending', () => { + it('should group suggestions by URL, source, and issueType and send messages', async () => { + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 2 groups for code-fix generation', + ); + + // Should send 2 messages (2 groups) + expect(context.sqs.sendMessage).to.have.been.calledTwice; + + // Verify first message (color-contrast group) + const firstCall = context.sqs.sendMessage.firstCall; + expect(firstCall.args[0]).to.equal('test-import-worker-queue-url'); + const firstMessage = firstCall.args[1]; + expect(firstMessage.type).to.equal('code'); + expect(firstMessage.siteId).to.equal('site-123'); + expect(firstMessage.forward.queue).to.equal('test-mystique-queue'); + expect(firstMessage.forward.type).to.equal('codefix:accessibility'); + expect(firstMessage.forward.siteId).to.equal('site-123'); + expect(firstMessage.forward.auditId).to.equal('audit-123'); + expect(firstMessage.forward.url).to.equal('https://example.com'); + expect(firstMessage.forward.data.opportunityId).to.equal('opportunity-123'); + expect(firstMessage.forward.data.suggestionIds).to.have.lengthOf(2); + expect(firstMessage.forward.data.suggestionIds).to.include('suggestion-123'); + expect(firstMessage.forward.data.suggestionIds).to.include('suggestion-456'); + + // Verify second message (select-name group) + const secondCall = context.sqs.sendMessage.secondCall; + expect(secondCall.args[0]).to.equal('test-import-worker-queue-url'); + const secondMessage = secondCall.args[1]; + expect(secondMessage.forward.data.suggestionIds).to.have.lengthOf(1); + expect(secondMessage.forward.data.suggestionIds).to.include('suggestion-789'); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Completed sending 2 code-fix messages to importer', + ); + }); + + it('should use dynamic opportunityType from opportunity.getType()', async () => { + mockOpportunity.getType.returns('forms'); + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-456', context); + + expect(context.sqs.sendMessage).to.have.been.calledTwice; + const firstMessage = context.sqs.sendMessage.firstCall.args[1]; + expect(firstMessage.forward.type).to.equal('codefix:forms'); + }); + + it('should handle suggestion with default source when source is undefined', async () => { + const mockSuggestionNoSource = { + getId: sandbox.stub().returns('suggestion-no-source'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form3', + // No source property + issues: [{ type: 'label-missing' }], + }), + }; + + mockOpportunity.getSuggestions.resolves([mockSuggestionNoSource]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.sqs.sendMessage).to.have.been.calledOnce; + expect(context.log.info).to.have.been.calledWith( + sinon.match(/Sent code-fix message to importer for URL: https:\/\/example\.com\/form3, source: default/), + ); + }); + + it('should skip suggestions without issues', async () => { + const mockSuggestionNoIssues = { + getId: sandbox.stub().returns('suggestion-no-issues'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form4', + source: 'form', + issues: [], + }), + }; + + mockOpportunity.getSuggestions.resolves([mockSuggestionNoIssues]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.sqs.sendMessage).not.to.have.been.called; + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 0 groups for code-fix generation', + ); + }); + + it('should skip suggestions without issues property', async () => { + const mockSuggestionNoIssuesProperty = { + getId: sandbox.stub().returns('suggestion-no-issues-property'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form5', + source: 'form', + // No issues property + }), + }; + + mockOpportunity.getSuggestions.resolves([mockSuggestionNoIssuesProperty]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.sqs.sendMessage).not.to.have.been.called; + }); + + it('should log individual message sending for each group', async () => { + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + sinon.match(/Sent code-fix message to importer for URL: https:\/\/example\.com\/form1, source: form, issueType: color-contrast, suggestions: 2/), + ); + + expect(context.log.info).to.have.been.calledWith( + sinon.match(/Sent code-fix message to importer for URL: https:\/\/example\.com\/form2, source: form2, issueType: select-name, suggestions: 1/), + ); + }); + }); + + describe('Failed message sending', () => { + it('should handle individual message sending failures gracefully', async () => { + const sendError = new Error('SQS send failed'); + context.sqs.sendMessage + .onFirstCall().rejects(sendError) + .onSecondCall().resolves(); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.sqs.sendMessage).to.have.been.calledTwice; + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Failed to send code-fix message for URL: https:\/\/example\.com\/form1, error: SQS send failed/), + ); + + // Should still complete and log completion + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Completed sending 2 code-fix messages to importer', + ); + }); + + it('should handle all message sending failures', async () => { + const sendError = new Error('SQS connection failed'); + context.sqs.sendMessage.rejects(sendError); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.sqs.sendMessage).to.have.been.calledTwice; + expect(context.log.error).to.have.been.calledTwice; + expect(context.log.error).to.have.been.calledWith( + sinon.match(/Failed to send code-fix message for URL.*error: SQS connection failed/), + ); + }); + }); + + describe('Error handling', () => { + it('should handle errors in getSuggestions', async () => { + const error = new Error('Database error'); + mockOpportunity.getSuggestions.rejects(error); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.error).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Error in sendCodeFixMessagesToImporter: Database error', + ); + expect(context.sqs.sendMessage).not.to.have.been.called; + }); + + it('should handle errors during grouping suggestions', async () => { + // Make getData throw an error + mockSuggestion1.getData.throws(new Error('getData failed')); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.error).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Error in sendCodeFixMessagesToImporter: getData failed', + ); + expect(context.sqs.sendMessage).not.to.have.been.called; + }); + }); + + describe('Complex grouping scenarios', () => { + it('should group multiple suggestions with same URL, source, and issueType', async () => { + const mockSuggestion4 = { + getId: sandbox.stub().returns('suggestion-999'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form1', + source: 'form', + issues: [{ type: 'color-contrast' }], + }), + }; + + mockOpportunity.getSuggestions.resolves([ + mockSuggestion1, + mockSuggestion2, + mockSuggestion4, + ]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 1 groups for code-fix generation', + ); + + expect(context.sqs.sendMessage).to.have.been.calledOnce; + const message = context.sqs.sendMessage.firstCall.args[1]; + expect(message.forward.data.suggestionIds).to.have.lengthOf(3); + expect(message.forward.data.suggestionIds).to.include('suggestion-123'); + expect(message.forward.data.suggestionIds).to.include('suggestion-456'); + expect(message.forward.data.suggestionIds).to.include('suggestion-999'); + }); + + it('should create separate groups for different URLs', async () => { + const mockSuggestionDifferentUrl = { + getId: sandbox.stub().returns('suggestion-url-diff'), + getData: sandbox.stub().returns({ + url: 'https://example.com/different-url', + source: 'form', + issues: [{ type: 'color-contrast' }], + }), + }; + + mockOpportunity.getSuggestions.resolves([ + mockSuggestion1, + mockSuggestionDifferentUrl, + ]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 2 groups for code-fix generation', + ); + expect(context.sqs.sendMessage).to.have.been.calledTwice; + }); + + it('should create separate groups for different sources', async () => { + const mockSuggestionDifferentSource = { + getId: sandbox.stub().returns('suggestion-source-diff'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form1', + source: 'different-source', + issues: [{ type: 'color-contrast' }], + }), + }; + + mockOpportunity.getSuggestions.resolves([ + mockSuggestion1, + mockSuggestionDifferentSource, + ]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 2 groups for code-fix generation', + ); + expect(context.sqs.sendMessage).to.have.been.calledTwice; + }); + + it('should create separate groups for different issue types', async () => { + const mockSuggestionDifferentIssue = { + getId: sandbox.stub().returns('suggestion-issue-diff'), + getData: sandbox.stub().returns({ + url: 'https://example.com/form1', + source: 'form', + issues: [{ type: 'button-name' }], + }), + }; + + mockOpportunity.getSuggestions.resolves([ + mockSuggestion1, + mockSuggestionDifferentIssue, + ]); + + await sendCodeFixMessagesToImporter(mockOpportunity, 'audit-123', context); + + expect(context.log.info).to.have.been.calledWith( + '[accessibility] [Site Id: site-123] Grouped suggestions into 2 groups for code-fix generation', + ); + expect(context.sqs.sendMessage).to.have.been.calledTwice; + }); + }); + }); }); diff --git a/test/audits/forms/accessibility-handler.test.js b/test/audits/forms/accessibility-handler.test.js index 66bef395c..485a8623d 100644 --- a/test/audits/forms/accessibility-handler.test.js +++ b/test/audits/forms/accessibility-handler.test.js @@ -1825,6 +1825,7 @@ describe('Forms Opportunities - Accessibility Handler', () => { }, env: { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', + IMPORT_WORKER_QUEUE_URL: 'test-import-worker-queue', }, sqs: { sendMessage: sandbox.stub().resolves(), @@ -1839,15 +1840,17 @@ describe('Forms Opportunities - Accessibility Handler', () => { allBySiteIdAndStatus: sandbox.stub().resolves([]), findById: sandbox.stub().resolves({ getId: () => opportunityId, - save: sandbox.stub().resolves({ - getId: () => opportunityId, - getData: () => ({ - accessibility: [{ - form: 'test-form', - formsource: 'test-source', - a11yIssues: [], - }], - }), + getType: () => 'form-accessibility', + getSiteId: () => siteId, + getSuggestions: sandbox.stub().resolves([]), + save: sandbox.stub().callsFake(function() { + return Promise.resolve({ + getId: () => opportunityId, + getSiteId: () => siteId, + getType: () => 'form-accessibility', + getData: () => mockOpportunityData, + getSuggestions: sandbox.stub().resolves([]), + }); }), getData: () => mockOpportunityData, setData: (data) => { @@ -1866,6 +1869,11 @@ describe('Forms Opportunities - Accessibility Handler', () => { getBaseURL: sinon.stub().returns('https://example.com'), }), }, + Configuration: { + findLatest: sandbox.stub().resolves({ + isHandlerEnabledForSite: sandbox.stub().resolves(false), + }), + }, }, }) .build(); @@ -1930,6 +1938,7 @@ describe('Forms Opportunities - Accessibility Handler', () => { }, env: { QUEUE_SPACECAT_TO_MYSTIQUE: 'test-queue', + IMPORT_WORKER_QUEUE_URL: 'test-import-worker-queue', }, sqs: { sendMessage: sandbox.stub().resolves(), @@ -1944,9 +1953,13 @@ describe('Forms Opportunities - Accessibility Handler', () => { allBySiteIdAndStatus: sandbox.stub().resolves([]), findById: sandbox.stub().resolves({ getId: () => opportunityId, + getType: () => 'form-accessibility', + getSiteId: () => siteId, + getSuggestions: sandbox.stub().resolves([]), save: sandbox.stub().resolves({ getType: () => 'form-accessibility', getId: () => opportunityId, + getSiteId: () => siteId, getData: () => ({ accessibility: [{ form: 'test-form-2', @@ -1966,6 +1979,7 @@ describe('Forms Opportunities - Accessibility Handler', () => { setData: (data) => { mockOpportunityData = data; }, + getSiteId: () => siteId, }), create: sandbox.stub().resolves({ getId: () => opportunityId, @@ -1979,6 +1993,11 @@ describe('Forms Opportunities - Accessibility Handler', () => { getBaseURL: sinon.stub().returns('https://example.com'), }), }, + Configuration: { + findLatest: sandbox.stub().resolves({ + isHandlerEnabledForSite: sandbox.stub().resolves(false), + }), + }, }, }) .build(); @@ -1988,6 +2007,47 @@ describe('Forms Opportunities - Accessibility Handler', () => { expect(context.sqs.sendMessage).to.have.been.calledOnce; }); + it('should send code-fix messages when auto-fix is enabled', async () => { + const message = { + auditId, + siteId, + data: { + opportunityId, + a11y: [{ + form: 'https://example.com/form1', + formSource: '#form1', + a11yIssues: [{ + issue: 'Missing alt text', + level: 'error', + successCriterias: ['1.1.1'], + htmlWithIssues: [''], + recommendation: 'Add alt text to image', + }], + }], + }, + }; + + // Override the context to have Configuration that enables auto-fix + context.dataAccess.Configuration = { + findLatest: sandbox.stub().resolves({ + isHandlerEnabledForSite: sandbox.stub().callsFake(async (handlerType) => { + // Enable auto-fix for form-accessibility + return handlerType === 'form-accessibility-auto-fix'; + }), + }), + }; + + // Verify context.sqs.sendMessage is stubbed and ready + expect(context.sqs.sendMessage).to.be.a('function'); + + await mystiqueDetectedFormAccessibilityHandler(message, context); + + // Verify SQS messages were sent (one for mystique, potentially one for code-fix importer) + expect(context.sqs.sendMessage).to.have.been.called; + // The actual number of calls depends on whether sendCodeFixMessagesToImporter sends messages + // At minimum, it should be called for mystique message + }); + it('should not send message to mystique when no opportunity is found', async () => { const message = { auditId,