Skip to content
Closed
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
11 changes: 10 additions & 1 deletion src/controllers/suggestions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,13 +1151,22 @@ function SuggestionsController(ctx, sqs, env) {
} else if (
suggestion.getStatus() === SuggestionModel.STATUSES.NEW
|| suggestion.getStatus() === SuggestionModel.STATUSES.PENDING_VALIDATION
// OUTDATED suggestions are re-detections of previously-FIXED rows
// where the producer found the issue still present on the live URL
// (after the publish-lag reset gate). The UI surfaces them in the
// Current tab (alt-text PR #1914) so customers can retry the deploy
// — this allowlist makes that retry actually reach the autofix
// worker instead of being silently rejected.
// bulkUpdateStatus is source-status-agnostic (verified in
// spacecat-shared-data-access), so OUTDATED → IN_PROGRESS just works.
|| suggestion.getStatus() === SuggestionModel.STATUSES.OUTDATED
) {
validSuggestions.push(suggestion);
} else {
failedSuggestions.push({
uuid: suggestion.getId(),
index: suggestionIds.indexOf(suggestion.getId()),
message: 'Suggestion must be in NEW or PENDING_VALIDATION status for auto-fix',
message: 'Suggestion must be in NEW, PENDING_VALIDATION, or OUTDATED status for auto-fix',
statusCode: 400,
});
}
Expand Down
22 changes: 17 additions & 5 deletions src/support/grant-suggestions-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,19 @@ const STALE_STATUSES = new Set([
SuggestionModel.STATUSES.PENDING_VALIDATION,
]);

const isRevocable = (status) => STALE_STATUSES.has(status)
|| status === SuggestionModel.STATUSES.NEW;
// V2 re-detected OUTDATED rows have a previousDeployment stamp and represent
// actionable work — their grants must stay live so PLG customers can re-deploy.
// V1 legacy OUTDATED rows (no stamp) are stale book-keeping and should be revoked.
const isRevocable = (suggestion) => {
const status = suggestion.getStatus();
if (status === SuggestionModel.STATUSES.OUTDATED) {
const recs = suggestion.getData?.()?.recommendations;
const isV2Redetected = Array.isArray(recs) && recs.length > 0
&& Boolean(recs[0]?.previousDeployment);
return !isV2Redetected;
}
return STALE_STATUSES.has(status) || status === SuggestionModel.STATUSES.NEW;
};

