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
27 changes: 23 additions & 4 deletions src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,8 @@
);
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
const filteredEntities = filterByExcludedPaths(grantedEntities, opportunity, site.getConfig?.());

Check failure on line 464 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 464 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
const suggestions = filteredEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok(suggestions);
Expand All @@ -470,7 +471,7 @@
/**
* Gets a page of suggestions for a given site and opportunity
* @param {Object} context of the request
* @param {number} context.params.limit - Number of suggestions per page. Default=100.

Check failure on line 474 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 474 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
* @param {number} context.params.cursor - The next cursor or null for first page.
* @param {string} context.params.view - Projection view: 'minimal', 'summary', or 'full'.
* @returns {Promise<Response>} Array of suggestions response.
Expand Down Expand Up @@ -527,7 +528,8 @@
}
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
const filteredEntities = filterByExcludedPaths(grantedEntities, opportunity, site.getConfig?.());

Check failure on line 531 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 531 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
const suggestions = filteredEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);

Expand All @@ -536,7 +538,7 @@
pagination: {
limit,
cursor: newCursor ?? null,
hasMore: !!newCursor,

Check failure on line 541 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 541 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
},
});
};
Expand Down Expand Up @@ -590,7 +592,8 @@
}
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
const filteredEntities = filterByExcludedPaths(grantedEntities, opportunity, site.getConfig?.());

Check failure on line 595 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 595 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
const suggestions = filteredEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok(suggestions);
Expand All @@ -599,7 +602,7 @@
/**
* Gets all suggestions for a given site, opportunity and status
* @param {Object} context of the request
* @returns {Promise<Response>} Array of suggestions response.

Check failure on line 605 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 605 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
*/
const getByStatusPaged = async (context) => {
const siteId = context.params?.siteId;
Expand Down Expand Up @@ -656,7 +659,8 @@
}
}
const grantedEntities = await filterByGrantStatus(site, suggestionEntities, context);
const suggestions = grantedEntities.map(
const filteredEntities = filterByExcludedPaths(grantedEntities, opportunity, site.getConfig?.());

Check failure on line 662 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 662 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
const suggestions = filteredEntities.map(
(sugg) => SuggestionDto.toJSON(sugg, view, opportunity, locale),
);
return ok({
Expand All @@ -665,7 +669,7 @@
limit,
cursor: newCursor ?? null,
hasMore: !!newCursor,
},

Check failure on line 672 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

'filterByExcludedPaths' was used before it was defined

Check failure on line 672 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 101. Maximum allowed is 100
});
};

Expand Down Expand Up @@ -1187,6 +1191,21 @@
: null)
|| opp?.getData()?.page;

