diff --git a/src/controllers/opportunities.js b/src/controllers/opportunities.js index f0336c232..49a1f5eb7 100644 --- a/src/controllers/opportunities.js +++ b/src/controllers/opportunities.js @@ -29,6 +29,7 @@ import { ValidationError } from '@adobe/spacecat-shared-data-access'; import { OpportunityDto } from '../dto/opportunity.js'; import { OpportunitySummaryDto } from '../dto/opportunity-summary.js'; import AccessControlUtil from '../support/access-control-util.js'; +import { OPPORTUNITY_TAG_MAPPINGS } from '../utils/constants.js'; /** * Opportunities controller. @@ -53,6 +54,18 @@ function OpportunitiesController(ctx) { const accessControlUtil = AccessControlUtil.fromContext(ctx); + /** + * Gets tags for an opportunity type + * @param {string} opportunityType - The type of opportunity + * @returns {string[]} Array of tags for the opportunity type + */ + function getTagsForOpportunityType(opportunityType) { + const defaultTags = ['automated', 'spacecat']; + const typeSpecificTags = OPPORTUNITY_TAG_MAPPINGS[opportunityType] || []; + + return [...defaultTags, ...typeSpecificTags]; + } + /** * returns a response for a data access error. * If there's a ValidationError it will return a 400 response, and the @@ -182,6 +195,18 @@ function OpportunitiesController(ctx) { } context.data.siteId = siteId; + + // Get hardcoded tags based on opportunity type + const opportunityType = context.data.type; + const hardcodedTags = getTagsForOpportunityType(opportunityType); + + // Merge with any existing tags from the request + if (Array.isArray(context.data.tags)) { + context.data.tags = [...new Set([...context.data.tags, ...hardcodedTags])]; + } else { + context.data.tags = hardcodedTags; + } + try { const oppty = await Opportunity.create(context.data); return createResponse(OpportunityDto.toJSON(oppty), 201); @@ -259,9 +284,18 @@ function OpportunitiesController(ctx) { hasUpdates = true; opportunity.setGuidance(guidance); } - if (tags && !arrayEquals(tags, opportunity.getTags())) { - hasUpdates = true; - opportunity.setTags(tags); + if (tags) { + // Get hardcoded tags based on opportunity type + const opportunityType = opportunity.getType(); + const hardcodedTags = getTagsForOpportunityType(opportunityType); + + // Merge with provided tags + const mergedTags = [...new Set([...tags, ...hardcodedTags])]; + + if (!arrayEquals(mergedTags, opportunity.getTags())) { + hasUpdates = true; + opportunity.setTags(mergedTags); + } } if (hasUpdates) { opportunity.setUpdatedBy(profile.email || 'system'); diff --git a/src/utils/constants.js b/src/utils/constants.js index 7dd8fad99..e1d638b4c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -31,3 +31,47 @@ export const REPORT_TYPES = { OPTIMIZATION: 'optimization', PERFORMANCE: 'performance', }; + +/** + * Opportunity tag mappings for different opportunity types + */ +export const OPPORTUNITY_TAG_MAPPINGS = { + // Web Performance + cwv: ['Core Web Vitals', 'Web Performance'], + + // Traffic Acquisition - SEO + metatags: ['Meta Tags', 'SEO'], + 'internal-links': ['Internal links', 'SEO', 'Engagement'], + 'broken-backlinks': ['Backlinks', 'SEO'], + 'broken-internal-links': ['Backlinks', 'SEO'], + sitemap: ['Sitemap', 'SEO'], + canonical: ['Canonical URLs', 'SEO'], + hreflang: ['Hreflang', 'SEO'], + 'structured-data': ['Structured Data', 'SEO'], + 'redirect-chains': ['Redirect Chains', 'SEO'], + headings: ['Headings', 'SEO', 'Engagement'], + + // Traffic Acquisition - Paid Media + 'consent-banner': ['Consent Banner', 'Engagement'], + + // Compliance & Accessibility + 'a11y-assistive': ['ARIA Labels', 'Accessibility'], + 'color-contrast': ['Color Constrast', 'Accessibility', 'Engagement'], + 'keyboard-access': ['Keyboard Access', 'Accessibility'], + readability: ['Readbability', 'Accessibility', 'Engagement'], + 'screen-readers': ['Screen Readers', 'Accessibility'], + 'alt-text': ['Alt-Text', 'Accessibility', 'SEO'], + 'form-a11y': ['Form Accessibility', 'Accessibility', 'Engagement'], + + // Engagement & Conversion + 'high-organic-low-ctr': ['Low CTR', 'Engagement'], + 'high-page-views-low-form-views': ['Form Visibility', 'Engagement'], + 'high-page-views-low-form-nav': ['Form Placement', 'Engagement'], + 'high-form-views-low-conversions': ['Form CTR', 'Conversion'], + + // Security + 'security-xss': ['Cross Site Scripting', 'Security'], + 'security-libraries': ['3rd Party Libraries', 'Security'], + 'security-permissions': ['Permission Settings', 'Security'], + 'security-cors': ['CORS', 'Security'], +}; diff --git a/test/controllers/opportunities.test.js b/test/controllers/opportunities.test.js index 39bacc234..6d1ce8742 100644 --- a/test/controllers/opportunities.test.js +++ b/test/controllers/opportunities.test.js @@ -362,10 +362,13 @@ describe('Opportunities Controller', () => { }); // TODO: Complete tests for OpportunitiesController - it('creates an opportunity', async () => { + it('creates an opportunity with hardcoded tags merged with existing tags', async () => { + // Reset the stub to track calls + mockOpportunity.create.resetHistory(); + const response = await opportunitiesController.createOpportunity({ params: { siteId: SITE_ID }, - data: opptys[0], + data: opptys[0], // This has tags: ['tag1', 'tag2'] }); expect(mockOpportunityDataAccess.Opportunity.create.calledOnce).to.be.true; expect(response.status).to.equal(201); @@ -373,9 +376,41 @@ describe('Opportunities Controller', () => { const opportunity = await response.json(); expect(opportunity).to.have.property('id', OPPORTUNITY_ID); expect(opportunity).to.have.property('siteId', SITE_ID); + + // Verify that hardcoded tags were added to the create call + const createCallData = mockOpportunity.create.getCall(0).args[0]; + expect(createCallData).to.have.property('tags').that.includes('automated'); + expect(createCallData).to.have.property('tags').that.includes('spacecat'); + expect(createCallData).to.have.property('tags').that.includes('tag1'); + expect(createCallData).to.have.property('tags').that.includes('tag2'); }); - it('updates an opportunity', async () => { + it('creates an opportunity with hardcoded tags when no tags exist', async () => { + // Reset the stub to track calls + mockOpportunity.create.resetHistory(); + + // Create a copy of the opportunity data without tags + const opptyWithoutTags = { ...opptys[0] }; + delete opptyWithoutTags.tags; + + const response = await opportunitiesController.createOpportunity({ + params: { siteId: SITE_ID }, + data: opptyWithoutTags, + }); + expect(mockOpportunityDataAccess.Opportunity.create.calledOnce).to.be.true; + expect(response.status).to.equal(201); + + // Verify that only hardcoded tags were added to the create call + const createCallData = mockOpportunity.create.getCall(0).args[0]; + expect(createCallData).to.have.property('tags').that.includes('automated'); + expect(createCallData).to.have.property('tags').that.includes('spacecat'); + expect(createCallData.tags).to.have.lengthOf(2); // Only the hardcoded tags + }); + + it('updates an opportunity and preserves hardcoded tags', async () => { + // Create a spy for the setTags method + const setTagsSpy = sandbox.spy(mockOpptyEntity, 'setTags'); + const response = await opportunitiesController.patchOpportunity({ ...defaultAuthAttributes, params: { @@ -398,6 +433,19 @@ describe('Opportunities Controller', () => { }, }); + // Verify that setTags was called + expect(setTagsSpy.called).to.be.true; + + // Verify the tags argument contains the expected values + const tagsArgument = setTagsSpy.firstCall.args[0]; + expect(tagsArgument).to.include('automated'); + expect(tagsArgument).to.include('spacecat'); + expect(tagsArgument).to.include('tag1'); + expect(tagsArgument).to.include('tag2'); + expect(tagsArgument).to.include('NEW'); + + setTagsSpy.restore(); + // Validate updated values expect(mockOpptyEntity.getAuditId()).to.be.equals('Audit ID NEW'); expect(mockOpptyEntity.getStatus()).to.be.equals('APPROVED');