/**
* Handles a new token cycle: creates the token and migrates
Expand Down Expand Up @@ -290,7 +301,7 @@ async function handleExistingTokenCycle(
tokenGrants
.filter((g) => {
const s = grantedSuggestions.find((gs) => gs?.getId() === g.getSuggestionId());
return s && isRevocable(s.getStatus());
return s && isRevocable(s);
})
.map((g) => g.getGrantId()),
)];
Expand Down Expand Up @@ -328,9 +339,10 @@ async function fillRemainingCapacity(
*
* **When a token already exists (current cycle):**
* Fetches all grants for the current token. If any granted
* suggestion is in a revocable state (OUTDATED, REJECTED,
* suggestion is in a revocable state (V1 OUTDATED, REJECTED,
* PENDING_VALIDATION, NEW), revokes only those grants, leaving
* permanent states (e.g. APPROVED) untouched. Fills remaining
* permanent states (e.g. APPROVED) and V2 re-detected OUTDATED
* (with previousDeployment stamp) untouched. Fills remaining
* capacity from NEW ungranted suggestions.
*
* **When no token exists (new cycle):**
Expand Down
34 changes: 33 additions & 1 deletion test/controllers/suggestions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4608,7 +4608,39 @@ describe('Suggestions Controller', () => {
expect(bulkPatchResponse.suggestions[1]).to.have.property('statusCode', 400);
expect(bulkPatchResponse.suggestions[0].suggestion).to.exist;
expect(bulkPatchResponse.suggestions[1].suggestion).to.not.exist;
expect(bulkPatchResponse.suggestions[1]).to.have.property('message', 'Suggestion must be in NEW or PENDING_VALIDATION status for auto-fix');
expect(bulkPatchResponse.suggestions[1]).to.have.property('message', 'Suggestion must be in NEW, PENDING_VALIDATION, or OUTDATED status for auto-fix');
});

it('autofix suggestion accepts OUTDATED status (re-detect of previously FIXED row)', async () => {
// OUTDATED suggestions are surfaced in the Current tab by the alt-text
// UI (#1914) so customers can retry a deploy whose previous attempt
// didn't stick on the live URL. Allowing OUTDATED through this gate
// makes that retry actually reach the autofix worker.
const outdatedSugg = { ...suggs[0], status: 'OUTDATED' };
mockSuggestion.allByOpportunityId.resolves([mockSuggestionEntity(outdatedSugg)]);
mockSuggestion.bulkUpdateStatus.resolves([
mockSuggestionEntity({ ...outdatedSugg, status: 'IN_PROGRESS' }),
]);

const response = await suggestionsControllerWithMock.autofixSuggestions({
params: {
siteId: SITE_ID,
opportunityId: OPPORTUNITY_ID,
},
data: { suggestionIds: [SUGGESTION_IDS[0]] },
...context,
});

expect(response.status).to.equal(207);
const bulkPatchResponse = await response.json();
expect(bulkPatchResponse.metadata).to.have.property('total', 1);
expect(bulkPatchResponse.metadata).to.have.property('success', 1);
expect(bulkPatchResponse.metadata).to.have.property('failed', 0);
expect(bulkPatchResponse.suggestions[0]).to.have.property('statusCode', 200);
expect(bulkPatchResponse.suggestions[0].suggestion).to.exist;
// OUTDATED → IN_PROGRESS transition must occur for the worker to pick it
// up via Suggestion.allByOpportunityIdAndStatus(opp, IN_PROGRESS).
expect(mockSuggestion.bulkUpdateStatus).to.have.been.calledOnce;
});

it('groups by relationshipContext.fixTargetPageId when fixTargetGroups is provided for a groupable type', async () => {
Expand Down
106 changes: 106 additions & 0 deletions test/support/grant-suggestions-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,112 @@ describe('grant-suggestions-handler', () => {
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
});

it('does not revoke V2 re-detected OUTDATED grant (has previousDeployment stamp)', async () => {
const s1 = {
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',
};

const existingToken = { getId: () => 'tok-1', getRemaining: () => 1 };

// V2 re-detected OUTDATED: has previousDeployment stamp in data
const sugg2V2Outdated = {
getId: () => 'sugg-2',
getRank: () => 2,
getStatus: () => 'OUTDATED',
getData: () => ({
recommendations: [{ previousDeployment: { altText: 'old text', deployedAt: '2026-05-01' } }],
}),
};

const Suggestion = {
allByOpportunityIdAndStatus: sandbox.stub().resolves([s1]),
batchGetByKeys: sandbox.stub().resolves({
data: [sugg2V2Outdated],
unprocessed: [],
}),
};

const SuggestionGrant = {
splitSuggestionsByGrantStatus: sandbox.stub().resolves({
grantedIds: [],
grantIds: [],
notGrantedIds: ['sugg-1'],
}),
allByIndexKeys: sandbox.stub().resolves([
mkGrant('sugg-2', 'g2'),
]),
grantSuggestions: sandbox.stub().resolves({ success: true }),
revokeSuggestionGrant: sandbox.stub(),
};

const Token = {
findBySiteIdAndTokenType: sandbox.stub().resolves(existingToken),
};

const dataAccess = { Suggestion, SuggestionGrant, Token };

await grantSuggestionsForOpportunity(dataAccess, site, opportunity);

// V2 OUTDATED grant must NOT be revoked — it's actionable work for PLG re-deploy
expect(SuggestionGrant.revokeSuggestionGrant).to.not.have.been.called;
// Token had remaining=1, fill with the ungranted NEW suggestion
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
});

it('revokes V1 legacy OUTDATED grant (no previousDeployment stamp)', async () => {
const s1 = {
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',
};

const existingToken = { getId: () => 'tok-1', getRemaining: () => 0 };
const tokenAfterRevoke = { getId: () => 'tok-1', getRemaining: () => 1 };

// V1 legacy OUTDATED: getData returns no previousDeployment stamp
const sugg2V1Outdated = {
getId: () => 'sugg-2',
getRank: () => 2,
getStatus: () => 'OUTDATED',
getData: () => ({ recommendations: [{ altText: 'some text' }] }),
};

const Suggestion = {
allByOpportunityIdAndStatus: sandbox.stub().resolves([s1]),
batchGetByKeys: sandbox.stub().resolves({
data: [sugg2V1Outdated],
unprocessed: [],
}),
};

const SuggestionGrant = {
splitSuggestionsByGrantStatus: sandbox.stub().resolves({
grantedIds: [],
grantIds: [],
notGrantedIds: ['sugg-1'],
}),
allByIndexKeys: sandbox.stub().resolves([
mkGrant('sugg-2', 'g2'),
]),
grantSuggestions: sandbox.stub().resolves({ success: true }),
revokeSuggestionGrant: sandbox.stub().resolves({ success: true }),
};

const Token = {
findBySiteIdAndTokenType: sandbox.stub(),
};
Token.findBySiteIdAndTokenType
.onFirstCall().resolves(existingToken)
.onSecondCall().resolves(tokenAfterRevoke);

const dataAccess = { Suggestion, SuggestionGrant, Token };

await grantSuggestionsForOpportunity(dataAccess, site, opportunity);

// V1 OUTDATED grant must be revoked (stale, no re-detection stamp)
expect(SuggestionGrant.revokeSuggestionGrant).to.have.been.calledOnce;
expect(SuggestionGrant.revokeSuggestionGrant).to.have.been.calledWith('g2');
expect(SuggestionGrant.grantSuggestions).to.have.been.calledOnce;
});

it('revokes grants with PENDING_VALIDATION status as stale', async () => {
const s1 = {
getId: () => 'sugg-1', getRank: () => 1, getStatus: () => 'NEW',
Expand Down
Loading