diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index 8a1d3537..5a8bc2b0 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -85,6 +85,8 @@ paths: $ref: './site-api.yaml#/site-by-base-url' /sites/with-latest-audit/{auditType}: $ref: './sites-api.yaml#/sites-with-latest-audit' + /sites/bulk-audit-config: + $ref: './sites-api.yaml#/bulk-update-audit-config' /sites/{siteId}: $ref: './site-api.yaml#/site' /sites/{siteId}/audits: diff --git a/docs/openapi/schemas.yaml b/docs/openapi/schemas.yaml index b5c8d4f0..822b31df 100644 --- a/docs/openapi/schemas.yaml +++ b/docs/openapi/schemas.yaml @@ -348,6 +348,28 @@ SiteCreate: organizationId: 'o1p2q3r4-s5t6-u7v8-w9x0-yz12x34y56z' baseURL: 'https://www.newsite.com' deliveryType: 'aem_cs' +BulkUpdateAuditConfigResponse: + type: array + items: + type: object + properties: + baseURL: + type: string + description: The base URL of the site + response: + type: object + description: The response for the site update operation + properties: + status: + type: string + description: The status of the operation + message: + type: string + description: The message of the operation + site: + $ref: './schemas.yaml#/Site' + required: + - status SiteUpdate: type: object properties: diff --git a/docs/openapi/sites-api.yaml b/docs/openapi/sites-api.yaml index 7418073e..5d4eb89e 100644 --- a/docs/openapi/sites-api.yaml +++ b/docs/openapi/sites-api.yaml @@ -125,3 +125,47 @@ sites-for-organization: $ref: './responses.yaml#/500' security: - api_key: [ ] +bulk-update-audit-config: + patch: + tags: + - site + summary: Enable/Disable audits for multiple sites/organizations + description: | + This endpoint is useful for enabling audit types for multiple sites and organizations. Or disabling audits for sites + operationId: bulkUpdateAuditConfigForSites + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + baseURLs: + type: array + items: + $ref: './schemas.yaml#/URL' + auditTypes: + type: array + items: + type: string + examples: + - 404 + - broken-backlinks + - organic-traffic + enableAudits: + type: boolean + 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#/BulkUpdateAuditConfigResponse' + '401': + $ref: './responses.yaml#/401' + '500': + $ref: './responses.yaml#/500' + security: + - admin_key: [ ] + + diff --git a/src/controllers/sites.js b/src/controllers/sites.js index 13f05505..0d9bc810 100644 --- a/src/controllers/sites.js +++ b/src/controllers/sites.js @@ -16,7 +16,7 @@ import { badRequest, noContent, notFound, - ok, + ok, internalServerError, } from '@adobe/spacecat-shared-http-utils'; import { hasText, @@ -30,6 +30,7 @@ import { SiteDto } from '../dto/site.js'; import { AuditDto } from '../dto/audit.js'; import { validateRepoUrl } from '../utils/validations.js'; import { KeyEventDto } from '../dto/key-event.js'; +import { ConfigurationDto } from '../dto/configuration.js'; /** * Sites controller. Provides methods to create, read, update and delete sites. @@ -103,6 +104,44 @@ function SitesController(dataAccess) { return ok(sites); }; + /** Bulk update audit configuration for multiple sites. + * @param {object} context - Context of the request. + * @returns {Promise} Array of sites response. + */ + const bulkUpdateSitesConfig = async (context) => { + const { baseURLs, enableAudits, auditTypes } = context.data; + + if (!Array.isArray(baseURLs) || baseURLs.length === 0) { + return badRequest('Base URLs are required'); + } + if (!Array.isArray(auditTypes) || auditTypes.length === 0) { + return badRequest('Audit types are required'); + } + if (!isBoolean(enableAudits)) { + return badRequest('enableAudits is required'); + } + const configuration = await dataAccess.getConfiguration(); + + const responses = await Promise.all(baseURLs.map(async (baseURL) => { + const site = await dataAccess.getSiteByBaseURL(baseURL); + if (!site) { + return { baseURL, errorMessage: `Site with baseURL: ${baseURL} not found`, status: 404 }; + } + auditTypes.forEach((auditType) => ( + enableAudits ? configuration.enableHandlerForSite(auditType, site) + : configuration.disableHandlerForSite(auditType, site))); + + return { baseURL: site.getBaseURL(), response: { body: SiteDto.toJSON(site), status: 200 } }; + })); + try { + await dataAccess.updateConfiguration(ConfigurationDto.toJSON(configuration)); + } catch (error) { + return internalServerError('Failed to enable audits for all provided sites'); + } + + return createResponse(responses, 207); + }; + /** * Gets all sites as an XLS file. * @returns {Promise} XLS file. @@ -361,6 +400,7 @@ function SitesController(dataAccess) { getAuditForSite, getByBaseURL, getAllByDeliveryType, + bulkUpdateSitesConfig, getByID, removeSite, updateSite, diff --git a/src/routes/index.js b/src/routes/index.js index cfe234a6..0bf611f5 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -107,6 +107,7 @@ export default function getRouteHandlers( 'GET /sites/by-base-url/:baseURL': sitesController.getByBaseURL, 'GET /sites/by-delivery-type/:deliveryType': sitesController.getAllByDeliveryType, 'GET /sites/with-latest-audit/:auditType': sitesController.getAllWithLatestAudit, + 'PATCH /sites/bulk-audit-config': sitesController.bulkUpdateSitesConfig, 'GET /slack/events': slackController.handleEvent, 'POST /slack/events': slackController.handleEvent, 'POST /slack/channels/invite-by-user-id': slackController.inviteUserToChannel, diff --git a/src/support/slack/commands.js b/src/support/slack/commands.js index 38c1da3d..b375e0cc 100644 --- a/src/support/slack/commands.js +++ b/src/support/slack/commands.js @@ -20,6 +20,7 @@ import runAudit from './commands/run-audit.js'; import runImport from './commands/run-import.js'; import setLiveStatus from './commands/set-live-status.js'; import help from './commands/help.js'; +import bulkUpdateAuditConfigs from './commands/bulk-update-audits.js'; /** * Returns all commands. @@ -37,5 +38,6 @@ export default (context) => [ runAudit(context), runImport(context), setLiveStatus(context), + bulkUpdateAuditConfigs(context), help(context), ]; diff --git a/src/support/slack/commands/bulk-update-audits.js b/src/support/slack/commands/bulk-update-audits.js new file mode 100644 index 00000000..47ed3008 --- /dev/null +++ b/src/support/slack/commands/bulk-update-audits.js @@ -0,0 +1,72 @@ +/* + * 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 = ['bulk']; +function BulkUpdateAuditConfigCommand(context) { + const baseCommand = BaseCommand({ + id: 'bulk--audits', + name: 'Bulk Update Audit Configs', + description: 'Enables or disables audits for multiple sites.', + phrases: PHRASES, + usageText: `${PHRASES[0]} {enable/disable} {site1,site2,...} {auditType1,auditType2,...}`, + }); + + const { log, dataAccess } = context; + + const handleExecution = async (args, slackContext) => { + const { say } = slackContext; + + try { + const [enableDisableInput, baseURLsInput, auditTypesInput] = args; + + const baseURLs = baseURLsInput.split(','); + const auditTypes = auditTypesInput.split(','); + + const enableAudits = enableDisableInput.toLowerCase() === 'enable'; + const configuration = await dataAccess.getConfiguration(); + + const siteResponses = await Promise.all(baseURLs.map(async (baseURLInput) => { + const baseURL = extractURLFromSlackInput(baseURLInput); + const site = await dataAccess.getSiteByBaseURL(baseURL); + if (!site) { + return { payload: `Cannot update site with baseURL: ${baseURL}, site not found` }; + } + auditTypes.forEach((auditType) => ( + enableAudits ? configuration.enableHandlerForSite(auditType, site) + : configuration.disableHandlerForSite(auditType, site))); + return { payload: `${baseURL}: successfully updated` }; + })); + await dataAccess.updateConfiguration(ConfigurationDto.toJSON(configuration)); + let message = 'Bulk update completed with the following responses:\n'; + message += siteResponses.map((response) => response.payload).join('\n'); + message += '\n'; + + await say(message); + } catch (error) { + log.error(error); + await say(`Error during bulk update: ${error.message}`); + } + }; + + baseCommand.init(context); + + return { + ...baseCommand, + handleExecution, + }; +} + +export default BulkUpdateAuditConfigCommand; diff --git a/test/controllers/sites.test.js b/test/controllers/sites.test.js index 3b87c8bf..2ae99d9f 100644 --- a/test/controllers/sites.test.js +++ b/test/controllers/sites.test.js @@ -21,6 +21,7 @@ import { createKeyEvent, KEY_EVENT_TYPES } from '@adobe/spacecat-shared-data-acc import { hasText } from '@adobe/spacecat-shared-utils'; import SitesController from '../../src/controllers/sites.js'; import { SiteDto } from '../../src/dto/site.js'; +import { OrganizationDto } from '../../src/dto/organization.js'; use(chaiAsPromised); @@ -87,6 +88,7 @@ describe('Sites Controller', () => { 'getAll', 'getAllByDeliveryType', 'getAllWithLatestAudit', + 'bulkUpdateSitesConfig', 'getAllAsCSV', 'getAllAsXLS', 'getAuditForSite', @@ -107,6 +109,7 @@ describe('Sites Controller', () => { mockDataAccess = { addSite: sandbox.stub().resolves(sites[0]), updateSite: sandbox.stub().resolves(sites[0]), + updateOrganization: sandbox.stub(), removeSite: sandbox.stub().resolves(), getSites: sandbox.stub().resolves(sites), getSitesByDeliveryType: sandbox.stub().resolves(sites), @@ -114,6 +117,7 @@ describe('Sites Controller', () => { getSiteByBaseURL: sandbox.stub().resolves(sites[0]), getSiteByID: sandbox.stub().resolves(sites[0]), getAuditForSite: sandbox.stub().resolves(sitesWithLatestAudits[0].getAudits()[0]), + getOrganizationByID: sandbox.stub(), createKeyEvent: sandbox.stub(), getKeyEventsForSite: sandbox.stub(), removeKeyEvent: sandbox.stub(), @@ -625,4 +629,121 @@ describe('Sites Controller', () => { expect(result.status).to.equal(404); expect(error).to.have.property('message', 'Site not found'); }); + describe('bulkUpdateSitesConfig', () => { + it('updates multiple sites and returns their responses', async () => { + const baseURLs = ['https://site1.com', 'https://site2.com']; + const enableAudits = true; + const auditTypes = ['type1', 'type2']; + + const site1 = SiteDto.fromJson({ id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge' }); + const site2 = SiteDto.fromJson({ id: 'site2', baseURL: 'https://site2.com', deliveryType: 'aem_edge' }); + + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(site1); + mockDataAccess.getSiteByBaseURL.withArgs('https://site2.com').resolves(site2); + + const response = await sitesController.bulkUpdateSitesConfig({ + data: { baseURLs, enableAudits, auditTypes }, + }); + + expect(mockDataAccess.getSiteByBaseURL.calledTwice).to.be.true; + expect(mockDataAccess.updateSite.calledTwice).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); + }); + + it('returns bad request when baseURLs is not provided', async () => { + const response = await sitesController.bulkUpdateSitesConfig({ data: {} }); + const error = await response.json(); + + expect(response.status).to.equal(400); + expect(error).to.have.property('message', 'Base URLs are required'); + }); + + it('returns bad request when auditTypes is not provided', async () => { + const response = await sitesController.bulkUpdateSitesConfig({ data: { baseURLs: ['https://site1.com'] } }); + const error = await response.json(); + + expect(response.status).to.equal(400); + expect(error).to.have.property('message', 'Audit types are required'); + }); + + it('returns bad request when enableAudits is not provided', async () => { + const response = await sitesController.bulkUpdateSitesConfig({ data: { baseURLs: ['https://site1.com'], auditTypes: ['type1'] } }); + const error = await response.json(); + + expect(response.status).to.equal(400); + expect(error).to.have.property('message', 'enableAudits is required'); + }); + + it('returns not found when site is not found', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(null); + + const response = await sitesController.bulkUpdateSitesConfig({ + data: { baseURLs: ['https://site1.com'], enableAudits: true, auditTypes: ['type1'] }, + }); + const responses = await response.json(); + + 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('returns 500 when org is not found', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(SiteDto.fromJson({ + id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge', organizationId: '12345678', + })); + mockDataAccess.getOrganizationByID.resolves(null); + + const response = await sitesController.bulkUpdateSitesConfig({ + data: { baseURLs: ['https://site1.com'], enableAudits: true, auditTypes: ['type1'] }, + }); + const responses = await response.json(); + + 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(500); + expect(responses[0].response.message).to.equal('Error updating site with baseURL: https://site1.com, organization with id: 12345678 organization 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.updateSite.rejects(new Error('Update site operation failed')); + const response = await sitesController.bulkUpdateSitesConfig({ + data: { baseURLs: ['https://site1.com'], enableAudits: true, auditTypes: ['type1'] }, + }); + + const responses = await response.json(); + + 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(500); + expect(responses[0].response.message).to.equal('Error updating site with with baseURL: https://site1.com, update site operation failed'); + }); + + it('return 500 when site organization cannot be updated', async () => { + mockDataAccess.getSiteByBaseURL.withArgs('https://site1.com').resolves(SiteDto.fromJson({ + id: 'site1', baseURL: 'https://site1.com', deliveryType: 'aem_edge', organizationId: '12345678', + })); + mockDataAccess.getOrganizationByID.resolves(OrganizationDto.fromJson({ name: 'Org1' })); + mockDataAccess.updateOrganization.rejects(new Error('Update organization operation failed')); + const response = await sitesController.bulkUpdateSitesConfig({ + data: { baseURLs: ['https://site1.com'], enableAudits: true, auditTypes: ['type1'] }, + }); + + const responses = await response.json(); + + 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(500); + expect(responses[0].response.message).to.equal('Error updating site with baseURL: https://site1.com, update site organization with id: 12345678 failed'); + }); + }); }); diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 2c392903..82b6ca77 100644 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -99,6 +99,7 @@ describe('getRouteHandlers', () => { 'GET /sites.xlsx', 'GET /slack/events', 'POST /slack/events', + 'PATCH /sites/bulk-audit-config', 'GET /trigger', 'POST /event/fulfillment', 'POST /slack/channels/invite-by-user-id', diff --git a/test/support/slack/commands/bulk-update-audits.test.js b/test/support/slack/commands/bulk-update-audits.test.js new file mode 100644 index 00000000..d191c11e --- /dev/null +++ b/test/support/slack/commands/bulk-update-audits.test.js @@ -0,0 +1,140 @@ +/* + * 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 BulkUpdateAuditConfigCommand from '../../../../src/support/slack/commands/bulk-update-audits.js'; + +describe('BulkUpdateAuditConfigCommand', () => { + let context; + let slackContext; + let getSiteByBaseURLStub; + let updateSiteStub; + let updateOrganizationStub; + + beforeEach(async () => { + getSiteByBaseURLStub = sinon.stub(); + updateSiteStub = sinon.stub(); + updateOrganizationStub = sinon.stub(); + context = { + log: { + error: sinon.stub(), + }, + dataAccess: { + getSiteByBaseURL: getSiteByBaseURLStub, + getOrganizationByID: sinon.stub(), + updateSite: updateSiteStub, + updateOrganization: updateOrganizationStub, + }, + }; + + slackContext = { + say: sinon.stub(), + }; + }); + afterEach(() => { + sinon.restore(); + }); + + it('should handle successful execution with multiple sites with default organization', async () => { + const args = ['enable', 'site1.com,site2.com', 'auditType1,auditType2']; + const site = { + getOrganizationId: () => 'default', + getAuditConfig: () => ({ + updateAuditTypeConfig: sinon.stub(), + }), + }; + + getSiteByBaseURLStub.resolves(site); + updateSiteStub.resolves(); + + const command = BulkUpdateAuditConfigCommand(context); + await command.handleExecution(args, slackContext); + + sinon.assert.calledWith(getSiteByBaseURLStub, 'https://site1.com'); + sinon.assert.calledWith(getSiteByBaseURLStub, 'https://site2.com'); + sinon.assert.calledTwice(updateSiteStub); + }); + + it('should handle successful execution with multiple sites belonging to organization', async () => { + const args = ['enable', 'site1.com,site2.com', 'auditType1']; + const site1 = { + getOrganizationId: () => 'organizationId', + getAuditConfig: () => ({ + updateAuditTypeConfig: sinon.stub(), + }), + }; + const site2 = { + getOrganizationId: () => 'organizationId', + getAuditConfig: () => ({ + updateAuditTypeConfig: sinon.stub(), + }), + }; + const organization = { + getAuditConfig: () => ({ + updateAuditTypeConfig: sinon.stub(), + }), + }; + getSiteByBaseURLStub.withArgs('https://site1.com').resolves(site1); + getSiteByBaseURLStub.withArgs('https://site2.com').resolves(site2); + context.dataAccess.getOrganizationByID.withArgs('organizationId').resolves(organization); + updateSiteStub.resolves(); + updateOrganizationStub.resolves(); + const command = BulkUpdateAuditConfigCommand(context); + await command.handleExecution(args, slackContext); + sinon.assert.calledTwice(updateSiteStub); + sinon.assert.calledOnce(updateOrganizationStub); + }); + + it('should handle site not found situation', async () => { + const args = ['enable', 'site1.com', 'auditType1,auditType2']; + + getSiteByBaseURLStub.resolves(null); + + const command = BulkUpdateAuditConfigCommand(context); + await command.handleExecution(args, slackContext); + + sinon.assert.calledWith(getSiteByBaseURLStub, 'https://site1.com'); + sinon.assert.notCalled(updateSiteStub); + sinon.assert.calledWith(slackContext.say, 'Bulk update completed with the following responses:\nCannot update site with baseURL: https://site1.com, site not found\n'); + }); + + it('should handle organization not found error', async () => { + const args = ['enable', 'site1.com', 'auditType1,auditType2']; + const site = { + getOrganizationId: () => 'organizationId', + }; + + getSiteByBaseURLStub.resolves(site); + + const command = BulkUpdateAuditConfigCommand(context); + await command.handleExecution(args, slackContext); + + sinon.assert.calledWith(getSiteByBaseURLStub, 'https://site1.com'); + sinon.assert.notCalled(updateSiteStub); + sinon.assert.calledWith(slackContext.say, 'Bulk update completed with the following responses:\nError updating site with baseURL: https://site1.com belonging organization with id: organizationId not found\n'); + }); + + it('should handle error during execution', async () => { + const args = ['enable', 'site1.com,site2.com', 'auditType1,auditType2']; + const error = new Error('Test error'); + + getSiteByBaseURLStub.rejects(error); + + const command = BulkUpdateAuditConfigCommand(context); + await command.handleExecution(args, slackContext); + + sinon.assert.calledWith(context.log.error, error); + sinon.assert.calledWith(slackContext.say, `Error during bulk update: ${error.message}`); + }); +});