Skip to content

Commit 0f56032

Browse files
committed
PM-5194: Bypass approval for Fun challenges
What was broken Fun challenges were created with pending budget approval and the API rejected attempts to move legacy pending Fun challenges from Draft to Active. Root cause The existing approval-flow bypass handled configured Topgear billing accounts only and did not consider the Fun challenge flag. What was changed Extended the existing approval bypass to auto-approve Fun challenges on create and update, including persisted Fun challenges when an activation payload omits the flag. Kept the separate billing-account and funds validations unchanged. Any added/updated tests Added approval-policy coverage for Fun challenges and a database-backed regression for activating a persisted pending Fun challenge. Updated the existing Fun creation expectation and kept the paid budget-lock fixture explicitly non-Fun.
1 parent c1fb84a commit 0f56032

3 files changed

Lines changed: 104 additions & 11 deletions

File tree

src/services/ChallengeService.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -563,13 +563,20 @@ function getApprovalFlowBillingAccountId(challenge, data?: any, projectBillingAc
563563
/**
564564
* Determines whether the challenge approval flow should be bypassed.
565565
*
566-
* Challenges billed to configured Topgear billing accounts are auto-approved
567-
* because they should not enter the manual budget approval flow.
566+
* Fun challenges and challenges billed to configured Topgear billing accounts
567+
* are auto-approved because they should not enter the manual budget approval flow.
568568
*
569569
* @param {string|number|null|undefined} billingAccountId Billing-account identifier.
570+
* @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update.
570571
* @returns {boolean} `true` when challenge approval should be skipped.
572+
* @throws This function does not throw.
573+
* @remarks Used by challenge create, update, and launch validation to apply one approval policy.
571574
*/
572-
function shouldSkipChallengeApprovalFlow(billingAccountId) {
575+
function shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge = false) {
576+
if (funChallenge === true) {
577+
return true;
578+
}
579+
573580
const normalizedBillingAccountId = normalizeOptionalString(billingAccountId);
574581

575582
if (!normalizedBillingAccountId) {
@@ -587,10 +594,13 @@ function shouldSkipChallengeApprovalFlow(billingAccountId) {
587594
*
588595
* @param {Object} target Challenge create or update payload to mutate.
589596
* @param {string|number|null|undefined} billingAccountId Billing-account identifier.
597+
* @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update.
590598
* @returns {boolean} `true` when approval fields were forced to approved.
599+
* @throws This function does not intentionally throw; callers provide a mutable challenge payload.
600+
* @remarks Used before normal approval validation so bypassed challenges persist as approved.
591601
*/
592-
function applyChallengeApprovalFlowBypass(target, billingAccountId) {
593-
if (!shouldSkipChallengeApprovalFlow(billingAccountId)) {
602+
function applyChallengeApprovalFlowBypass(target, billingAccountId, funChallenge = false) {
603+
if (!shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge)) {
594604
return false;
595605
}
596606

@@ -606,11 +616,18 @@ function applyChallengeApprovalFlowBypass(target, billingAccountId) {
606616
*
607617
* @param {string|null|undefined} approvalStatus Effective approval status.
608618
* @param {string|number|null|undefined} billingAccountId Billing-account identifier.
619+
* @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update.
609620
* @returns {boolean} `true` when launch should be blocked by approval state.
621+
* @throws This function does not throw.
622+
* @remarks Used when a challenge update transitions its status to Active.
610623
*/
611-
function shouldBlockChallengeLaunchForApproval(approvalStatus, billingAccountId) {
624+
function shouldBlockChallengeLaunchForApproval(
625+
approvalStatus,
626+
billingAccountId,
627+
funChallenge = false,
628+
) {
612629
return (
613-
!shouldSkipChallengeApprovalFlow(billingAccountId) &&
630+
!shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge) &&
614631
normalizeApprovalStatus(approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED
615632
);
616633
}
@@ -2530,7 +2547,8 @@ searchChallenges.schema = {
25302547

25312548
/**
25322549
* Create challenge.
2533-
* Challenges billed to configured Topgear accounts skip manual budget approval and are auto-approved.
2550+
* Fun challenges and challenges billed to configured Topgear accounts skip manual budget approval
2551+
* and are auto-approved.
25342552
* @param {Object} currentUser the user who perform operation
25352553
* @param {Object} challenge the challenge to create; omitted `is_test_challenge` metadata defaults
25362554
* to the exact string `false`
@@ -2645,6 +2663,7 @@ async function createChallenge(currentUser, challenge, userToken) {
26452663
const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass(
26462664
challenge,
26472665
approvalBillingAccountId,
2666+
challenge.funChallenge === true,
26482667
);
26492668

26502669
if (!skipsChallengeApprovalFlow) {
@@ -3654,7 +3673,8 @@ function prepareTaskCompletionData(challenge, challengeResources, data) {
36543673
* Update challenge.
36553674
* When a challenge transitions to completed task status or a cancelled status,
36563675
* payment generation is requested after the database update commits.
3657-
* Challenges billed to configured Topgear accounts skip manual budget approval and remain approved.
3676+
* Fun challenges and challenges billed to configured Topgear accounts skip manual budget approval
3677+
* and remain approved.
36583678
* Updates that start in or transition to a completed/cancelled status may not change the effective
36593679
* `is_test_challenge` metadata value.
36603680
* @param {Object} currentUser the user who perform operation
@@ -3743,6 +3763,9 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {}
37433763
}
37443764

37453765
data = preserveBillingMarkupForCopilotUpdate(currentUser, data, challenge);
3766+
const effectiveFunChallenge = _.isBoolean(data.funChallenge)
3767+
? data.funChallenge
3768+
: challenge.funChallenge === true;
37463769
const rawApprovalRejectionReason = _.toString(_.get(data, "approvalRejectionReason", ""));
37473770

37483771
// Remove fields from data that are not allowed to be updated and that match the existing challenge
@@ -3762,6 +3785,7 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {}
37623785
const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass(
37633786
data,
37643787
approvalBillingAccountId,
3788+
effectiveFunChallenge,
37653789
);
37663790

37673791
if (!skipsChallengeApprovalFlow) {
@@ -3844,7 +3868,11 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {}
38443868

38453869
if (
38463870
isStatusChangingToActive &&
3847-
shouldBlockChallengeLaunchForApproval(resolvedApprovalStatus, approvalBillingAccountId)
3871+
shouldBlockChallengeLaunchForApproval(
3872+
resolvedApprovalStatus,
3873+
approvalBillingAccountId,
3874+
effectiveFunChallenge,
3875+
)
38483876
) {
38493877
throw new errors.BadRequestError(
38503878
"Challenge launch is blocked until budget approval is Approved.",

test/unit/ChallengeService.test.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ describe("challenge service unit tests", () => {
284284
should.equal(result.legacyId, testChallengeData.legacyId);
285285
should.equal(result.forumId, testChallengeData.forumId);
286286
should.equal(result.status, testChallengeData.status);
287-
should.equal(result.approvalStatus, "PENDING_APPROVAL");
287+
should.equal(result.approvalStatus, "APPROVED");
288288
should.equal(result.funChallenge, testChallengeData.funChallenge);
289289
should.equal(result.createdBy, "testuser");
290290
should.exist(result.startDate);
@@ -2091,6 +2091,7 @@ describe("challenge service unit tests", () => {
20912091
challengeData.name = `${challengeData.name} Billing Lock ${Date.now()}`;
20922092
challengeData.legacyId = Math.floor(Math.random() * 1000000);
20932093
challengeData.status = ChallengeStatusEnum.NEW;
2094+
challengeData.funChallenge = false;
20942095
challengeData.prizeSets = [
20952096
{
20962097
type: PrizeSetTypeEnum.PLACEMENT,
@@ -2866,6 +2867,59 @@ describe("challenge service unit tests", () => {
28662867
}
28672868
});
28682869

2870+
it("update challenge - auto-approves and activates a persisted pending Fun challenge", async () => {
2871+
const activationChallenge = await createActivationChallenge(ChallengeStatusEnum.DRAFT);
2872+
const originalGetChallengeResources = helper.getChallengeResources;
2873+
const originalGetM2MToken = m2mHelper.getM2MToken;
2874+
const originalAxiosGet = axios.get;
2875+
const originalPostBusEvent = helper.postBusEvent;
2876+
await prisma.challenge.update({
2877+
where: { id: activationChallenge.id },
2878+
data: {
2879+
approvalStatus: "PENDING_APPROVAL",
2880+
funChallenge: true,
2881+
},
2882+
});
2883+
helper.getChallengeResources = async () => [];
2884+
helper.postBusEvent = async () => {};
2885+
m2mHelper.getM2MToken = async () => "test-token";
2886+
axios.get = async (url, options) => {
2887+
if (_.toString(url) === config.RESOURCE_ROLES_API_URL) {
2888+
return { data: [], status: 200, headers: {} };
2889+
}
2890+
return originalAxiosGet(url, options);
2891+
};
2892+
2893+
try {
2894+
const updated = await service.updateChallenge(
2895+
{ isMachine: true, sub: "sub-activate-fun", userId: 22838965 },
2896+
activationChallenge.id,
2897+
{
2898+
status: ChallengeStatusEnum.ACTIVE,
2899+
reviewers: [
2900+
{
2901+
phaseId: data.phase.id,
2902+
scorecardId: "activation-scorecard",
2903+
isMemberReview: true,
2904+
memberReviewerCount: 1,
2905+
shouldOpenOpportunity: false,
2906+
},
2907+
],
2908+
},
2909+
);
2910+
2911+
should.equal(updated.status, ChallengeStatusEnum.ACTIVE);
2912+
should.equal(updated.approvalStatus, "APPROVED");
2913+
should.equal(updated.funChallenge, true);
2914+
} finally {
2915+
helper.getChallengeResources = originalGetChallengeResources;
2916+
helper.postBusEvent = originalPostBusEvent;
2917+
m2mHelper.getM2MToken = originalGetM2MToken;
2918+
axios.get = originalAxiosGet;
2919+
await prisma.challenge.delete({ where: { id: activationChallenge.id } });
2920+
}
2921+
});
2922+
28692923
it("update challenge - prevent activating with an inactive project billing account", async () => {
28702924
const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT);
28712925
const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation;

test/unit/challenge-activation-billing.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,22 @@ describe("challenge activation billing validation unit tests", () => {
157157
should.equal(shouldSkipChallengeApprovalFlow("80001061"), false);
158158
});
159159

160+
it("skips approval flow for Fun challenges", () => {
161+
config.TOPGEAR_BILLING_ACCOUNTS_ID = [];
162+
163+
should.equal(shouldSkipChallengeApprovalFlow("80001061", true), true);
164+
should.equal(shouldSkipChallengeApprovalFlow("80001061", false), false);
165+
});
166+
160167
it("does not block launch approval for configured Topgear billing accounts", () => {
161168
config.TOPGEAR_BILLING_ACCOUNTS_ID = ["80000062"];
162169

163170
should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80000062"), false);
164171
should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061"), true);
172+
should.equal(
173+
shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061", true),
174+
false,
175+
);
165176
should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false);
166177
});
167178

0 commit comments

Comments
 (0)