From f4e2edf0f34275fcbd6a0f25aded4a13f389bdff Mon Sep 17 00:00:00 2001 From: Ravi Verma Date: Wed, 25 Feb 2026 13:47:21 +0530 Subject: [PATCH] feat: auto fix api v2 version to support session token --- docs/openapi/api.yaml | 2 + docs/openapi/site-opportunities.yaml | 65 ++++- src/controllers/suggestions.js | 180 ++++++++++++++ src/routes/index.js | 1 + test/controllers/suggestions.test.js | 348 +++++++++++++++++++++++++++ test/routes/index.test.js | 1 + 6 files changed, 596 insertions(+), 1 deletion(-) diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index e4a036128..0b7f9edaf 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -238,6 +238,8 @@ paths: $ref: './site-opportunities.yaml#/site-opportunity-suggestions-status' /sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix: $ref: './site-opportunities.yaml#/site-opportunity-suggestions-auto-fix' + /sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix-v2: + $ref: './site-opportunities.yaml#/site-opportunity-suggestions-auto-fix-v2' /sites/{siteId}/site-enrollments: $ref: './site-enrollments-api.yaml#/site-enrollments-by-site' /sites/{siteId}/user-activities: diff --git a/docs/openapi/site-opportunities.yaml b/docs/openapi/site-opportunities.yaml index a49c894f0..cd1cecd37 100644 --- a/docs/openapi/site-opportunities.yaml +++ b/docs/openapi/site-opportunities.yaml @@ -510,9 +510,14 @@ site-opportunity-suggestions-auto-fix: - $ref: './parameters.yaml#/opportunityId' patch: operationId: autoFixSiteOpportunitySuggestions + deprecated: true summary: | - Trigger autofix for one or multiple suggestions + Trigger autofix for one or multiple suggestions (deprecated) description: | + **Deprecated** — use the v2 endpoint (`/sites/{siteId}/opportunities/{opportunityId}/suggestions/auto-fix-v2`) + instead, which reads the promise token from the request `promise_token` cookie rather + than creating one from the Authorization header via IMS. + Triggers the autofix worker for the given suggestions. By default, suggestions are transitioned to IN_PROGRESS status before the message is queued. When `action` is set to `"assess"`, suggestions remain in their current status (no IN_PROGRESS transition) @@ -556,6 +561,64 @@ site-opportunity-suggestions-auto-fix: security: - ims_key: [ ] +site-opportunity-suggestions-auto-fix-v2: + parameters: + - $ref: './parameters.yaml#/siteId' + - $ref: './parameters.yaml#/opportunityId' + patch: + operationId: autoFixSiteOpportunitySuggestionsV2 + summary: | + Trigger autofix for one or multiple suggestions (v2) + description: | + Triggers the autofix worker for the given suggestions. The promise token is read from + the `promise_token` cookie on the request instead of being created from the Authorization + header via IMS (as in v1). Returns 400 if the cookie is missing or empty. + + By default, suggestions are transitioned to IN_PROGRESS status before the message is + queued. When `action` is set to `"assess"`, suggestions remain in their current status + (no IN_PROGRESS transition) and the worker performs a read-only fixability assessment + instead. + tags: + - opportunity-suggestions + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + suggestionIds: + type: array + items: + $ref: './schemas.yaml#/Id' + action: + type: string + description: | + Optional action to forward to the autofix worker. When set to `"assess"`, + the suggestion status transition to IN_PROGRESS is skipped and the worker + performs a read-only fixability assessment. + customData: + description: Additional custom data to forward to the autofix worker + required: + - suggestionIds + + responses: + '207': + description: | + A list of suggestions that are sent to autofix, + or the status code and error message for the ones failed. + content: + application/json: + schema: + $ref: './schemas.yaml#/SuggestionStatusUpdateListResponse' + examples: + broken-backlinks-suggestions-status-update-response: + $ref: './examples.yaml#/broken-backlinks-suggestions-status-update-response' + '400': + description: Promise token cookie is missing or empty. + security: + - ims_key: [ ] + site-opportunity-suggestion: parameters: - $ref: './parameters.yaml#/siteId' diff --git a/src/controllers/suggestions.js b/src/controllers/suggestions.js index 2f9d78af6..816488517 100644 --- a/src/controllers/suggestions.js +++ b/src/controllers/suggestions.js @@ -765,6 +765,10 @@ function SuggestionsController(ctx, sqs, env) { }; return createResponse(fullResponse, 207); }; + /** + * @deprecated Use autofixSuggestionsV2 instead, which reads the promise token from + * the request cookie rather than creating one from the Authorization header via IMS. + */ const autofixSuggestions = async (context) => { const siteId = context.params?.siteId; const opportunityId = context.params?.opportunityId; @@ -950,6 +954,181 @@ function SuggestionsController(ctx, sqs, env) { return createResponse(response, 207); }; + const autofixSuggestionsV2 = async (context) => { + const siteId = context.params?.siteId; + const opportunityId = context.params?.opportunityId; + + if (!isValidUUID(siteId)) { + return badRequest('Site ID required'); + } + + if (!isValidUUID(opportunityId)) { + return badRequest('Opportunity ID required'); + } + + if (!isNonEmptyObject(context.data)) { + return badRequest('No updates provided'); + } + const { + suggestionIds, variations, action, customData, + } = context.data; + + if (!isArray(suggestionIds)) { + return badRequest('Request body must be an array of suggestionIds'); + } + if (variations && !isArray(variations)) { + return badRequest('variations must be an array'); + } + if (action !== undefined && !hasText(action)) { + return badRequest('action cannot be empty'); + } + const site = await Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + + if (!await accessControlUtil.hasAccess(site, 'auto_fix')) { + return forbidden('User does not belong to the organization or does not have sufficient permissions'); + } + + const opportunity = await Opportunity.findById(opportunityId); + if (!opportunity || opportunity.getSiteId() !== siteId) { + return notFound('Opportunity not found'); + } + const configuration = await Configuration.findLatest(); + if (!configuration.isHandlerEnabledForSite(`${opportunity.getType()}-auto-fix`, site)) { + return badRequest(`Handler is not enabled for site ${site.getId()} autofix type ${opportunity.getType()}`); + } + const suggestions = await Suggestion.allByOpportunityId( + opportunityId, + ); + const validSuggestions = []; + const failedSuggestions = []; + suggestions.forEach((suggestion) => { + if (suggestionIds.includes(suggestion.getId())) { + /* c8 ignore start */ + if (isDomainWideSuggestion(suggestion)) { + failedSuggestions.push({ + uuid: suggestion.getId(), + index: suggestionIds.indexOf(suggestion.getId()), + message: 'Domain-wide aggregate suggestions cannot be auto-fixed individually', + statusCode: 400, + }); + /* c8 ignore stop */ + } else if (suggestion.getStatus() === SuggestionModel.STATUSES.NEW) { + validSuggestions.push(suggestion); + } else { + failedSuggestions.push({ + uuid: suggestion.getId(), + index: suggestionIds.indexOf(suggestion.getId()), + message: 'Suggestion is not in NEW status', + statusCode: 400, + }); + } + } + }); + + let suggestionGroups; + if (shouldGroupSuggestionsForAutofix(opportunity.getType())) { + const opportunityData = opportunity.getData(); + const suggestionsByUrl = validSuggestions.reduce((acc, suggestion) => { + const data = suggestion.getData(); + const url = data?.url || data?.recommendations?.[0]?.pageUrl + || data?.url_from + || data?.urlFrom + || opportunityData?.page; + if (!url) return acc; + + if (!acc[url]) { + acc[url] = []; + } + acc[url].push(suggestion); + return acc; + }, {}); + + suggestionGroups = Object.entries(suggestionsByUrl).map(([url, groupedSuggestions]) => ({ + groupedSuggestions, + url, + })); + } + + suggestionIds.forEach((suggestionId, index) => { + if (!suggestions.find((s) => s.getId() === suggestionId)) { + failedSuggestions.push({ + uuid: suggestionId, + index, + message: 'Suggestion not found', + statusCode: 404, + }); + } + }); + let succeededSuggestions = []; + if (isNonEmptyArray(validSuggestions)) { + succeededSuggestions = await Suggestion.bulkUpdateStatus( + validSuggestions, + SuggestionModel.STATUSES.IN_PROGRESS, + ); + } + + const cookieHeader = context.pathInfo?.headers?.cookie || ''; + const promiseTokenMatch = cookieHeader.match(/(?:^|;\s*)promise_token=([^;]*)/); + const promiseTokenValue = promiseTokenMatch?.[1]; + if (!hasText(promiseTokenValue)) { + return badRequest('Promise token cookie is required'); + } + const promiseTokenResponse = { promise_token: promiseTokenValue }; + + const response = { + suggestions: [ + ...succeededSuggestions.map((suggestion) => ({ + uuid: suggestion.getId(), + index: suggestionIds.indexOf(suggestion.getId()), + statusCode: 200, + suggestion: SuggestionDto.toJSON(suggestion), + })), + ...failedSuggestions, + ], + metadata: { + total: suggestionIds.length, + success: succeededSuggestions.length, + failed: failedSuggestions.length, + }, + }; + response.suggestions.sort((a, b) => a.index - b.index); + const { AUTOFIX_JOBS_QUEUE: queueUrl } = env; + + if (shouldGroupSuggestionsForAutofix(opportunity.getType())) { + await Promise.all( + suggestionGroups.map(({ groupedSuggestions, url }) => sendAutofixMessage( + sqs, + queueUrl, + siteId, + opportunityId, + groupedSuggestions.map((s) => s.getId()), + promiseTokenResponse, + variations, + action, + customData, + { url }, + )), + ); + } else { + await sendAutofixMessage( + sqs, + queueUrl, + siteId, + opportunityId, + succeededSuggestions.map((s) => s.getId()), + promiseTokenResponse, + variations, + action, + customData, + ); + } + + return createResponse(response, 207); + }; + const removeSuggestion = async (context) => { const siteId = context.params?.siteId; const opportunityId = context.params?.opportunityId; @@ -1955,6 +2134,7 @@ function SuggestionsController(ctx, sqs, env) { return { autofixSuggestions, + autofixSuggestionsV2, createSuggestions, deploySuggestionToEdge, rollbackSuggestionFromEdge, diff --git a/src/routes/index.js b/src/routes/index.js index b6f1ac92d..4a75b63ca 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -216,6 +216,7 @@ export default function getRouteHandlers( 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit/:cursor': suggestionsController.getAllForOpportunityPaged, 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit': suggestionsController.getAllForOpportunityPaged, 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix': suggestionsController.autofixSuggestions, + 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix-v2': suggestionsController.autofixSuggestionsV2, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-deploy': suggestionsController.deploySuggestionToEdge, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-rollback': suggestionsController.rollbackSuggestionFromEdge, 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-preview': suggestionsController.previewSuggestions, diff --git a/test/controllers/suggestions.test.js b/test/controllers/suggestions.test.js index 6e20ad276..8dc68f889 100644 --- a/test/controllers/suggestions.test.js +++ b/test/controllers/suggestions.test.js @@ -145,6 +145,7 @@ describe('Suggestions Controller', () => { const suggestionsFunctions = [ 'autofixSuggestions', + 'autofixSuggestionsV2', 'createSuggestions', 'getAllForOpportunity', 'getAllForOpportunityPaged', @@ -3298,6 +3299,353 @@ describe('Suggestions Controller', () => { }); }); + describe('autofixSuggestionsV2', () => { + afterEach(() => { + sandbox.restore(); + }); + + it('returns 400 when promise_token cookie is missing', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves( + [mockSuggestionEntity(suggs[0])], + ); + mockSuggestion.bulkUpdateStatus.resolves( + [mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' })], + ); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + pathInfo: { headers: {} }, + ...context, + }); + + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Promise token cookie is required'); + }); + + it('returns 400 when promise_token cookie is empty', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves( + [mockSuggestionEntity(suggs[0])], + ); + mockSuggestion.bulkUpdateStatus.resolves( + [mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' })], + ); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + pathInfo: { headers: { cookie: 'other_cookie=abc' } }, + ...context, + }); + + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Promise token cookie is required'); + }); + + it('returns 400 when cookie header is absent', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves( + [mockSuggestionEntity(suggs[0])], + ); + mockSuggestion.bulkUpdateStatus.resolves( + [mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' })], + ); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Promise token cookie is required'); + }); + + it('triggers autofix with promise token from cookie', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves( + [mockSuggestionEntity(suggs[0]), mockSuggestionEntity(suggs[2])], + ); + mockSuggestion.bulkUpdateStatus.resolves([ + mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }), + mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' }), + ]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[2]] }, + pathInfo: { headers: { cookie: 'session=xyz; promise_token=myPromiseToken123; other=val' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const bulkPatchResponse = await response.json(); + expect(bulkPatchResponse.metadata).to.have.property('total', 2); + expect(bulkPatchResponse.metadata).to.have.property('success', 2); + expect(bulkPatchResponse.metadata).to.have.property('failed', 0); + + expect(mockSqs.sendMessage).to.have.been.calledOnce; + const sqsCallArgs = mockSqs.sendMessage.firstCall.args; + expect(sqsCallArgs[1]).to.have.property('promiseToken'); + expect(sqsCallArgs[1].promiseToken).to.deep.equal({ promise_token: 'myPromiseToken123' }); + }); + + it('extracts promise_token when it is the only cookie', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves( + [mockSuggestionEntity(suggs[0])], + ); + mockSuggestion.bulkUpdateStatus.resolves([ + mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }), + ]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { + siteId: SITE_ID, + opportunityId: OPPORTUNITY_ID, + }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + pathInfo: { headers: { cookie: 'promise_token=singleToken' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const sqsCallArgs = mockSqs.sendMessage.firstCall.args; + expect(sqsCallArgs[1].promiseToken).to.deep.equal({ promise_token: 'singleToken' }); + }); + + it('returns 400 for bad siteId', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Site ID required'); + }); + + it('returns 400 for bad opportunityId', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Opportunity ID required'); + }); + + it('returns 400 when no data is provided', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'No updates provided'); + }); + + it('returns 400 when suggestionIds is not an array', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: 'not-an-array' }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'Request body must be an array of suggestionIds'); + }); + + it('returns 400 when variations is not an array', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]], variations: 'not-an-array' }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'variations must be an array'); + }); + + it('returns 400 when action is empty', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]], action: '' }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error).to.have.property('message', 'action cannot be empty'); + }); + + it('returns 404 when site not found', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID_NOT_FOUND, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + expect(response.status).to.equal(404); + const error = await response.json(); + expect(error).to.have.property('message', 'Site not found'); + }); + + it('returns 404 when opportunity not found', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID_NOT_FOUND }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + expect(response.status).to.equal(404); + const error = await response.json(); + expect(error).to.have.property('message', 'Opportunity not found'); + }); + + it('returns 400 when handler is not enabled for site', async () => { + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID_NOT_ENABLED, opportunityId: OPPORTUNITY_ID_NOT_ENABLED }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + ...context, + }); + expect(response.status).to.equal(400); + const error = await response.json(); + expect(error.message).to.include('Handler is not enabled for site'); + }); + + it('returns 207 with failed suggestions when suggestion not found', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves([mockSuggestionEntity(suggs[2])]); + mockSuggestion.bulkUpdateStatus.resolves([mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' })]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: ['not-found', SUGGESTION_IDS[2]] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const body = await response.json(); + expect(body.metadata).to.have.property('total', 2); + expect(body.metadata).to.have.property('success', 1); + expect(body.metadata).to.have.property('failed', 1); + expect(body.suggestions[0]).to.have.property('statusCode', 404); + }); + + it('returns 207 with failed suggestions when suggestion is not in NEW status', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves([mockSuggestionEntity(suggs[1])]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[1]] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const body = await response.json(); + expect(body.metadata).to.have.property('failed', 1); + expect(body.suggestions[0]).to.have.property('statusCode', 400); + expect(body.suggestions[0]).to.have.property('message', 'Suggestion is not in NEW status'); + }); + + it('does not call bulkUpdateStatus when no valid suggestions exist', async () => { + opportunity.getType = sandbox.stub().returns('meta-tags'); + mockSuggestion.allByOpportunityId.resolves([]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: ['not-found'] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(207); + expect(mockSuggestion.bulkUpdateStatus).to.not.have.been.called; + }); + + it('returns 403 when user does not have access', async () => { + sandbox.stub(AccessControlUtil.prototype, 'hasAccess').resolves(false); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0]] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(403); + const error = await response.json(); + expect(error).to.have.property('message', 'User does not belong to the organization or does not have sufficient permissions'); + }); + + it('sends ungrouped autofix message for broken-backlinks type', async () => { + opportunity.getType = sandbox.stub().returns('broken-backlinks'); + mockSuggestion.allByOpportunityId.resolves([mockSuggestionEntity(suggs[0]), mockSuggestionEntity(suggs[2])]); + mockSuggestion.bulkUpdateStatus.resolves([ + mockSuggestionEntity({ ...suggs[0], status: 'IN_PROGRESS' }), + mockSuggestionEntity({ ...suggs[2], status: 'IN_PROGRESS' }), + ]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[2]] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const body = await response.json(); + expect(body.metadata).to.have.property('success', 2); + expect(mockSqs.sendMessage).to.have.been.calledOnce; + const sqsCallArgs = mockSqs.sendMessage.firstCall.args; + expect(sqsCallArgs[1]).to.have.property('promiseToken'); + expect(sqsCallArgs[1].promiseToken).to.deep.equal({ promise_token: 'tok123' }); + }); + + it('groups suggestions by URL for alt-text opportunity type', async () => { + opportunity.getType = sandbox.stub().returns('alt-text'); + mockSuggestion.allByOpportunityId.resolves([ + mockSuggestionEntity(altTextSuggs[0]), + mockSuggestionEntity(altTextSuggs[1]), + ]); + mockSuggestion.bulkUpdateStatus.resolves([ + mockSuggestionEntity({ ...altTextSuggs[0], status: 'IN_PROGRESS' }), + mockSuggestionEntity({ ...altTextSuggs[1], status: 'IN_PROGRESS' }), + ]); + + const response = await suggestionsController.autofixSuggestionsV2({ + params: { siteId: SITE_ID, opportunityId: OPPORTUNITY_ID }, + data: { suggestionIds: [SUGGESTION_IDS[0], SUGGESTION_IDS[1]] }, + pathInfo: { headers: { cookie: 'promise_token=tok123' } }, + ...context, + }); + + expect(response.status).to.equal(207); + const body = await response.json(); + expect(body.metadata).to.have.property('success', 2); + expect(mockSqs.sendMessage).to.have.been.called; + }); + }); + describe('removeSuggestion', () => { /* create unit test suite for this code: diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 644994bcf..46bcc4d3e 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -510,6 +510,7 @@ describe('getRouteHandlers', () => { 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit/:cursor', 'GET /sites/:siteId/opportunities/:opportunityId/suggestions/paged/:limit', 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix', + 'PATCH /sites/:siteId/opportunities/:opportunityId/suggestions/auto-fix-v2', 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-deploy', 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-rollback', 'POST /sites/:siteId/opportunities/:opportunityId/suggestions/edge-preview',