Skip to content
29 changes: 26 additions & 3 deletions src/controllers/opportunities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -53,6 +54,16 @@ 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 typeSpecificTags = OPPORTUNITY_TAG_MAPPINGS[opportunityType] || [];
return [...typeSpecificTags];
}

/**
* returns a response for a data access error.
* If there's a ValidationError it will return a 400 response, and the
Expand Down Expand Up @@ -182,6 +193,11 @@ function OpportunitiesController(ctx) {
}

context.data.siteId = siteId;

// Get type-specific tags based on opportunity type
const opportunityType = context.data.type;
context.data.tags = getTagsForOpportunityType(opportunityType);

try {
const oppty = await Opportunity.create(context.data);
return createResponse(OpportunityDto.toJSON(oppty), 201);
Expand Down Expand Up @@ -259,9 +275,16 @@ function OpportunitiesController(ctx) {
hasUpdates = true;
opportunity.setGuidance(guidance);
}
if (tags && !arrayEquals(tags, opportunity.getTags())) {
hasUpdates = true;
opportunity.setTags(tags);
if (tags) {
// Get type-specific tags based on opportunity type
const opportunityType = opportunity.getType();
const typeSpecificTags = getTagsForOpportunityType(opportunityType);

// Use only type-specific tags
if (!arrayEquals(typeSpecificTags, opportunity.getTags())) {
hasUpdates = true;
opportunity.setTags(typeSpecificTags);
}
}
if (hasUpdates) {
opportunity.setUpdatedBy(profile.email || 'system');
Expand Down
44 changes: 44 additions & 0 deletions src/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};
51 changes: 48 additions & 3 deletions test/controllers/opportunities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,20 +362,55 @@ describe('Opportunities Controller', () => {
});

// TODO: Complete tests for OpportunitiesController
it('creates an opportunity', async () => {
it('creates an opportunity with type-specific tags only', 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);

const opportunity = await response.json();
expect(opportunity).to.have.property('id', OPPORTUNITY_ID);
expect(opportunity).to.have.property('siteId', SITE_ID);

// Verify that only type-specific tags are used
const createCallData = mockOpportunity.create.getCall(0).args[0];
expect(createCallData).to.have.property('tags');
// We no longer expect 'automated' or 'spacecat' tags
// We also don't expect input tags to be included
expect(createCallData.tags).to.be.an('array');
});

it('updates an opportunity', async () => {
it('creates an opportunity with type-specific 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 type-specific tags were added to the create call
const createCallData = mockOpportunity.create.getCall(0).args[0];
expect(createCallData).to.have.property('tags');
expect(createCallData.tags).to.be.an('array');
// We no longer expect 'automated' or 'spacecat' tags
});

it('updates an opportunity and uses only type-specific tags', async () => {
// Create a spy for the setTags method
const setTagsSpy = sandbox.spy(mockOpptyEntity, 'setTags');

const response = await opportunitiesController.patchOpportunity({
...defaultAuthAttributes,
params: {
Expand All @@ -398,6 +433,16 @@ describe('Opportunities Controller', () => {
},
});

// Verify that setTags was called
expect(setTagsSpy.called).to.be.true;

// Verify the tags argument is an array
const tagsArgument = setTagsSpy.firstCall.args[0];
expect(tagsArgument).to.be.an('array');
// We no longer expect input tags or 'automated'/'spacecat' tags

setTagsSpy.restore();

// Validate updated values
expect(mockOpptyEntity.getAuditId()).to.be.equals('Audit ID NEW');
expect(mockOpptyEntity.getStatus()).to.be.equals('APPROVED');
Expand Down