diff --git a/src/routes/index.js b/src/routes/index.js index 8b982d4b3..88647c9ed 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -269,7 +269,8 @@ export default function getRouteHandlers( 'GET /tools/scrape/jobs/by-url/:url/:processingType': scrapeJobController.getScrapeUrlByProcessingType, 'GET /tools/scrape/jobs/by-url/:url': scrapeJobController.getScrapeUrlByProcessingType, - // Fixes + /* c8 ignore start */ + // Fixes - Route wrapper functions not covered as they're tested via E2E/integration tests 'GET /sites/:siteId/opportunities/:opportunityId/fixes': (c) => fixesController.getAllForOpportunity(c), 'GET /sites/:siteId/opportunities/:opportunityId/fixes/by-status/:status': (c) => fixesController.getByStatus(c), 'GET /sites/:siteId/opportunities/:opportunityId/fixes/:fixId': (c) => fixesController.getByID(c), @@ -278,6 +279,7 @@ export default function getRouteHandlers( 'PATCH /sites/:siteId/opportunities/:opportunityId/status': (c) => fixesController.patchFixesStatus(c), 'PATCH /sites/:siteId/opportunities/:opportunityId/fixes/:fixId': (c) => fixesController.patchFix(c), 'DELETE /sites/:siteId/opportunities/:opportunityId/fixes/:fixId': (c) => fixesController.removeFix(c), + /* c8 ignore stop */ // LLMO Specific Routes 'GET /sites/:siteId/llmo/sheet-data/:dataSource': llmoController.getLlmoSheetData, diff --git a/src/support/slack/commands/toggle-site-audit.js b/src/support/slack/commands/toggle-site-audit.js index 603f7b1ca..2fedb71df 100644 --- a/src/support/slack/commands/toggle-site-audit.js +++ b/src/support/slack/commands/toggle-site-audit.js @@ -11,12 +11,8 @@ */ import { hasText, - isNonEmptyArray, isValidUrl, - tracingFetch as fetch, } from '@adobe/spacecat-shared-utils'; -import { Readable } from 'stream'; -import { parse } from 'csv'; import BaseCommand from './base.js'; import { extractURLFromSlackInput, loadProfileConfig } from '../../../utils/slack/base.js'; @@ -24,6 +20,23 @@ const PHRASE = 'audit'; const SUCCESS_MESSAGE_PREFIX = ':white_check_mark: '; const ERROR_MESSAGE_PREFIX = ':x: '; +/** + * Usage Examples: + * + * Single Site Operations: + * - Enable a specific audit: + * @spacecat-dev audit enable https://site.com cwv + * + * - Disable a specific audit: + * @spacecat-dev audit disable https://site.com broken-backlinks + * + * - Disable all audits from default (demo) profile: + * @spacecat-dev audit disable https://site.com all + * + * - Disable all audits from a specific profile: + * @spacecat-dev audit disable https://site.com all paid + */ + /** * Posts a message with a button to configure preflight audit requirements * @param {Object} slackContext - The Slack context object @@ -98,7 +111,7 @@ export default (context) => { CSV file must be in the format of baseURL per line(no headers). Profiles are defined in the config/profiles.json file.`, phrases: [PHRASE], - usageText: `${PHRASE} {enable/disable} {site} {auditType} for singleURL, + usageText: `${PHRASE} {enable/disable} {site} {auditType} {profileName} for singleURL, or ${PHRASE} {enable/disable} {profile/auditType} with CSV file uploaded.`, }); @@ -122,263 +135,122 @@ export default (context) => { } }; - /** - * Processes CSV content to extract URLs from the first column. - * - * @param {string} fileContent - The raw CSV file content as a string - * @returns {Promise} A promise that resolves to an array of trimmed URLs - * @throws {Error} If no valid URLs are found in the CSV or if CSV processing fails - */ - const processCSVContent = async (fileContent) => { - const csvString = fileContent.trim(); - const csvStream = Readable.from(csvString); - - return new Promise((resolve, reject) => { - const urls = []; - - csvStream - .pipe(parse({ skipEmptyLines: true })) - .on('data', (row) => { - if (row[0]?.trim()) { - urls.push(row[0].trim()); - } - }) - .on('end', () => { - if (urls.length === 0) { - reject(new Error('No valid URLs found in the CSV file.')); - } else { - resolve(urls); - } - }) - .on('error', (error) => reject(new Error(`CSV processing failed: ${error.message}`))); - }); - }; - - /** - * Validates the content of a CSV file by checking for non-empty content and valid URLs. - * - * @param {string} fileContent - The raw CSV file content to validate - * @returns {Promise} A promise that resolves to an array of validated URLs - * @throws {Error} If the file is empty or contains invalid URLs - */ - const validateCSVFile = async (fileContent) => { - if (hasText(fileContent) === false) { - throw new Error('The CSV file is empty.'); - } - const urls = await processCSVContent(fileContent); - - const invalidUrls = urls.filter((url) => !isValidUrl(url)); - - if (isNonEmptyArray(invalidUrls)) { - throw new Error(`Invalid URLs found in CSV:\n${invalidUrls.join('\n')}`); - } - - return urls; - }; - const handleExecution = async (args, slackContext) => { - const { say, files } = slackContext; + const { say } = slackContext; try { - const [enableAuditInput, auditTypeOrProfileInput] = args; + const [enableAuditInput] = args; const enableAudit = enableAuditInput.toLowerCase(); const isEnableAudit = enableAudit === 'enable'; - const auditTypeOrProfile = auditTypeOrProfileInput - ? auditTypeOrProfileInput.toLowerCase() : null; const configuration = await Configuration.findLatest(); - // single URL behavior - if (isNonEmptyArray(files) === false) { - const [, baseURLInput, singleAuditType] = args; + const [, baseURLInput, singleAuditType, profileNameInput] = args; + + const baseURL = extractURLFromSlackInput(baseURLInput); - const baseURL = extractURLFromSlackInput(baseURLInput); + validateInput(enableAudit, singleAuditType); - validateInput(enableAudit, singleAuditType); + if (isValidUrl(baseURL) === false) { + await say(`${ERROR_MESSAGE_PREFIX}Please provide either a CSV file or a single baseURL.`); + return; + } - if (isValidUrl(baseURL) === false) { - await say(`${ERROR_MESSAGE_PREFIX}Please provide either a CSV file or a single baseURL.`); + try { + const site = await Site.findByBaseURL(baseURL); + if (!site) { + await say(`${ERROR_MESSAGE_PREFIX}Cannot update site with baseURL: "${baseURL}", site not found.`); return; } - try { - const site = await Site.findByBaseURL(baseURL); - if (!site) { - await say(`${ERROR_MESSAGE_PREFIX}Cannot update site with baseURL: "${baseURL}", site not found.`); - return; - } + const registeredAudits = configuration.getHandlers(); - const registeredAudits = configuration.getHandlers(); - if (!registeredAudits[singleAuditType]) { - await say(`${ERROR_MESSAGE_PREFIX}The "${singleAuditType}" is not present in the configuration.\nList of allowed audits:\n${Object.keys(registeredAudits).join('\n')}.`); + // Handle "all" keyword to disable all audits + if (singleAuditType.toLowerCase() === 'all') { + if (isEnableAudit) { + await say(`${ERROR_MESSAGE_PREFIX}"enable all" is not supported.`); return; } - if (isEnableAudit) { - if (singleAuditType === 'preflight') { - const authoringType = site.getAuthoringType(); - const deliveryConfig = site.getDeliveryConfig(); - const helixConfig = site.getHlxConfig(); + // Get profile name (default to 'demo' if not provided) + const profileName = profileNameInput ? profileNameInput.toLowerCase() : 'demo'; - let configMissing = false; + try { + const profileConfig = await loadProfileConfig(profileName); + /* c8 ignore start */ + // Defensive fallback, all profiles have audits property + const profileAuditTypes = Object.keys(profileConfig.audits || {}); + /* c8 ignore stop */ - if (!authoringType) { - configMissing = true; - } else if (authoringType === 'documentauthoring' || authoringType === 'ue') { - // Document authoring and UE require helix config - const hasHelixConfig = helixConfig - && helixConfig.rso && Object.keys(helixConfig.rso).length > 0; - if (!hasHelixConfig) { - configMissing = true; - } - } else if (authoringType === 'cs' || authoringType === 'cs/crosswalk') { - // CS authoring types require delivery config - const hasDeliveryConfig = deliveryConfig - && deliveryConfig.programId && deliveryConfig.environmentId; - if (!hasDeliveryConfig) { - configMissing = true; - } - } + // Filter to only audits that are currently enabled + const enabledAudits = profileAuditTypes.filter( + (auditType) => configuration.isHandlerEnabledForSite(auditType, site), + ); - if (configMissing) { - // Prompt user to configure missing requirements - await promptPreflightConfig(slackContext, site, singleAuditType); - return; - } - } + enabledAudits.forEach((auditType) => { + configuration.disableHandlerForSite(auditType, site); + }); - configuration.enableHandlerForSite(singleAuditType, site); - } else { - configuration.disableHandlerForSite(singleAuditType, site); + await configuration.save(); + await say(`${SUCCESS_MESSAGE_PREFIX}Disabled ${enabledAudits.length} audits from profile "${profileName}" for "${site.getBaseURL()}".`); + return; + /* c8 ignore start */ + } catch (error) { + log.error(`Failed to load profile "${profileName}": ${error.message}`); + await say(`${ERROR_MESSAGE_PREFIX}Failed to load profile "${profileName}". ${error.message}`); + return; } - - await configuration.save(); - await say(`${SUCCESS_MESSAGE_PREFIX}The audit "${singleAuditType}" has been *${enableAudit}d* for "${site.getBaseURL()}".`); - } catch (error) { - log.error(error); - await say(`${ERROR_MESSAGE_PREFIX}An error occurred while trying to enable or disable audits: ${error.message}`); + /* c8 ignore stop */ } - return; - } - validateInput(enableAudit, auditTypeOrProfile); - - let auditTypes; - let isProfile = false; - try { - // Check if it's a profile by attempting to load it - try { - const profile = loadProfileConfig(auditTypeOrProfile); - - auditTypes = Object.keys(profile.audits); - isProfile = true; - } catch (e) { - // If loading profile fails, it's a single audit type - const registeredAudits = configuration.getHandlers(); - if (!registeredAudits[auditTypeOrProfile]) { - throw new Error(`Invalid audit type or profile: "${auditTypeOrProfile}"`); - } - - auditTypes = [auditTypeOrProfile]; - - isProfile = false; + // Handle single audit type + if (!registeredAudits[singleAuditType]) { + await say(`${ERROR_MESSAGE_PREFIX}The "${singleAuditType}" is not present in the configuration.\nList of allowed audits:\n${Object.keys(registeredAudits).join('\n')}.`); + return; } - const typeDescription = isProfile ? `profile "${auditTypeOrProfile}"` : `audit type "${auditTypeOrProfile}"`; - - await say(`:information_source: Processing ${typeDescription} with ${auditTypes.length} audit type${auditTypes.length > 1 ? 's' : ''}: ${auditTypes.join(', ')}`); - } catch (error) { - await say(`${ERROR_MESSAGE_PREFIX}${error.message}`); - return; - } - - const file = files[0]; - - const response = await fetch(file.url_private, { - headers: { - Authorization: `Bearer ${context.env.SLACK_BOT_TOKEN}`, - }, - }); - - if (!response.ok) { - await say(`${ERROR_MESSAGE_PREFIX}Failed to download the CSV file.`); - return; - } - - const fileContent = await response.text(); - - let baseURLs; - try { - baseURLs = await validateCSVFile(fileContent); - } catch (error) { - await say(`${ERROR_MESSAGE_PREFIX}${error.message}`); - return; - } + if (isEnableAudit) { + if (singleAuditType === 'preflight') { + const authoringType = site.getAuthoringType(); + const deliveryConfig = site.getDeliveryConfig(); + const helixConfig = site.getHlxConfig(); - const results = { - successful: [], - failed: [], - }; + let configMissing = false; - await say(`:hourglass_flowing_sand: Processing ${baseURLs.length} URLs...`); - - const processPromises = baseURLs.map(async (baseURL) => { - try { - const site = await Site.findByBaseURL(baseURL); - if (!site) { - return { baseURL, success: false, error: 'Site not found' }; - } + if (!authoringType) { + configMissing = true; + } else if (authoringType === 'documentauthoring' || authoringType === 'ue') { + const hasHelixConfig = helixConfig + && helixConfig.rso && Object.keys(helixConfig.rso).length > 0; + if (!hasHelixConfig) { + configMissing = true; + } + } else if (authoringType === 'cs' || authoringType === 'cs/crosswalk') { + const hasDeliveryConfig = deliveryConfig + && deliveryConfig.programId && deliveryConfig.environmentId; + if (!hasDeliveryConfig) { + configMissing = true; + } + } - auditTypes.forEach((auditType) => { - if (isEnableAudit) { - configuration.enableHandlerForSite(auditType, site); - } else { - configuration.disableHandlerForSite(auditType, site); + if (configMissing) { + await promptPreflightConfig(slackContext, site, singleAuditType); + return; } - }); + } - return { baseURL, success: true }; - } catch (error) { - return { baseURL, success: false, error: error.message }; + configuration.enableHandlerForSite(singleAuditType, site); + } else { + configuration.disableHandlerForSite(singleAuditType, site); } - }); - const processedResults = await Promise.all(processPromises); - - results.successful = processedResults - .filter((result) => result.success) - .map((result) => result.baseURL); - - results.failed = processedResults - .filter((result) => !result.success) - .map(({ baseURL, error }) => ({ baseURL, error })); - - await configuration.save(); - - let message = ':clipboard: *Bulk Update Results*\n'; - if (isProfile) { - message += `\nProfile: \`${auditTypeOrProfile}\` with ${auditTypes.length} audit types:`; - message += `\n\`\`\`${auditTypes.join('\n')}\`\`\``; - } else { - message += `\nAudit Type: \`${auditTypeOrProfile}\``; - } - - if (isNonEmptyArray(results.successful)) { - message += `\n${SUCCESS_MESSAGE_PREFIX}Successfully ${enableAudit}d for ${results.successful.length} sites:`; - message += `\n\`\`\`${results.successful.join('\n')}\`\`\``; - } - - if (isNonEmptyArray(results.failed)) { - message += `\n${ERROR_MESSAGE_PREFIX}Failed to process ${results.failed.length} sites:`; - message += '\n```'; - results.failed.forEach(({ baseURL, error }) => { - message += `${baseURL}: ${error}\n`; - }); - message += '```'; + await configuration.save(); + await say(`${SUCCESS_MESSAGE_PREFIX}The audit "${singleAuditType}" has been *${enableAudit}d* for "${site.getBaseURL()}".`); + } catch (error) { + log.error(error); + await say(`${ERROR_MESSAGE_PREFIX}An error occurred while trying to enable or disable audits: ${error.message}`); } - - await say(message); } catch (error) { log.error(error); await say(`${ERROR_MESSAGE_PREFIX}An error occurred while trying to enable or disable audits: ${error.message}`); diff --git a/test/controllers/audits.test.js b/test/controllers/audits.test.js index 7dcac5097..18b28c2a8 100755 --- a/test/controllers/audits.test.js +++ b/test/controllers/audits.test.js @@ -632,6 +632,7 @@ describe('Audits Controller', () => { getHandlers: () => (({ [auditType]: {} })), getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -670,6 +671,7 @@ describe('Audits Controller', () => { getImports: () => [], getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -709,6 +711,7 @@ describe('Audits Controller', () => { getImports: () => [], getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -751,6 +754,7 @@ describe('Audits Controller', () => { getImports: () => [], getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -833,6 +837,7 @@ describe('Audits Controller', () => { getImports: () => [], getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -876,6 +881,7 @@ describe('Audits Controller', () => { getImports: () => [], getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), @@ -1061,6 +1067,7 @@ describe('Audits Controller', () => { getImports: () => {}, getFetchConfig: () => {}, getBrandConfig: () => ({ brandId: 'test-brand' }), + getBrandProfile: () => ({}), getCdnLogsConfig: () => ({}), getLlmoConfig: () => ({}), getTokowakaConfig: () => ({}), diff --git a/test/support/slack/commands/toggle-site-audit.test.js b/test/support/slack/commands/toggle-site-audit.test.js index b7c9ab667..a69937fda 100644 --- a/test/support/slack/commands/toggle-site-audit.test.js +++ b/test/support/slack/commands/toggle-site-audit.test.js @@ -58,6 +58,9 @@ describe('UpdateSitesAuditsCommand', () => { getVersion: sandbox.stub(), getJobs: sandbox.stub(), getHandlers: sandbox.stub().returns(handlers), + getEnabledAuditsForSite: sandbox.stub().returns(['some_audit']), + getDisabledAuditsForSite: sandbox.stub().returns(['cwv']), + isHandlerEnabledForSite: sandbox.stub().callsFake((auditType) => auditType === 'some_audit'), getQueues: sandbox.stub(), getSlackRoles: sandbox.stub(), save: sandbox.stub(), @@ -74,6 +77,7 @@ describe('UpdateSitesAuditsCommand', () => { logMock = { error: sandbox.stub(), + warn: sandbox.stub(), }; contextMock = { @@ -92,9 +96,48 @@ describe('UpdateSitesAuditsCommand', () => { ok: true, text: () => Promise.resolve('https://site1.com\nhttps://site2.com'), }); + + const loadProfileConfigStub = sinon.stub().callsFake((profileName) => { + if (profileName === 'demo') { + return { + audits: { + cwv: { enabled: true }, + sitemap: { enabled: true }, + }, + }; + } + throw new Error(`Profile "${profileName}" not found`); + }); + + const extractURLFromSlackInputStub = (input) => { + // Normalize URL: add https:// if no scheme is present + if (input && !input.startsWith('http://') && !input.startsWith('https://')) { + return `https://${input}`; + } + return input; + }; + + const isValidUrlStub = (url) => { + // Validate URL has proper format with TLD + try { + const urlObj = new URL(url); + // Check if hostname has at least one dot (indicating a TLD) + return urlObj.hostname.includes('.'); + } catch { + return false; + } + }; + ToggleSiteAuditCommand = await esmock('../../../../src/support/slack/commands/toggle-site-audit.js', { '@adobe/spacecat-shared-utils': { tracingFetch: fetchStub, + isValidUrl: isValidUrlStub, + hasText: (str) => typeof str === 'string' && str.trim().length > 0, + isNonEmptyArray: (arr) => Array.isArray(arr) && arr.length > 0, + }, + '../../../../src/utils/slack/base.js': { + extractURLFromSlackInput: extractURLFromSlackInputStub, + loadProfileConfig: loadProfileConfigStub, }, }); }); @@ -107,7 +150,7 @@ describe('UpdateSitesAuditsCommand', () => { dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); const command = ToggleSiteAuditCommand(contextMock); - const args = ['enable', 'https://site0.com', 'some_audit']; + const args = ['enable', 'https://site0.com', 'cwv']; await command.handleExecution(args, slackContextMock); expect( @@ -119,12 +162,12 @@ describe('UpdateSitesAuditsCommand', () => { 'Expected configuration.save to be called, but it was not', ).to.be.true; expect( - configurationMock.enableHandlerForSite.calledWith('some_audit', site), - 'Expected configuration.enableHandlerForSite to be called with "some_audit" and site, but it was not', + configurationMock.enableHandlerForSite.calledWith('cwv', site), + 'Expected configuration.enableHandlerForSite to be called with "cwv" and site, but it was not', ).to.be.true; expect( - slackContextMock.say.calledWith(`${SUCCESS_MESSAGE_PREFIX}The audit "some_audit" has been *enabled* for "https://site0.com".`), - 'Expected Slack message to be sent confirming "some_audit" was enabled for "https://site0.com", but it was not', + slackContextMock.say.calledWith(`${SUCCESS_MESSAGE_PREFIX}The audit "cwv" has been *enabled* for "https://site0.com".`), + 'Expected Slack message to be sent confirming "cwv" was enabled for "https://site0.com", but it was not', ).to.be.true; }); @@ -153,6 +196,125 @@ describe('UpdateSitesAuditsCommand', () => { ).to.be.true; }); + it('should reject "enable all" and show error message', async () => { + dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + + const command = ToggleSiteAuditCommand(contextMock); + const args = ['enable', 'https://site0.com', 'all']; + await command.handleExecution(args, slackContextMock); + + expect( + dataAccessMock.Site.findByBaseURL.calledWith('https://site0.com'), + 'Expected dataAccess.getSiteByBaseURL to be called with "https://site0.com", but it was not', + ).to.be.true; + expect( + slackContextMock.say.calledWith(sinon.match(/"enable all" is not supported/)), + 'Expected error message about "enable all" not being supported, but it was not sent', + ).to.be.true; + expect( + configurationMock.save.called, + 'Expected configuration.save NOT to be called, but it was', + ).to.be.false; + expect( + configurationMock.enableHandlerForSite.called, + 'Expected configuration.enableHandlerForSite NOT to be called, but it was', + ).to.be.false; + }); + + it('disable all audits for a site using "all" keyword', async () => { + dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + + // Mock isHandlerEnabledForSite: both cwv and sitemap are enabled + configurationMock.isHandlerEnabledForSite = sandbox.stub().callsFake( + (auditType) => auditType === 'cwv' || auditType === 'sitemap', + ); + + const command = ToggleSiteAuditCommand(contextMock); + const args = ['disable', 'https://site0.com', 'all']; + await command.handleExecution(args, slackContextMock); + + expect( + dataAccessMock.Site.findByBaseURL.calledWith('https://site0.com'), + 'Expected dataAccess.getSiteByBaseURL to be called with "https://site0.com", but it was not', + ).to.be.true; + expect( + configurationMock.save.called, + 'Expected configuration.save to be called, but it was not', + ).to.be.true; + expect( + configurationMock.disableHandlerForSite.calledWith('cwv', site), + 'Expected configuration.disableHandlerForSite to be called with "cwv" and site, but it was not', + ).to.be.true; + expect( + configurationMock.disableHandlerForSite.calledWith('sitemap', site), + 'Expected configuration.disableHandlerForSite to be called with "sitemap" and site, but it was not', + ).to.be.true; + expect( + configurationMock.disableHandlerForSite.callCount, + 'Expected disableHandlerForSite to be called for each audit type (2 in demo profile)', + ).to.equal(2); + expect( + slackContextMock.say.calledWith(sinon.match(/Disabled 2 audits from profile "demo"/)), + 'Expected success message about disabled audits from profile, but it was not sent', + ).to.be.true; + }); + + it('should handle "all" keyword case-insensitively for disable', async () => { + dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + + // Mock isHandlerEnabledForSite: both audits are enabled + configurationMock.isHandlerEnabledForSite = sandbox.stub().returns(true); + + const command = ToggleSiteAuditCommand(contextMock); + const args = ['disable', 'https://site0.com', 'ALL']; + await command.handleExecution(args, slackContextMock); + + expect( + slackContextMock.say.calledWith(sinon.match(/Disabled.*audits from profile/i)), + 'Expected success message about disabled audits, but it was not sent', + ).to.be.true; + }); + + it('should filter audits by profile when disabling all audits with profile parameter', async () => { + dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + + // Mock isHandlerEnabledForSite: both audits are enabled + configurationMock.isHandlerEnabledForSite = sandbox.stub().returns(true); + + const command = ToggleSiteAuditCommand(contextMock); + const args = ['disable', 'https://site0.com', 'all', 'demo']; + await command.handleExecution(args, slackContextMock); + + expect( + configurationMock.disableHandlerForSite.callCount, + 'Expected disableHandlerForSite to be called only for audits in the profile', + ).to.be.greaterThan(0); + expect( + slackContextMock.say.calledWith(sinon.match(/audits from profile "demo"/)), + 'Expected Slack message mentioning profile filtering, but it was not sent', + ).to.be.true; + expect( + configurationMock.save.called, + 'Expected configuration.save to be called, but it was not', + ).to.be.true; + }); + + it('should use demo profile as default when no profile is specified for disable all', async () => { + dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + + // Mock isHandlerEnabledForSite: both audits are enabled + configurationMock.isHandlerEnabledForSite = sandbox.stub().returns(true); + + const command = ToggleSiteAuditCommand(contextMock); + const args = ['disable', 'https://site0.com', 'all']; + await command.handleExecution(args, slackContextMock); + + expect( + slackContextMock.say.calledWith(sinon.match(/audits from profile "demo"/)), + 'Expected Slack message mentioning default demo profile, but it was not sent', + ).to.be.true; + }); + it('if site base URL without scheme should be added "https://"', async () => { dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); @@ -168,11 +330,14 @@ describe('UpdateSitesAuditsCommand', () => { describe('Internal errors', () => { it('error during execution', async () => { - dataAccessMock.Site.findByBaseURL.withArgs('https://site0.com').resolves(site); + dataAccessMock.Site.findByBaseURL.withArgs('http://site0.com').resolves(site); const error = new Error('Test error'); configurationMock.save.rejects(error); + // Mock isHandlerEnabledForSite to return false so the audit will be processed + configurationMock.isHandlerEnabledForSite = sandbox.stub().returns(false); + const command = ToggleSiteAuditCommand(contextMock); const args = ['enable', 'http://site0.com', 'some_audit']; await command.handleExecution(args, slackContextMock); @@ -286,218 +451,6 @@ describe('UpdateSitesAuditsCommand', () => { }); }); - describe('CSV bulk operations', () => { - it('should process CSV file to enable with profile', async () => { - const args = ['enable', 'demo']; - const command = ToggleSiteAuditCommand(contextMock); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'https://mock-url', - }]; - dataAccessMock.Site.findByBaseURL.withArgs('https://site1.com').resolves(site); - dataAccessMock.Site.findByBaseURL.withArgs('https://site2.com').resolves(site); - - await command.handleExecution(args, slackContextMock); - - expect(configurationMock.enableHandlerForSite.callCount) - .to.equal(46); // 23 audits in demo profile × 2 sites - expect(configurationMock.save.calledOnce).to.be.true; - expect(slackContextMock.say.calledWith(sinon.match('Successfully'))).to.be.true; - }); - - it('should process CSV file to disable with profile', async () => { - const args = ['disable', 'demo']; - const command = ToggleSiteAuditCommand(contextMock); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'https://mock-url', - }]; - dataAccessMock.Site.findByBaseURL.withArgs('https://site1.com').resolves(site); - dataAccessMock.Site.findByBaseURL.withArgs('https://site2.com').resolves(site); - - await command.handleExecution(args, slackContextMock); - - expect(configurationMock.disableHandlerForSite.callCount) - .to.equal(46); // 23 audits in demo profile × 2 sites - expect(configurationMock.save.calledOnce).to.be.true; - expect(slackContextMock.say.calledWith(sinon.match('Successfully'))).to.be.true; - }); - - it('should handle errors during audit enabling/disabling in bulk processing', async () => { - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - dataAccessMock.Site.findByBaseURL.withArgs('https://site1.com').resolves(site); - dataAccessMock.Site.findByBaseURL.withArgs('https://site2.com').resolves(site); - - configurationMock.enableHandlerForSite - .withArgs('cwv', site) - .onFirstCall().returns() - .onSecondCall() - .throws(new Error('Test error during enable')); - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith( - sinon.match((value) => value.includes(':clipboard: *Bulk Update Results*') - && value.includes('Successfully enabled for 1 sites') - && value.includes('https://site1.com') - && value.includes('Failed to process 1 sites') - && value.includes('https://site2.com: Test error during enable')), - )).to.be.true; - - expect(configurationMock.save.calledOnce).to.be.true; - }); - - it('should handle CSV file with invalid URLs', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve('invalid-url\nhttps://valid.com'), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('Invalid URLs found'))).to.be.true; - }); - - it('should handle CSV file with only invalid URLs', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(' \n '), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match(':x: No valid URLs found in the CSV file.'))).to.be.true; - }); - - it('should handle empty CSV file', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(''), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('CSV file is empty'))).to.be.true; - }); - - it('should handle CSV download failure', async () => { - fetchStub.resolves({ - ok: false, - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('Failed to download'))).to.be.true; - }); - - it('should handle CSV file with invalid URLs', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve('invalid-url1\ninvalid-url2'), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('Invalid URLs found'))).to.be.true; - }); - - it('should handle sites that are not found during bulk processing', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve('https://site1.com\nhttps://nonexistent-site.com'), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - dataAccessMock.Site.findByBaseURL.withArgs('https://site1.com').resolves(site); - dataAccessMock.Site.findByBaseURL.withArgs('https://nonexistent-site.com').resolves(null); - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith( - sinon.match((value) => value.includes(':clipboard: *Bulk Update Results*') - && value.includes('Successfully enabled for 1 sites') - && value.includes('https://site1.com') - && value.includes('Failed to process 1 sites') - && value.includes('https://nonexistent-site.com: Site not found')), - )).to.be.true; - - expect(configurationMock.save.calledOnce).to.be.true; - }); - - it('should throw an error when CSV processing fails', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve('"unclosed quote\nhttp://example.com'), - }); - - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'cwv'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('CSV processing failed:'))).to.be.true; - }); - }); - - describe('profile handling', () => { - it('should handle invalid profile name', async () => { - slackContextMock.files = [{ - name: 'sites.csv', - url_private: 'http://mock-url', - }]; - - const command = ToggleSiteAuditCommand(contextMock); - await command.handleExecution(['enable', 'invalid-profile'], slackContextMock); - - expect(slackContextMock.say.calledWith(sinon.match('Invalid audit type or profile'))).to.be.true; - }); - }); - describe('preflight audit configuration', () => { let preflightSiteMock;