const filterByExcludedPaths = (suggestions, opportunity, siteConfig) => {
const excludedPrefixes = siteConfig?.getHandlerConfig?.(opportunity?.getType?.())?.excludedPathPrefixes;

Check failure on line 1195 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 108. Maximum allowed is 100
if (!excludedPrefixes || excludedPrefixes.length === 0) return suggestions;

Check failure on line 1196 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

Expected { after 'if' condition
return suggestions.filter((suggestion) => {
const url = getSuggestionUrl(suggestion.getData(), opportunity);
if (!url) return true;
try {
const { pathname } = new URL(url);
return !excludedPrefixes.some((prefix) => pathname.startsWith(prefix));
} catch {
return true;
}

Check failure on line 1205 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

This line has a length of 108. Maximum allowed is 100
});

Check failure on line 1206 in src/controllers/suggestions.js

View workflow job for this annotation

GitHub Actions / ci / build

Expected { after 'if' condition
};

/**
* Triggers auto-fix for the given suggestions. Validates the site, opportunity, and
* suggestions, then queues an autofix message via SQS.
Expand Down
192 changes: 192 additions & 0 deletions test/controllers/suggestions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,198 @@ describe('Suggestions Controller', () => {
expect(mockSuggestionGrant.splitSuggestionsByGrantStatus).to.have.been.calledOnce;
});

describe('filterByExcludedPaths', () => {
const EXCLUDED_PREFIX = '/jp/ja';
const jpSugg = {
id: SUGGESTION_IDS[0],
opportunityId: OPPORTUNITY_ID,
type: 'FIX_LINK',
status: 'NEW',
rank: 1,
data: { url: 'https://www.pwc.com/jp/ja/services/consulting.html' },
updatedAt: new Date(),
};
const globalSugg = {
id: SUGGESTION_IDS[1],
opportunityId: OPPORTUNITY_ID,
type: 'FIX_LINK',
status: 'NEW',
rank: 2,
data: { url: 'https://www.pwc.com/gx/en/issues.html' },
updatedAt: new Date(),
};
const noUrlSugg = {
id: SUGGESTION_IDS[2],
opportunityId: OPPORTUNITY_ID,
type: 'FIX_LINK',
status: 'NEW',
rank: 3,
data: {},
updatedAt: new Date(),
};
const invalidUrlSugg = {
id: SUGGESTION_IDS[0],
opportunityId: OPPORTUNITY_ID,
type: 'FIX_LINK',
status: 'NEW',
rank: 1,
data: { url: 'not-a-valid-url' },
updatedAt: new Date(),
};

it('filters out suggestions whose URL starts with an excluded path prefix (getAllForOpportunity)', async () => {
mockSuggestion.allByOpportunityId.resolves([
mockSuggestionEntity(jpSugg),
mockSuggestionEntity(globalSugg),
]);
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getAllForOpportunity({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(1);
expect(suggestions[0].opportunityId).to.equal(OPPORTUNITY_ID);
});

it('keeps all suggestions when excludedPathPrefixes is empty', async () => {
mockSuggestion.allByOpportunityId.resolves([
mockSuggestionEntity(jpSugg),
mockSuggestionEntity(globalSugg),
]);
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [] }),
});
const response = await suggestionsController.getAllForOpportunity({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(2);
});

it('keeps all suggestions when getConfig returns null', async () => {
mockSuggestion.allByOpportunityId.resolves([
mockSuggestionEntity(jpSugg),
mockSuggestionEntity(globalSugg),
]);
site.getConfig = sandbox.stub().returns(null);
const response = await suggestionsController.getAllForOpportunity({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(2);
});

it('keeps suggestions with no URL when prefix filter is active', async () => {
const noUrlSuggEntity = {
...mockSuggestionEntity(noUrlSugg),
getOpportunity: () => ({
getSiteId: () => SITE_ID,
getType: () => 'test-opportunity-type',
getData: () => ({}),
}),
};
mockSuggestion.allByOpportunityId.resolves([noUrlSuggEntity]);
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getAllForOpportunity({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(1);
});

it('keeps suggestions with invalid URL when prefix filter is active', async () => {
mockSuggestion.allByOpportunityId.resolves([
mockSuggestionEntity(invalidUrlSugg),
]);
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getAllForOpportunity({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(1);
});

it('filters out suggestions matching excluded prefix in getByStatus', async () => {
const jpSuggWithStatus = { ...jpSugg, status: 'NEW' };
const globalSuggWithStatus = { ...globalSugg, status: 'NEW' };
mockSuggestion.allByOpportunityIdAndStatus.resolves([
mockSuggestionEntity(jpSuggWithStatus),
mockSuggestionEntity(globalSuggWithStatus),
]);
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getByStatus({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID, status: 'NEW' },
...context,
});
expect(response.status).to.equal(200);
const suggestions = await response.json();
expect(suggestions).to.be.an('array').with.lengthOf(1);
});

it('filters out suggestions matching excluded prefix in getAllForOpportunityPaged', async () => {
mockSuggestionResults = {
data: [mockSuggestionEntity(jpSugg), mockSuggestionEntity(globalSugg)],
cursor: undefined,
};
mockSuggestion.allByOpportunityId.callsFake((opptyId, options) => {
if (options) return Promise.resolve(mockSuggestionResults);
return Promise.resolve([mockSuggestionEntity(suggs[0])]);
});
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getAllForOpportunityPaged({
params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID, limit: '10' },
...context,
});
expect(response.status).to.equal(200);
const body = await response.json();
expect(body.suggestions).to.be.an('array').with.lengthOf(1);
});

it('filters out suggestions matching excluded prefix in getByStatusPaged', async () => {
mockSuggestion.allByOpportunityIdAndStatus.callsFake((opptyId, status, options) => {
if (options) {
return Promise.resolve({
data: [mockSuggestionEntity(jpSugg), mockSuggestionEntity(globalSugg)],
cursor: undefined,
});
}
return Promise.resolve([mockSuggestionEntity(jpSugg)]);
});
site.getConfig = sandbox.stub().returns({
getHandlerConfig: () => ({ excludedPathPrefixes: [EXCLUDED_PREFIX] }),
});
const response = await suggestionsController.getByStatusPaged({
params: {
siteId: SITE_ID, opportunityId: OPPORTUNITY_ID, status: 'NEW', limit: '10',
},
...context,
});
expect(response.status).to.equal(200);
const body = await response.json();
expect(body.suggestions).to.be.an('array').with.lengthOf(1);
});
});

it('does not call grantSuggestionsForOpportunity when no suggestions exist', async () => {
const grantStub = sandbox.stub().resolves();
mockSuggestion.allByOpportunityId.resolves([]);
Expand Down
Loading