diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index fc6ae26f..8dc89630 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -31,6 +31,8 @@ tags: description: Site operations - name: audit description: Audit operations + - name: site-audit + description: Site-audit links operations - name: auth description: Init authentication against 3rd party services - name: organization @@ -99,6 +101,8 @@ paths: $ref: './audit-api.yaml#/latest-audit-for-site' /sites/{siteId}/{auditType}: $ref: './audit-api.yaml#/update-handler-type-config-for-site' + /sites/audits: + $ref: './sites-audits-api.yaml#/update-sites-audits' /sites/{siteId}/key-events: $ref: './key-events-api.yaml#/key-events' /sites/{siteId}/key-events/{keyEventId}: diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index 4cd7c9c9..eca16cb7 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -380,6 +380,28 @@ UpdateHandlerTypeConfig: excludedURLs: - 'https://www.adobe.com/some-page' - 'https://www.adobe.com/another-page' +SitesAuditsUpdateResult: + type: array + items: + type: object + properties: + baseURL: + type: string + description: The base URL of the site + response: + type: object + description: The response for operation + properties: + status: + type: string + description: The status of the operation + message: + type: string + description: The message of the operation + site: + $ref: './schemas.yaml#/SiteList' + required: + - status Audit: type: object readOnly: true diff --git a/docs/openapi/sites-audits-api.yaml b/docs/openapi/sites-audits-api.yaml new file mode 100644 index 00000000..0aaecc64 --- /dev/null +++ b/docs/openapi/sites-audits-api.yaml @@ -0,0 +1,47 @@ +update-sites-audits: + patch: + tags: + - site-audit + summary: Enable/Disable audits for multiple sites + description: | + This endpoint is useful for enabling or disabling audit types for multiple sites. + operationId: update + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + baseURL: + $ref: './schemas.yaml#/URL' + auditTypes: + type: array + items: + type: string + examples: + - 404 + - broken-backlinks + - organic-traffic + enableAudits: + type: boolean + description: Set to true to enable audits, or false to disable them. + required: + - baseURL + - auditTypes + - enableAudits + responses: + '207': + description: A list of baseURL, the status of the update, and the corresponding site if successful, or the error message if failed. + content: + application/json: + schema: + $ref: './schemas.yaml#/SitesAuditsUpdateResult' + '401': + $ref: './responses.yaml#/401' + '500': + $ref: './responses.yaml#/500' + security: + - admin_key: [ ] diff --git a/src/controllers/sites-audits.js b/src/controllers/sites-audits.js new file mode 100644 index 00000000..998cf712 --- /dev/null +++ b/src/controllers/sites-audits.js @@ -0,0 +1,112 @@ +/* + * Copyright 2024 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 { + badRequest, + createResponse, + internalServerError, +} from '@adobe/spacecat-shared-http-utils'; +import { isObject, isValidUrl } from '@adobe/spacecat-shared-utils'; + +import { ConfigurationDto } from '../dto/configuration.js'; +import { SiteDto } from '../dto/site.js'; + +/** + * Sites Audits controller. + * @param {DataAccess} dataAccess - Data access. + * @returns {object} Sites Audits controller. + * @constructor + */ +export default (dataAccess) => { + if (!isObject(dataAccess)) { + throw new Error('Data access required'); + } + + const validateInput = ({ baseURL, enableAudits, auditTypes }) => { + if (!baseURL) { + throw new Error('Base URL is required'); + } + + if (!isValidUrl(baseURL)) { + throw new Error(`Invalid Base URL format: ${baseURL}`); + } + + if (!Array.isArray(auditTypes) || auditTypes.length === 0) { + throw new Error('Audit types are required'); + } + + if (typeof enableAudits === 'undefined') { + throw new Error('The "enableAudits" flag is required'); + } + + if (typeof enableAudits !== 'boolean') { + throw new Error('The "enableAudits" flag should be boolean'); + } + }; + + const update = async (context) => { + const sitesConfigurations = context.data; + + try { + for (const siteConfiguration of sitesConfigurations) { + validateInput(siteConfiguration); + } + } catch (error) { + return badRequest(error.message); + } + + try { + let hasUpdates = false; + const configuration = await dataAccess.getConfiguration(); + + const responses = await Promise.all( + sitesConfigurations.map(async ({ baseURL, auditTypes, enableAudits }) => { + const site = await dataAccess.getSiteByBaseURL(baseURL); + if (!site) { + return { + baseURL, + response: { + message: `Site with baseURL: ${baseURL} not found`, + status: 404, + }, + }; + } + hasUpdates = true; + for (const auditType of auditTypes) { + if (enableAudits === true) { + configuration.enableHandlerForSite(auditType, site); + } else { + configuration.disableHandlerForSite(auditType, site); + } + } + + return { + baseURL: site.getBaseURL(), + response: { site: SiteDto.toJSON(site), status: 200 }, + }; + }), + ); + + if (hasUpdates === true) { + await dataAccess.updateConfiguration(ConfigurationDto.toJSON(configuration)); + } + + return createResponse(responses, 207); + } catch (error) { + return internalServerError(error.message || 'Failed to enable/disable audits for all provided sites'); + } + }; + + return { + update, + }; +}; diff --git a/src/index.js b/src/index.js index eef68821..fe130b92 100644 --- a/src/index.js +++ b/src/index.js @@ -43,6 +43,7 @@ import SitesController from './controllers/sites.js'; import ExperimentsController from './controllers/experiments.js'; import HooksController from './controllers/hooks.js'; import SlackController from './controllers/slack.js'; +import SitesAuditsController from './controllers/sites-audits.js'; import trigger from './controllers/trigger.js'; // prevents webpack build error @@ -97,6 +98,7 @@ async function run(request, context) { FulfillmentController(context), ImportController(context), AssistantController(context), + SitesAuditsController(context), ); const routeMatch = matchPath(method, suffix, routeHandlers); diff --git a/src/routes/index.js b/src/routes/index.js index 1bd38ec1..f66c4394 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -53,6 +53,7 @@ function isStaticRoute(routePattern) { * @param {Object} fulfillmentController - The fulfillment controller. * @param {Object} importController - The import controller. * @param {Object} assistantController - The assistant controller. + * @param {Object} sitesAuditsController - The sites audits controller. * @return {{staticRoutes: {}, dynamicRoutes: {}}} - An object with static and dynamic routes. */ export default function getRouteHandlers( @@ -67,6 +68,7 @@ export default function getRouteHandlers( fulfillmentController, importController, assistantController, + sitesAuditsController, ) { const staticRoutes = {}; const dynamicRoutes = {}; @@ -101,6 +103,7 @@ export default function getRouteHandlers( 'PATCH /sites/:siteId/:auditType': auditsController.patchAuditForSite, 'GET /sites/:siteId/audits/latest': auditsController.getAllLatestForSite, 'GET /sites/:siteId/latest-audit/:auditType': auditsController.getLatestForSite, + 'PATCH /sites/audits': sitesAuditsController.update, 'GET /sites/:siteId/experiments': experimentsController.getExperiments, 'GET /sites/:siteId/key-events': sitesController.getKeyEventsBySiteID, 'POST /sites/:siteId/key-events': sitesController.createKeyEvent, diff --git a/src/support/slack/commands.js b/src/support/slack/commands.js index 4fa51fb3..fd760b64 100644 --- a/src/support/slack/commands.js +++ b/src/support/slack/commands.js @@ -22,6 +22,7 @@ import runScrape from './commands/run-scrape.js'; import setLiveStatus from './commands/set-live-status.js'; import getGoogleLink from './commands/create-google-link.js'; import help from './commands/help.js'; +import updateSitesAudits from './commands/update-sites-audits.js'; /** * Returns all commands. @@ -42,4 +43,5 @@ export default (context) => [ setLiveStatus(context), getGoogleLink(context), help(context), + updateSitesAudits(context), ]; diff --git a/src/support/slack/commands/update-sites-audits.js b/src/support/slack/commands/update-sites-audits.js new file mode 100644 index 00000000..04c7d037 --- /dev/null +++ b/src/support/slack/commands/update-sites-audits.js @@ -0,0 +1,112 @@ +/* + * Copyright 2024 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 BaseCommand from './base.js'; +import { extractURLFromSlackInput } from '../../../utils/slack/base.js'; +import { ConfigurationDto } from '../../../dto/configuration.js'; + +const PHRASES = ['audits']; + +export default (context) => { + const baseCommand = BaseCommand({ + id: 'sites--audits', + name: 'Update Sites Audits', + description: 'Enables or disables audits for multiple sites.', + phrases: PHRASES, + usageText: `${PHRASES[0]} {enable/disable} {site1,site2,...} {auditType1,auditType2,...}`, + }); + + const { log, dataAccess } = context; + + const validateInput = ({ baseURLs, enableAudits, auditTypes }) => { + if (!Array.isArray(baseURLs) || baseURLs.length === 0) { + throw new Error('Base URLs are required'); + } + + if (!Array.isArray(auditTypes) || auditTypes.length === 0) { + throw new Error('Audit types are required'); + } + + if (enableAudits.length === 0) { + throw new Error('enable/disable value is required'); + } + + if (['enable', 'disable'].includes(enableAudits) === false) { + throw new Error(`Invalid enable/disable value: ${enableAudits}`); + } + }; + + const handleExecution = async (args, slackContext) => { + const { say } = slackContext; + const [enableAuditsInput, baseURLsInput, auditTypesInput] = args; + + const enableAudits = enableAuditsInput.toLowerCase(); + const baseURLs = baseURLsInput.length > 0 ? baseURLsInput.split(',') : []; + if (baseURLs) { + baseURLs.map((baseURL) => extractURLFromSlackInput(baseURL)); + } + const auditTypes = auditTypesInput.length > 0 ? auditTypesInput.split(',') : []; + + try { + validateInput({ baseURLs, enableAudits, auditTypes }); + } catch (error) { + await say(error.message); + return; + } + + try { + let hasUpdates = false; + const configuration = await dataAccess.getConfiguration(); + + const responses = await Promise.all( + baseURLs + .map(async (baseURL) => { + const site = await dataAccess.getSiteByBaseURL(extractURLFromSlackInput(baseURL)); + + if (!site) { + return { payload: `Cannot update site with baseURL: ${baseURL}, site not found` }; + } + + hasUpdates = true; + for (const auditType of auditTypes) { + if (enableAudits === 'enable') { + configuration.enableHandlerForSite(auditType, site); + } else { + configuration.disableHandlerForSite(auditType, site); + } + } + + return { payload: `${site.getBaseURL()}: successfully updated` }; + }), + ); + + if (hasUpdates === true) { + await dataAccess.updateConfiguration(ConfigurationDto.toJSON(configuration)); + } + + const message = `Bulk update completed with the following responses:\n${responses + .map((response) => response.payload) + .join('\n')}\n`; + + await say(message); + } catch (error) { + log.error(error); + await say(`:nuclear-warning: Failed to enable audits for all provided sites: ${error.message}`); + } + }; + + baseCommand.init(context); + + return { + ...baseCommand, + handleExecution, + }; +}; diff --git a/test/controllers/sites-audits.test.js b/test/controllers/sites-audits.test.js new file mode 100644 index 00000000..afa501da --- /dev/null +++ b/test/controllers/sites-audits.test.js @@ -0,0 +1,284 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import { use, expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; + +import SitesAuditsController from '../../src/controllers/sites-audits.js'; +import { SiteDto } from '../../src/dto/site.js'; + +use(chaiAsPromised); + +describe('Sites Audits Controller', () => { + const sandbox = sinon.createSandbox(); + + const sites = [ + { id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge' }, + { id: 'site2', baseURL: 'https://site2.com', deliveryType: 'aem_edge' }, + ].map((site) => SiteDto.fromJson(site)); + + const controllerFunctions = [ + 'update', + ]; + + let mockConfiguration; + let mockDataAccess; + let sitesAuditsController; + + const checkBadRequestFailure = (response, error, errorMessage) => { + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(response.status).to.equal(400); + expect(error).to.have.property('message', errorMessage); + }; + + beforeEach(() => { + mockConfiguration = { + enableHandlerForSite: sandbox.stub(), + disableHandlerForSite: sandbox.stub(), + getVersion: sandbox.stub(), + getJobs: sandbox.stub(), + getHandlers: sandbox.stub(), + getQueues: sandbox.stub(), + }; + + mockDataAccess = { + getConfiguration: sandbox.stub().resolves(mockConfiguration), + getSiteByBaseURL: sandbox.stub(), + updateConfiguration: sandbox.stub(), + }; + + sitesAuditsController = SitesAuditsController(mockDataAccess); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('contains all controller functions', () => { + controllerFunctions.forEach((funcName) => { + expect(sitesAuditsController).to.have.property(funcName); + }); + }); + + it('does not contain any unexpected functions', () => { + Object.keys(sitesAuditsController).forEach((funcName) => { + expect(controllerFunctions).to.include(funcName); + }); + }); + + it('updates multiple sites and returns their responses', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(sites[0]); + mockDataAccess.getSiteByBaseURL.withArgs('https://site2.com').resolves(sites[1]); + + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'], enableAudits: true }, + { baseURL: 'https://site1.com', auditTypes: ['404'], enableAudits: true }, + { baseURL: 'https://site2.com', auditTypes: ['cwv'], enableAudits: false }, + { baseURL: 'https://site2.com', auditTypes: ['404'], enableAudits: false }, + ]; + const response = await sitesAuditsController.update({ + data: requestData, + }); + + expect(mockDataAccess.getSiteByBaseURL.callCount).to.equal(4); + expect(mockDataAccess.updateConfiguration.called).to.be.true; + + expect(mockConfiguration.enableHandlerForSite.calledTwice).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('cwv', sites[0])).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('404', sites[0])).to.be.true; + + expect(mockConfiguration.disableHandlerForSite.calledTwice).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('cwv', sites[1])).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('404', sites[1])).to.be.true; + + expect(response.status).to.equal(207); + const multiResponse = await response.json(); + + expect(multiResponse).to.be.an('array').with.lengthOf(4); + expect(multiResponse[0].baseURL).to.equal('https://site1.com'); + expect(multiResponse[0].response.status).to.equal(200); + expect(multiResponse[1].baseURL).to.equal('https://site1.com'); + expect(multiResponse[1].response.status).to.equal(200); + + expect(multiResponse[2].baseURL).to.equal('https://site2.com'); + expect(multiResponse[2].response.status).to.equal(200); + expect(multiResponse[3].baseURL).to.equal('https://site2.com'); + expect(multiResponse[3].response.status).to.equal(200); + }); + + it('updates multiple sites with multiple audits and returns their responses', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(sites[0]); + mockDataAccess.getSiteByBaseURL.withArgs('https://site2.com').resolves(sites[1]); + + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv', '404'], enableAudits: true }, + { baseURL: 'https://site2.com', auditTypes: ['cwv', '404'], enableAudits: false }, + ]; + const response = await sitesAuditsController.update({ + data: requestData, + }); + + expect(mockDataAccess.getSiteByBaseURL.callCount).to.equal(2); + expect(mockDataAccess.updateConfiguration.called).to.be.true; + + expect(mockConfiguration.enableHandlerForSite.calledTwice).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('cwv', sites[0])).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('404', sites[0])).to.be.true; + + expect(mockConfiguration.disableHandlerForSite.calledTwice).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('cwv', sites[1])).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('404', sites[1])).to.be.true; + + expect(response.status).to.equal(207); + const multiResponse = await response.json(); + + expect(multiResponse).to.be.an('array').with.lengthOf(2); + expect(multiResponse[0].baseURL).to.equal('https://site1.com'); + expect(multiResponse[0].response.status).to.equal(200); + expect(multiResponse[1].baseURL).to.equal('https://site2.com'); + expect(multiResponse[1].response.status).to.equal(200); + }); + + describe('bad request errors', () => { + it('returns bad request when baseURL is not provided', async () => { + const requestData = [ + { auditTypes: ['cwv'], enableAudits: true }, + ]; + + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'Base URL is required'); + }); + + it('returns bad request when baseURL is empty', async () => { + const requestData = [ + { baseURL: '', auditTypes: ['cwv'], enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'Base URL is required'); + }); + + it('returns bad request when baseURL has wrong format', async () => { + const requestData = [ + { baseURL: 'wrong_format', auditTypes: ['cwv'], enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'Invalid Base URL format: wrong_format'); + }); + + it('returns bad request when auditTypes is not provided', async () => { + const requestData = [ + { baseURL: 'https://site1.com', enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'Audit types are required'); + }); + + it('returns bad request when auditTypes has wrong format', async () => { + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: 'not_array', enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'Audit types are required'); + }); + + it('returns bad request when enableAudits is not provided', async () => { + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'] }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'The "enableAudits" flag is required'); + }); + + it('returns bad request when enableAudits has wrong format', async () => { + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'], enableAudits: 'not_boolean' }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + checkBadRequestFailure(response, error, 'The "enableAudits" flag should be boolean'); + }); + }); + + describe('misc errors', () => { + it('throws an error if data access is not an object', () => { + expect(() => SitesAuditsController()).to.throw('Data access required'); + }); + + it('returns not found when site is not found', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(null); + + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'], enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const responses = await response.json(); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(responses).to.be.an('array').with.lengthOf(1); + expect(responses[0].baseURL).to.equal('https://site1.com'); + expect(responses[0].response.status).to.equal(404); + expect(responses[0].response.message).to.equal('Site with baseURL: https://site1.com not found'); + }); + + it('return 500 when site cannot be updated', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(SiteDto.fromJson({ + id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge', + })); + mockDataAccess.updateConfiguration.rejects(new Error('Update operation failed')); + + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'], enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + expect(response.status).to.equal(500); + expect(error).to.have.property('message', 'Update operation failed'); + }); + + it('return 500 when site cannot be updated with empty error message', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(SiteDto.fromJson({ + id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge', + })); + mockDataAccess.updateConfiguration.rejects(new Error()); + + const requestData = [ + { baseURL: 'https://site1.com', auditTypes: ['cwv'], enableAudits: true }, + ]; + const response = await sitesAuditsController.update({ data: requestData }); + const error = await response.json(); + + expect(response.status).to.equal(500); + expect(error).to.have.property('message', 'Failed to enable/disable audits for all provided sites'); + }); + }); +}); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index debcd891..acb71249 100644 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -78,6 +78,10 @@ describe('getRouteHandlers', () => { processImportAssistant: sinon.stub(), }; + const mockSitesAuditsController = { + update: sinon.stub(), + }; + it('segregates static and dynamic routes', () => { const { staticRoutes, dynamicRoutes } = getRouteHandlers( mockAuditsController, @@ -91,6 +95,7 @@ describe('getRouteHandlers', () => { mockFulfillmentController, mockImportController, mockAssistantController, + mockSitesAuditsController, ); expect(staticRoutes).to.have.all.keys( @@ -110,6 +115,7 @@ describe('getRouteHandlers', () => { 'POST /slack/channels/invite-by-user-id', 'POST /tools/import/jobs', 'POST /tools/import/assistant/prompt', + 'PATCH /sites/audits', ); expect(staticRoutes['GET /configurations']).to.equal(mockConfigurationController.getAll); diff --git a/test/support/slack/commands/update-sites-audits.test.js b/test/support/slack/commands/update-sites-audits.test.js new file mode 100644 index 00000000..1cb9d1df --- /dev/null +++ b/test/support/slack/commands/update-sites-audits.test.js @@ -0,0 +1,189 @@ +/* + * Copyright 2024 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 sinon from 'sinon'; +import { expect } from 'chai'; +import UpdateSitesAuditsCommand from '../../../../src/support/slack/commands/update-sites-audits.js'; +import { SiteDto } from '../../../../src/dto/site.js'; + +describe('UpdateSitesAuditsCommand', () => { + const sandbox = sinon.createSandbox(); + + const sites = [ + { id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge' }, + { id: 'site2', baseURL: 'https://site2.com', deliveryType: 'aem_edge' }, + ].map((site) => SiteDto.fromJson(site)); + + let mockConfiguration; + let mockDataAccess; + let mockContext; + let mockSlackContext; + + beforeEach(async () => { + mockConfiguration = { + enableHandlerForSite: sandbox.stub(), + disableHandlerForSite: sandbox.stub(), + getVersion: sandbox.stub(), + getJobs: sandbox.stub(), + getHandlers: sandbox.stub(), + getQueues: sandbox.stub(), + }; + + mockDataAccess = { + getConfiguration: sandbox.stub().resolves(mockConfiguration), + getSiteByBaseURL: sandbox.stub(), + updateConfiguration: sandbox.stub(), + }; + + mockContext = { + log: { + error: sinon.stub(), + }, + dataAccess: mockDataAccess, + }; + + mockSlackContext = { + say: sinon.stub(), + }; + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('successful enable execution with multiple sites', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(sites[0]); + mockDataAccess.getSiteByBaseURL.withArgs('https://site2.com').resolves(sites[1]); + + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['enable', 'http://site1.com,https://site2.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockDataAccess.getSiteByBaseURL.calledTwice).to.be.true; + expect(mockDataAccess.updateConfiguration.called).to.be.true; + + expect(mockConfiguration.enableHandlerForSite.callCount).to.equal(4); + expect(mockConfiguration.enableHandlerForSite.calledWith('cwv', sites[0])).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('cwv', sites[1])).to.be.true; + + expect(mockConfiguration.enableHandlerForSite.calledWith('404', sites[0])).to.be.true; + expect(mockConfiguration.enableHandlerForSite.calledWith('404', sites[1])).to.be.true; + + expect(mockSlackContext.say.calledWith('Bulk update completed with the following responses:\n' + + 'https://site1.com: successfully updated\n' + + 'https://site2.com: successfully updated\n')).to.be.true; + }); + + it('successful disable execution with multiple sites', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(sites[0]); + mockDataAccess.getSiteByBaseURL.withArgs('https://site2.com').resolves(sites[1]); + + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['disable', 'http://site1.com,https://site2.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockDataAccess.getSiteByBaseURL.calledTwice).to.be.true; + expect(mockDataAccess.updateConfiguration.called).to.be.true; + + expect(mockConfiguration.disableHandlerForSite.callCount).to.equal(4); + expect(mockConfiguration.disableHandlerForSite.calledWith('cwv', sites[0])).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('cwv', sites[1])).to.be.true; + + expect(mockConfiguration.disableHandlerForSite.calledWith('404', sites[0])).to.be.true; + expect(mockConfiguration.disableHandlerForSite.calledWith('404', sites[1])).to.be.true; + + expect(mockSlackContext.say.calledWith('Bulk update completed with the following responses:\n' + + 'https://site1.com: successfully updated\n' + + 'https://site2.com: successfully updated\n')).to.be.true; + }); + + describe('bad requests', () => { + it('returns bad request when baseURLs is not provided', async () => { + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['enable', '', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(mockSlackContext.say.calledWith('Base URLs are required')).to.be.true; + }); + + it('returns bad request when auditTypes is not provided', async () => { + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['enable', 'http://site1.com,https://site2.com', '']; + await command.handleExecution(args, mockSlackContext); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(mockSlackContext.say.calledWith('Audit types are required')).to.be.true; + }); + + it('returns bad request when enableAudits is not provided', async () => { + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['', 'http://site1.com,https://site2.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(mockSlackContext.say.calledWith('enable/disable value is required')).to.be.true; + }); + + it('returns bad request when enableAudits has wrong format', async () => { + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['wrong_format', 'http://site1.com,https://site2.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + expect(mockSlackContext.say.calledWith('Invalid enable/disable value: wrong_format')).to.be.true; + }); + + it('returns not found when site is not found', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(null); + + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['enable', 'http://site1.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockConfiguration.enableHandlerForSite.called).to.be.false; + expect(mockConfiguration.disableHandlerForSite.called).to.be.false; + expect(mockDataAccess.updateConfiguration.called).to.be.false; + + expect(mockSlackContext.say.calledWith('Bulk update completed with the following responses:\n' + + 'Cannot update site with baseURL: http://site1.com, site not found\n')).to.be.true; + }); + }); + + describe('misc errors', () => { + it('error during execution', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(sites[0]); + + const error = new Error('Test error'); + mockDataAccess.updateConfiguration.rejects(error); + + const command = UpdateSitesAuditsCommand(mockContext); + const args = ['enable', 'http://site1.com', 'cwv,404']; + await command.handleExecution(args, mockSlackContext); + + expect(mockContext.log.error.calledWith(error)).to.be.true; + expect( + mockSlackContext.say.calledWith(':nuclear-warning: Failed to enable audits for all provided sites: Test error'), + ).to.be.true; + }); + }); +});