Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 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,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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
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'],
};
54 changes: 51 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 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);

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: {
Expand All @@ -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');
Expand Down