diff --git a/packages/spacecat-shared-data-access/src/models/configuration/configuration.model.js b/packages/spacecat-shared-data-access/src/models/configuration/configuration.model.js index 0d82430bd..bca25f70d 100644 --- a/packages/spacecat-shared-data-access/src/models/configuration/configuration.model.js +++ b/packages/spacecat-shared-data-access/src/models/configuration/configuration.model.js @@ -14,7 +14,6 @@ import { isNonEmptyObject, isNonEmptyArray } from '@adobe/spacecat-shared-utils' import { sanitizeIdAndAuditFields } from '../../util/util.js'; import BaseModel from '../base/base.model.js'; -import { Audit } from '../audit/index.js'; import { Entitlement } from '../entitlement/index.js'; /** @@ -47,6 +46,11 @@ class Configuration extends BaseModel { FORTNIGHTLY_SUNDAY: 'fortnightly-sunday', MONTHLY: 'monthly', }; + + static AUDIT_NAME_REGEX = /^[a-z0-9-]+$/; + + static AUDIT_NAME_MAX_LENGTH = 37; + // add your custom methods or overrides here getHandler(type) { @@ -253,23 +257,186 @@ class Configuration extends BaseModel { this.updateHandlerOrgs(type, orgId, false); } + /** + * Updates the queue URLs configuration by merging with existing queues. + * Only the specified queue URLs will be updated; others remain unchanged. + * + * @param {object} queues - Queue URLs to update (merged with existing) + * @throws {Error} If queues object is empty or invalid + */ + updateQueues(queues) { + if (!isNonEmptyObject(queues)) { + throw new Error('Queues configuration cannot be empty'); + } + const existingQueues = this.getQueues() || {}; + const mergedQueues = { ...existingQueues, ...queues }; + this.setQueues(mergedQueues); + } + + /** + * Updates a job's properties (interval, group). + * + * @param {string} type - The job type to update + * @param {object} properties - Properties to update (interval, group) + * @throws {Error} If job not found or properties are invalid + */ + updateJob(type, properties) { + const jobs = this.getJobs(); + const jobIndex = jobs.findIndex((job) => job.type === type); + + if (jobIndex === -1) { + throw new Error(`Job type "${type}" not found in configuration`); + } + + if (properties.interval && !Object.values(Configuration.JOB_INTERVALS) + .includes(properties.interval)) { + throw new Error(`Invalid interval "${properties.interval}". Must be one of: ${Object.values(Configuration.JOB_INTERVALS).join(', ')}`); + } + + if (properties.group && !Object.values(Configuration.JOB_GROUPS).includes(properties.group)) { + throw new Error(`Invalid group "${properties.group}". Must be one of: ${Object.values(Configuration.JOB_GROUPS).join(', ')}`); + } + + jobs[jobIndex] = { ...jobs[jobIndex], ...properties }; + this.setJobs(jobs); + } + + /** + * Updates a handler's properties. + * + * @param {string} type - The handler type to update + * @param {object} properties - Properties to update + * @throws {Error} If handler not found or properties are invalid + */ + updateHandlerProperties(type, properties) { + const handlers = this.getHandlers(); + if (!handlers[type]) { + throw new Error(`Handler "${type}" not found in configuration`); + } + + if (properties.productCodes !== undefined) { + if (!isNonEmptyArray(properties.productCodes)) { + throw new Error('productCodes must be a non-empty array'); + } + const validProductCodes = Object.values(Entitlement.PRODUCT_CODES); + if (!properties.productCodes.every((pc) => validProductCodes.includes(pc))) { + throw new Error('Invalid product codes provided'); + } + } + + if (isNonEmptyArray(properties.dependencies)) { + for (const dep of properties.dependencies) { + if (!handlers[dep.handler]) { + throw new Error(`Dependency handler "${dep.handler}" does not exist in configuration`); + } + } + } + + if (properties.movingAvgThreshold !== undefined && properties.movingAvgThreshold < 1) { + throw new Error('movingAvgThreshold must be greater than or equal to 1'); + } + + if (properties.percentageChangeThreshold !== undefined && properties + .percentageChangeThreshold < 1) { + throw new Error('percentageChangeThreshold must be greater than or equal to 1'); + } + + handlers[type] = { ...handlers[type], ...properties }; + this.setHandlers(handlers); + } + + /** + * Updates the configuration by merging changes into existing sections. + * This is a flexible update method that allows updating one or more sections at once. + * Changes are merged, not replaced - existing data is preserved. + * + * @param {object} data - Configuration data to update + * @param {object} [data.handlers] - Handlers to merge (adds new, updates existing) + * @param {Array} [data.jobs] - Jobs to merge (updates matching jobs by type) + * @param {object} [data.queues] - Queues to merge (updates specific queue URLs) + * @throws {Error} If validation fails + */ + updateConfiguration(data) { + if (!isNonEmptyObject(data)) { + throw new Error('Configuration data cannot be empty'); + } + + if (data.handlers !== undefined) { + if (!isNonEmptyObject(data.handlers)) { + throw new Error('Handlers must be a non-empty object if provided'); + } + const existingHandlers = this.getHandlers() || {}; + const mergedHandlers = { ...existingHandlers }; + + Object.keys(data.handlers).forEach((handlerType) => { + mergedHandlers[handlerType] = { + ...existingHandlers[handlerType], + ...data.handlers[handlerType], + }; + }); + + this.setHandlers(mergedHandlers); + } + + if (data.jobs !== undefined) { + if (!isNonEmptyArray(data.jobs)) { + throw new Error('Jobs must be a non-empty array if provided'); + } + const existingJobs = this.getJobs() || []; + const mergedJobs = [...existingJobs]; + + data.jobs.forEach((newJob) => { + const existingIndex = mergedJobs.findIndex( + (job) => job.type === newJob.type && job.group === newJob.group, + ); + + if (existingIndex !== -1) { + mergedJobs[existingIndex] = { ...mergedJobs[existingIndex], ...newJob }; + } else { + mergedJobs.push(newJob); + } + }); + + this.setJobs(mergedJobs); + } + + if (data.queues !== undefined) { + if (!isNonEmptyObject(data.queues)) { + throw new Error('Queues must be a non-empty object if provided'); + } + const existingQueues = this.getQueues() || {}; + const mergedQueues = { ...existingQueues, ...data.queues }; + + this.setQueues(mergedQueues); + } + } + registerAudit( type, enabledByDefault = false, interval = Configuration.JOB_INTERVALS.NEVER, productCodes = [], ) { - // Validate audit type - if (!Object.values(Audit.AUDIT_TYPES).includes(type)) { - throw new Error(`Audit type ${type} is not a valid audit type in the data model`); + if (!type || typeof type !== 'string' || type.trim() === '') { + throw new Error('Audit type must be a non-empty string'); + } + + if (type.length > Configuration.AUDIT_NAME_MAX_LENGTH) { + throw new Error(`Audit type must not exceed ${Configuration.AUDIT_NAME_MAX_LENGTH} characters`); + } + if (!Configuration.AUDIT_NAME_REGEX.test(type)) { + throw new Error('Audit type can only contain lowercase letters, numbers, and hyphens'); + } + + const handlers = this.getHandlers(); + if (handlers && handlers[type]) { + throw new Error(`Audit type "${type}" is already registered`); } - // Validate job interval if (!Object.values(Configuration.JOB_INTERVALS).includes(interval)) { throw new Error(`Invalid interval ${interval}`); } - // Validate product codes if (!isNonEmptyArray(productCodes)) { throw new Error('No product codes provided'); } @@ -277,26 +444,22 @@ class Configuration extends BaseModel { throw new Error('Invalid product codes provided'); } - // Add to handlers if not already registered - const handlers = this.getHandlers(); - if (!handlers[type]) { - handlers[type] = { - enabledByDefault, - enabled: { - sites: [], - orgs: [], - }, - disabled: { - sites: [], - orgs: [], - }, - dependencies: [], - productCodes, - }; - this.setHandlers(handlers); - } - - // Add to jobs if not already registered + const updatedHandlers = handlers || {}; + updatedHandlers[type] = { + enabledByDefault, + enabled: { + sites: [], + orgs: [], + }, + disabled: { + sites: [], + orgs: [], + }, + dependencies: [], + productCodes, + }; + this.setHandlers(updatedHandlers); + const jobs = this.getJobs(); const exists = jobs.find((job) => job.group === 'audits' && job.type === type); if (!exists) { @@ -310,19 +473,18 @@ class Configuration extends BaseModel { } unregisterAudit(type) { - // Validate audit type - if (!Object.values(Audit.AUDIT_TYPES).includes(type)) { - throw new Error(`Audit type ${type} is not a valid audit type in the data model`); + if (!type || typeof type !== 'string' || type.trim() === '') { + throw new Error('Audit type must be a non-empty string'); } - // Remove from handlers const handlers = this.getHandlers(); - if (handlers[type]) { - delete handlers[type]; - this.setHandlers(handlers); + if (!handlers || !handlers[type]) { + throw new Error(`Audit type "${type}" is not registered`); } - // Remove from jobs + delete handlers[type]; + this.setHandlers(handlers); + const jobs = this.getJobs(); const jobIndex = jobs.findIndex((job) => job.group === 'audits' && job.type === type); if (jobIndex !== -1) { diff --git a/packages/spacecat-shared-data-access/src/models/configuration/configuration.schema.js b/packages/spacecat-shared-data-access/src/models/configuration/configuration.schema.js index 8a39f3d4a..8d0ced0da 100755 --- a/packages/spacecat-shared-data-access/src/models/configuration/configuration.schema.js +++ b/packages/spacecat-shared-data-access/src/models/configuration/configuration.schema.js @@ -40,7 +40,7 @@ const handlerSchema = Joi.object().pattern(Joi.string(), Joi.object( actions: Joi.array().items(Joi.string()), }, )), - productCodes: Joi.array().items(Joi.string()), + productCodes: Joi.array().items(Joi.string()).min(1).required(), }, )).unknown(true); diff --git a/packages/spacecat-shared-data-access/src/models/configuration/index.js b/packages/spacecat-shared-data-access/src/models/configuration/index.js index c8704d91a..f4ec7ea73 100644 --- a/packages/spacecat-shared-data-access/src/models/configuration/index.js +++ b/packages/spacecat-shared-data-access/src/models/configuration/index.js @@ -12,8 +12,10 @@ import Configuration from './configuration.model.js'; import ConfigurationCollection from './configuration.collection.js'; +import { checkConfiguration } from './configuration.schema.js'; export { Configuration, ConfigurationCollection, + checkConfiguration, }; diff --git a/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js index 544aba250..594d002ad 100644 --- a/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js +++ b/packages/spacecat-shared-data-access/test/fixtures/configurations.fixture.js @@ -53,18 +53,22 @@ const configurations = [ handlers: { 404: { enabledByDefault: true, + productCodes: ['ASO'], }, 'rum-ingest': { enabledByDefault: false, + productCodes: ['ASO'], enabled: { sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'], }, }, 'organic-keywords': { enabledByDefault: false, + productCodes: ['ASO'], }, cwv: { enabledByDefault: true, + productCodes: ['ASO'], disabled: { sites: [ '5d6d4439-6659-46c2-b646-92d110fa5a52', @@ -83,6 +87,7 @@ const configurations = [ }, sitemap: { enabledByDefault: true, + productCodes: ['ASO'], enabled: { sites: [], orgs: [], @@ -94,6 +99,7 @@ const configurations = [ }, 'lhs-mobile': { enabledByDefault: false, + productCodes: ['ASO'], enabled: { sites: ['c6f41da6-3a7e-4a59-8b8d-2da742ac2dbe'], orgs: ['757ceb98-05c8-4e07-bb23-bc722115b2b0'], diff --git a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js index 2eafafe34..d045b523f 100644 --- a/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js +++ b/packages/spacecat-shared-data-access/test/it/configuration/configuration.test.js @@ -77,6 +77,7 @@ describe('Configuration IT', async () => { const data = { enabledByDefault: true, + productCodes: ['ASO'], enabled: { sites: ['site1'], orgs: ['org1'], diff --git a/packages/spacecat-shared-data-access/test/unit/models/configuration/configuration.model.test.js b/packages/spacecat-shared-data-access/test/unit/models/configuration/configuration.model.test.js index 734146d08..25076ee01 100755 --- a/packages/spacecat-shared-data-access/test/unit/models/configuration/configuration.model.test.js +++ b/packages/spacecat-shared-data-access/test/unit/models/configuration/configuration.model.test.js @@ -173,6 +173,16 @@ describe('ConfigurationModel', () => { delete instance.record.jobs; expect(instance.getDisabledAuditsForSite(site)).to.deep.equal([]); }); + + it('returns empty array for enabled audits when handlers is null', () => { + delete instance.record.handlers; + expect(instance.getEnabledAuditsForSite(site)).to.deep.equal([]); + }); + + it('returns empty array for enabled audits when jobs is null', () => { + delete instance.record.jobs; + expect(instance.getEnabledAuditsForSite(site)).to.deep.equal([]); + }); }); describe('manage handlers', () => { @@ -185,6 +195,27 @@ describe('ConfigurationModel', () => { expect(instance.getHandler('new-handler')).to.deep.equal(handlerData); }); + it('adds a new handler when handlers object is null', () => { + delete instance.record.handlers; + + const handlerData = { + enabledByDefault: true, + }; + + instance.addHandler('first-handler', handlerData); + expect(instance.getHandler('first-handler')).to.deep.equal(handlerData); + }); + + it('checks if handler is enabled for site when disabled.orgs is missing', () => { + instance.addHandler('test-missing-orgs', { + enabledByDefault: true, + disabled: { sites: [] }, + }); + + const isEnabled = instance.isHandlerEnabledForSite('test-missing-orgs', site); + expect(isEnabled).to.be.true; + }); + it('updates handler orgs for a handler disabled by default with enabled', () => { instance.updateHandlerOrgs('lhs-mobile', org.getId(), true); expect(instance.getHandler('lhs-mobile').enabled.orgs).to.include(org.getId()); @@ -280,6 +311,101 @@ describe('ConfigurationModel', () => { expect(instance.getHandler('organic-keywords').enabled.orgs).to.not.include(org.getId()); }); + it('disables a handler for a site when not enabled (early return)', () => { + const handler = instance.getHandler('organic-keywords'); + const initialState = JSON.parse(JSON.stringify(handler)); + + instance.disableHandlerForSite('organic-keywords', site); + + expect(instance.getHandler('organic-keywords')).to.deep.equal(initialState); + }); + + it('disables a handler for an organization when not enabled (early return)', () => { + const handler = instance.getHandler('organic-keywords'); + const initialState = JSON.parse(JSON.stringify(handler)); + + instance.disableHandlerForOrg('organic-keywords', org); + + expect(instance.getHandler('organic-keywords')).to.deep.equal(initialState); + }); + + it('disables a handler enabled by default when disabled array does not exist', () => { + instance.addHandler('test-handler-enabled', { + enabledByDefault: true, + }); + + instance.disableHandlerForSite('test-handler-enabled', site); + + expect(instance.getHandler('test-handler-enabled').disabled.sites).to.include(site.getId()); + }); + + it('enables a handler not enabled by default when enabled array does not exist', () => { + instance.addHandler('test-handler-not-enabled', { + enabledByDefault: false, + }); + + instance.enableHandlerForSite('test-handler-not-enabled', site); + + expect(instance.getHandler('test-handler-not-enabled').enabled.sites).to.include(site.getId()); + }); + + it('enables a handler enabled by default that was previously disabled', () => { + instance.disableHandlerForSite('404', site); + expect(instance.getHandler('404').disabled.sites).to.include(site.getId()); + + instance.enableHandlerForSite('404', site); + expect(instance.getHandler('404').disabled.sites).to.not.include(site.getId()); + }); + + it('handles handler with disabled object missing sites array', () => { + instance.addHandler('test-handler-partial-disabled', { + enabledByDefault: true, + disabled: { orgs: [] }, + }); + + instance.disableHandlerForSite('test-handler-partial-disabled', site); + + expect(instance.getHandler('test-handler-partial-disabled').disabled.sites).to.include(site.getId()); + }); + + it('handles handler with enabled object missing sites array', () => { + instance.addHandler('test-handler-partial-enabled', { + enabledByDefault: false, + enabled: { orgs: [] }, + }); + + instance.enableHandlerForSite('test-handler-partial-enabled', site); + + expect(instance.getHandler('test-handler-partial-enabled').enabled.sites).to.include(site.getId()); + }); + + it('handles handler with disabled object missing orgs array when checking if enabled', () => { + instance.addHandler('test-handler-no-orgs', { + enabledByDefault: true, + disabled: { sites: [] }, + }); + + const isEnabled = instance.isHandlerEnabledForOrg('test-handler-no-orgs', org); + expect(isEnabled).to.be.true; + }); + + it('returns early when trying to enable a non-existent handler', () => { + const handlers = instance.getHandlers(); + const initialHandlers = JSON.parse(JSON.stringify(handlers)); + + instance.enableHandlerForSite('non-existent-handler', site); + + expect(instance.getHandlers()).to.deep.equal(initialHandlers); + }); + + it('disables a handler not enabled by default when enabled array exists', () => { + instance.enableHandlerForSite('organic-keywords', site); + expect(instance.getHandler('organic-keywords').enabled.sites).to.include(site.getId()); + + instance.disableHandlerForSite('organic-keywords', site); + expect(instance.getHandler('organic-keywords').enabled.sites).to.not.include(site.getId()); + }); + it('registers a new audit', () => { const auditType = 'structured-data'; instance.registerAudit(auditType, true, 'weekly', ['ASO']); @@ -303,20 +429,96 @@ describe('ConfigurationModel', () => { }); }); - it('throws error when registering an invalid audit type', () => { - expect(() => instance.registerAudit('invalid-audit-type', true, 'weekly')).to.throw(Error, 'Audit type invalid-audit-type is not a valid audit type in the data model'); + it('throws error when registering an empty audit type', () => { + expect(() => instance.registerAudit('', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type must be a non-empty string'); + }); + + it('throws error when registering a null audit type', () => { + expect(() => instance.registerAudit(null, true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type must be a non-empty string'); + }); + + it('throws error when audit name exceeds 37 characters', () => { + const longAuditType = 'this-is-a-very-long-audit-name-that-exceeds-37-characters'; + expect(() => instance.registerAudit(longAuditType, true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type must not exceed 37 characters'); + }); + + it('throws error when audit name contains invalid characters', () => { + expect(() => instance.registerAudit('invalid@audit!', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when audit name contains spaces', () => { + expect(() => instance.registerAudit('invalid audit', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when audit name contains underscores', () => { + expect(() => instance.registerAudit('invalid_audit', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when audit name contains uppercase letters', () => { + expect(() => instance.registerAudit('MyAudit', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when audit name contains mixed case letters', () => { + expect(() => instance.registerAudit('my-Custom-Audit', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when audit name starts with uppercase letter', () => { + expect(() => instance.registerAudit('Custom-audit', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type can only contain lowercase letters, numbers, and hyphens'); + }); + + it('throws error when registering an already registered audit', () => { + expect(() => instance.registerAudit('404', true, 'weekly', ['ASO'])).to.throw(Error, 'Audit type "404" is already registered'); + }); + + it('allows registering any valid audit type as string', () => { + const auditType = 'my-custom-audit-123'; + instance.registerAudit(auditType, true, 'weekly', ['ASO']); + expect(instance.getHandler(auditType)).to.deep.equal({ + enabledByDefault: true, + dependencies: [], + disabled: { + sites: [], + orgs: [], + }, + enabled: { + sites: [], + orgs: [], + }, + productCodes: ['ASO'], + }); + expect(instance.getJobs().find((job) => job.group === 'audits' && job.type === auditType)).to.deep.equal({ + group: 'audits', + type: 'my-custom-audit-123', + interval: 'weekly', + }); + }); + + it('registers audit when handlers is null', () => { + const getHandlersStub = stub(instance, 'getHandlers'); + getHandlersStub.onFirstCall().returns(null); + getHandlersStub.callThrough(); + + const auditType = 'first-audit'; + instance.registerAudit(auditType, true, 'daily', ['ASO']); + + const handler = instance.getHandler(auditType); + expect(handler).to.exist; + expect(handler.enabledByDefault).to.be.true; + expect(handler.productCodes).to.deep.equal(['ASO']); + + getHandlersStub.restore(); }); it('throws error when registering an invalid job interval', () => { - expect(() => instance.registerAudit('lhs-mobile', true, 'invalid-interval')).to.throw(Error, 'Invalid interval invalid-interval'); + expect(() => instance.registerAudit('new-test-audit', true, 'invalid-interval', ['ASO'])).to.throw(Error, 'Invalid interval invalid-interval'); }); it('throws error when registering an invalid product code', () => { - expect(() => instance.registerAudit('lhs-mobile', true, 'weekly', ['invalid'])).to.throw(Error, 'Invalid product codes provided'); + expect(() => instance.registerAudit('new-test-audit-2', true, 'weekly', ['invalid'])).to.throw(Error, 'Invalid product codes provided'); }); it('throws error when registering an empty product code', () => { - expect(() => instance.registerAudit('lhs-mobile', true, 'weekly', [])).to.throw(Error, 'No product codes provided'); + expect(() => instance.registerAudit('new-test-audit-3', true, 'weekly', [])).to.throw(Error, 'No product codes provided'); }); it('unregisters an audit', () => { @@ -326,8 +528,387 @@ describe('ConfigurationModel', () => { expect(instance.getJobs().find((job) => job.group === 'audits' && job.type === auditType)).to.be.undefined; }); - it('throws error when unregistering an invalid audit type', () => { - expect(() => instance.unregisterAudit('invalid-audit-type')).to.throw(Error, 'Audit type invalid-audit-type is not a valid audit type in the data model'); + it('throws error when unregistering an empty audit type', () => { + expect(() => instance.unregisterAudit('')).to.throw(Error, 'Audit type must be a non-empty string'); + }); + + it('throws error when unregistering a null audit type', () => { + expect(() => instance.unregisterAudit(null)).to.throw(Error, 'Audit type must be a non-empty string'); + }); + + it('throws error when unregistering a non-existent audit', () => { + expect(() => instance.unregisterAudit('non-existent-audit')).to.throw(Error, 'Audit type "non-existent-audit" is not registered'); + }); + }); + + describe('updateQueues', () => { + it('merges single queue URL while keeping others', () => { + const existingQueues = instance.getQueues(); + + instance.updateQueues({ + audits: 'sqs://new-audit-queue', + }); + + const updatedQueues = instance.getQueues(); + expect(updatedQueues.audits).to.equal('sqs://new-audit-queue'); + expect(updatedQueues.imports).to.equal(existingQueues.imports); + expect(updatedQueues.reports).to.equal(existingQueues.reports); + expect(updatedQueues.scrapes).to.equal(existingQueues.scrapes); + }); + + it('merges multiple queue URLs while keeping others', () => { + const existingQueues = instance.getQueues(); + + instance.updateQueues({ + audits: 'sqs://new-audit-queue', + imports: 'sqs://new-import-queue', + }); + + const updatedQueues = instance.getQueues(); + expect(updatedQueues.audits).to.equal('sqs://new-audit-queue'); + expect(updatedQueues.imports).to.equal('sqs://new-import-queue'); + expect(updatedQueues.reports).to.equal(existingQueues.reports); + expect(updatedQueues.scrapes).to.equal(existingQueues.scrapes); + }); + + it('updates all queues successfully', () => { + const newQueues = { + audits: 'sqs://new-audit-queue', + imports: 'sqs://new-import-queue', + reports: 'sqs://new-report-queue', + scrapes: 'sqs://new-scrape-queue', + }; + + instance.updateQueues(newQueues); + + expect(instance.getQueues()).to.deep.equal(newQueues); + }); + + it('adds new queue type while keeping existing ones', () => { + const existingQueues = instance.getQueues(); + + instance.updateQueues({ + newQueueType: 'sqs://new-queue-type', + }); + + const updatedQueues = instance.getQueues(); + expect(updatedQueues.newQueueType).to.equal('sqs://new-queue-type'); + expect(updatedQueues.audits).to.equal(existingQueues.audits); + expect(updatedQueues.imports).to.equal(existingQueues.imports); + }); + + it('throws error when queues is not provided', () => { + expect(() => instance.updateQueues(null)).to.throw(Error, 'Queues configuration cannot be empty'); + }); + + it('throws error when queues is empty object', () => { + expect(() => instance.updateQueues({})).to.throw(Error, 'Queues configuration cannot be empty'); + }); + }); + + describe('updateJob', () => { + it('updates job interval successfully', () => { + instance.updateJob('404', { interval: 'weekly' }); + + const job = instance.getJobs().find((j) => j.type === '404'); + expect(job.interval).to.equal('weekly'); + }); + + it('updates job group successfully', () => { + instance.updateJob('404', { group: 'audits' }); + + const job = instance.getJobs().find((j) => j.type === '404'); + expect(job.group).to.equal('audits'); + }); + + it('updates both interval and group successfully', () => { + instance.updateJob('404', { interval: 'monthly', group: 'audits' }); + + const job = instance.getJobs().find((j) => j.type === '404'); + expect(job.interval).to.equal('monthly'); + expect(job.group).to.equal('audits'); + }); + + it('throws error when job type not found', () => { + expect(() => instance.updateJob('non-existent-job', { interval: 'daily' })).to.throw(Error, 'Job type "non-existent-job" not found in configuration'); + }); + + it('throws error when invalid interval provided', () => { + expect(() => instance.updateJob('404', { interval: 'invalid-interval' })).to.throw(Error, 'Invalid interval "invalid-interval"'); + }); + + it('throws error when invalid group provided', () => { + expect(() => instance.updateJob('404', { group: 'invalid-group' })).to.throw(Error, 'Invalid group "invalid-group"'); + }); + }); + + describe('updateHandlerProperties', () => { + it('updates handler enabledByDefault successfully', () => { + instance.updateHandlerProperties('404', { enabledByDefault: false }); + + const handler = instance.getHandler('404'); + expect(handler.enabledByDefault).to.be.false; + }); + + it('updates handler productCodes successfully', () => { + const newProductCodes = ['ASO', 'LLMO']; + instance.updateHandlerProperties('404', { productCodes: newProductCodes }); + + const handler = instance.getHandler('404'); + expect(handler.productCodes).to.deep.equal(newProductCodes); + }); + + it('updates handler dependencies successfully', () => { + const newDependencies = [{ handler: 'cwv', actions: ['test'] }]; + instance.updateHandlerProperties('404', { dependencies: newDependencies }); + + const handler = instance.getHandler('404'); + expect(handler.dependencies).to.deep.equal(newDependencies); + }); + + it('updates handler movingAvgThreshold successfully', () => { + instance.updateHandlerProperties('404', { movingAvgThreshold: 5 }); + + const handler = instance.getHandler('404'); + expect(handler.movingAvgThreshold).to.equal(5); + }); + + it('updates handler percentageChangeThreshold successfully', () => { + instance.updateHandlerProperties('404', { percentageChangeThreshold: 10 }); + + const handler = instance.getHandler('404'); + expect(handler.percentageChangeThreshold).to.equal(10); + }); + + it('updates multiple handler properties at once', () => { + instance.updateHandlerProperties('404', { + enabledByDefault: false, + productCodes: ['ASO'], + movingAvgThreshold: 7, + }); + + const handler = instance.getHandler('404'); + expect(handler.enabledByDefault).to.be.false; + expect(handler.productCodes).to.deep.equal(['ASO']); + expect(handler.movingAvgThreshold).to.equal(7); + }); + + it('throws error when handler not found', () => { + expect(() => instance.updateHandlerProperties('non-existent', { enabledByDefault: true })).to.throw(Error, 'Handler "non-existent" not found in configuration'); + }); + + it('throws error when productCodes is empty array', () => { + expect(() => instance.updateHandlerProperties('404', { productCodes: [] })).to.throw(Error, 'productCodes must be a non-empty array'); + }); + + it('throws error when productCodes is not an array', () => { + expect(() => instance.updateHandlerProperties('404', { productCodes: 'invalid' })).to.throw(Error, 'productCodes must be a non-empty array'); + }); + + it('throws error when invalid product code provided', () => { + expect(() => instance.updateHandlerProperties('404', { productCodes: ['INVALID_CODE'] })).to.throw(Error, 'Invalid product codes provided'); + }); + + it('throws error when dependency handler does not exist', () => { + expect(() => instance.updateHandlerProperties('404', { + dependencies: [{ handler: 'non-existent-handler', actions: ['test'] }], + })).to.throw(Error, 'Dependency handler "non-existent-handler" does not exist in configuration'); + }); + + it('throws error when movingAvgThreshold is less than 1', () => { + expect(() => instance.updateHandlerProperties('404', { movingAvgThreshold: 0 })).to.throw(Error, 'movingAvgThreshold must be greater than or equal to 1'); + }); + + it('throws error when percentageChangeThreshold is less than 1', () => { + expect(() => instance.updateHandlerProperties('404', { percentageChangeThreshold: 0 })).to.throw(Error, 'percentageChangeThreshold must be greater than or equal to 1'); + }); + }); + + describe('updateConfiguration', () => { + it('merges handlers - adds new handler while keeping existing ones', () => { + const newHandler = { + 'new-test-handler': { + enabledByDefault: true, + productCodes: ['ASO'], + }, + }; + + instance.updateConfiguration({ handlers: newHandler }); + + const updatedHandlers = instance.getHandlers(); + expect(updatedHandlers['404']).to.exist; + expect(updatedHandlers.cwv).to.exist; + expect(updatedHandlers['new-test-handler']).to.deep.equal(newHandler['new-test-handler']); + }); + + it('merges handlers - updates existing handler properties', () => { + const existingCwv = instance.getHandler('cwv'); + + instance.updateConfiguration({ + handlers: { + cwv: { + movingAvgThreshold: 20, + }, + }, + }); + + const updatedCwv = instance.getHandler('cwv'); + expect(updatedCwv.enabledByDefault).to.equal(existingCwv.enabledByDefault); + expect(updatedCwv.productCodes).to.deep.equal(existingCwv.productCodes); + expect(updatedCwv.movingAvgThreshold).to.equal(20); + }); + + it('merges jobs - updates existing job interval', () => { + instance.updateConfiguration({ + jobs: [{ group: 'audits', type: 'cwv', interval: 'weekly' }], + }); + + const updatedJobs = instance.getJobs(); + const updatedCwvJob = updatedJobs.find((j) => j.type === 'cwv'); + + expect(updatedCwvJob.interval).to.equal('weekly'); + expect(updatedJobs.length).to.be.greaterThan(1); + expect(updatedJobs.find((j) => j.type === '404')).to.exist; + }); + + it('merges jobs - adds new job while keeping existing ones', () => { + const existingJobs = instance.getJobs(); + + instance.updateConfiguration({ + jobs: [{ group: 'audits', type: 'new-test-job', interval: 'monthly' }], + }); + + const updatedJobs = instance.getJobs(); + + expect(updatedJobs.length).to.equal(existingJobs.length + 1); + expect(updatedJobs.find((j) => j.type === 'new-test-job')).to.deep.equal({ + group: 'audits', + type: 'new-test-job', + interval: 'monthly', + }); + }); + + it('merges queues - updates specific queue URLs while keeping others', () => { + const existingQueues = instance.getQueues(); + + instance.updateConfiguration({ + queues: { + audits: 'sqs://new-audit-queue', + }, + }); + + const updatedQueues = instance.getQueues(); + + expect(updatedQueues.audits).to.equal('sqs://new-audit-queue'); + expect(updatedQueues.imports).to.equal(existingQueues.imports); + expect(updatedQueues.reports).to.equal(existingQueues.reports); + }); + + it('merges multiple sections at once', () => { + instance.updateConfiguration({ + handlers: { + cwv: { movingAvgThreshold: 15 }, + }, + jobs: [{ group: 'audits', type: 'cwv', interval: 'daily' }], + queues: { audits: 'sqs://audit-queue' }, + }); + + const updatedHandlers = instance.getHandlers(); + const updatedJobs = instance.getJobs(); + const updatedQueues = instance.getQueues(); + + expect(updatedHandlers['404']).to.exist; + expect(updatedHandlers.cwv.movingAvgThreshold).to.equal(15); + expect(updatedJobs.find((j) => j.type === '404')).to.exist; + expect(updatedJobs.find((j) => j.type === 'cwv').interval).to.equal('daily'); + expect(updatedQueues.audits).to.equal('sqs://audit-queue'); + }); + + it('handles null handlers gracefully when merging', () => { + const getHandlersStub = stub(instance, 'getHandlers'); + getHandlersStub.onFirstCall().returns(null); + getHandlersStub.callThrough(); + + instance.updateConfiguration({ + handlers: { + 'new-handler': { + enabledByDefault: true, + productCodes: ['ASO'], + }, + }, + }); + + const handlers = instance.getHandlers(); + expect(handlers['new-handler']).to.exist; + expect(handlers['new-handler'].enabledByDefault).to.be.true; + + getHandlersStub.restore(); + }); + + it('handles null jobs gracefully when merging', () => { + const getJobsStub = stub(instance, 'getJobs'); + getJobsStub.onFirstCall().returns(null); + getJobsStub.callThrough(); + + instance.updateConfiguration({ + jobs: [{ + type: 'new-job', + group: 'audits', + interval: 'daily', + }], + }); + + const jobs = instance.getJobs(); + expect(jobs).to.be.an('array'); + expect(jobs.find((j) => j.type === 'new-job')).to.exist; + + getJobsStub.restore(); + }); + + it('handles null queues gracefully when merging', () => { + const getQueuesStub = stub(instance, 'getQueues'); + getQueuesStub.onFirstCall().returns(null); + getQueuesStub.callThrough(); + + instance.updateConfiguration({ + queues: { + audits: 'sqs://new-queue', + }, + }); + + const queues = instance.getQueues(); + expect(queues).to.be.an('object'); + expect(queues.audits).to.equal('sqs://new-queue'); + + getQueuesStub.restore(); + }); + + it('throws error when data is not provided', () => { + expect(() => instance.updateConfiguration(null)).to.throw(Error, 'Configuration data cannot be empty'); + }); + + it('throws error when data is empty object', () => { + expect(() => instance.updateConfiguration({})).to.throw(Error, 'Configuration data cannot be empty'); + }); + + it('throws error when handlers is not an object', () => { + expect(() => instance.updateConfiguration({ handlers: 'invalid' })).to.throw(Error, 'Handlers must be a non-empty object if provided'); + }); + + it('throws error when handlers is empty object', () => { + expect(() => instance.updateConfiguration({ handlers: {} })).to.throw(Error, 'Handlers must be a non-empty object if provided'); + }); + + it('throws error when jobs is not an array', () => { + expect(() => instance.updateConfiguration({ jobs: 'invalid' })).to.throw(Error, 'Jobs must be a non-empty array if provided'); + }); + + it('throws error when queues is not an object', () => { + expect(() => instance.updateConfiguration({ queues: 'invalid' })).to.throw(Error, 'Queues must be a non-empty object if provided'); + }); + + it('throws error when queues is empty object', () => { + expect(() => instance.updateConfiguration({ queues: {} })).to.throw(Error, 'Queues must be a non-empty object if provided'); }); });