From 0f560325da8d009d7efc15d1e52185beef3e1533 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 12 Aug 2026 15:16:04 +1000 Subject: [PATCH] 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. --- src/services/ChallengeService.ts | 48 ++++++++++++---- test/unit/ChallengeService.test.js | 56 ++++++++++++++++++- .../unit/challenge-activation-billing.test.js | 11 ++++ 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/services/ChallengeService.ts b/src/services/ChallengeService.ts index 4192db3..daf8e26 100644 --- a/src/services/ChallengeService.ts +++ b/src/services/ChallengeService.ts @@ -563,13 +563,20 @@ function getApprovalFlowBillingAccountId(challenge, data?: any, projectBillingAc /** * Determines whether the challenge approval flow should be bypassed. * - * Challenges billed to configured Topgear billing accounts are auto-approved - * because they should not enter the manual budget approval flow. + * Fun challenges and challenges billed to configured Topgear billing accounts + * are auto-approved because they should not enter the manual budget approval flow. * * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update. * @returns {boolean} `true` when challenge approval should be skipped. + * @throws This function does not throw. + * @remarks Used by challenge create, update, and launch validation to apply one approval policy. */ -function shouldSkipChallengeApprovalFlow(billingAccountId) { +function shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge = false) { + if (funChallenge === true) { + return true; + } + const normalizedBillingAccountId = normalizeOptionalString(billingAccountId); if (!normalizedBillingAccountId) { @@ -587,10 +594,13 @@ function shouldSkipChallengeApprovalFlow(billingAccountId) { * * @param {Object} target Challenge create or update payload to mutate. * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update. * @returns {boolean} `true` when approval fields were forced to approved. + * @throws This function does not intentionally throw; callers provide a mutable challenge payload. + * @remarks Used before normal approval validation so bypassed challenges persist as approved. */ -function applyChallengeApprovalFlowBypass(target, billingAccountId) { - if (!shouldSkipChallengeApprovalFlow(billingAccountId)) { +function applyChallengeApprovalFlowBypass(target, billingAccountId, funChallenge = false) { + if (!shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge)) { return false; } @@ -606,11 +616,18 @@ function applyChallengeApprovalFlowBypass(target, billingAccountId) { * * @param {string|null|undefined} approvalStatus Effective approval status. * @param {string|number|null|undefined} billingAccountId Billing-account identifier. + * @param {boolean} [funChallenge=false] Effective Fun challenge flag from the create or update. * @returns {boolean} `true` when launch should be blocked by approval state. + * @throws This function does not throw. + * @remarks Used when a challenge update transitions its status to Active. */ -function shouldBlockChallengeLaunchForApproval(approvalStatus, billingAccountId) { +function shouldBlockChallengeLaunchForApproval( + approvalStatus, + billingAccountId, + funChallenge = false, +) { return ( - !shouldSkipChallengeApprovalFlow(billingAccountId) && + !shouldSkipChallengeApprovalFlow(billingAccountId, funChallenge) && normalizeApprovalStatus(approvalStatus) !== CHALLENGE_APPROVAL_STATUS.APPROVED ); } @@ -2530,7 +2547,8 @@ searchChallenges.schema = { /** * Create challenge. - * Challenges billed to configured Topgear accounts skip manual budget approval and are auto-approved. + * Fun challenges and challenges billed to configured Topgear accounts skip manual budget approval + * and are auto-approved. * @param {Object} currentUser the user who perform operation * @param {Object} challenge the challenge to create; omitted `is_test_challenge` metadata defaults * to the exact string `false` @@ -2645,6 +2663,7 @@ async function createChallenge(currentUser, challenge, userToken) { const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( challenge, approvalBillingAccountId, + challenge.funChallenge === true, ); if (!skipsChallengeApprovalFlow) { @@ -3654,7 +3673,8 @@ function prepareTaskCompletionData(challenge, challengeResources, data) { * Update challenge. * When a challenge transitions to completed task status or a cancelled status, * payment generation is requested after the database update commits. - * Challenges billed to configured Topgear accounts skip manual budget approval and remain approved. + * Fun challenges and challenges billed to configured Topgear accounts skip manual budget approval + * and remain approved. * Updates that start in or transition to a completed/cancelled status may not change the effective * `is_test_challenge` metadata value. * @param {Object} currentUser the user who perform operation @@ -3743,6 +3763,9 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {} } data = preserveBillingMarkupForCopilotUpdate(currentUser, data, challenge); + const effectiveFunChallenge = _.isBoolean(data.funChallenge) + ? data.funChallenge + : challenge.funChallenge === true; const rawApprovalRejectionReason = _.toString(_.get(data, "approvalRejectionReason", "")); // 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 = {} const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass( data, approvalBillingAccountId, + effectiveFunChallenge, ); if (!skipsChallengeApprovalFlow) { @@ -3844,7 +3868,11 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {} if ( isStatusChangingToActive && - shouldBlockChallengeLaunchForApproval(resolvedApprovalStatus, approvalBillingAccountId) + shouldBlockChallengeLaunchForApproval( + resolvedApprovalStatus, + approvalBillingAccountId, + effectiveFunChallenge, + ) ) { throw new errors.BadRequestError( "Challenge launch is blocked until budget approval is Approved.", diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index 2325db9..4b4c60b 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -284,7 +284,7 @@ describe("challenge service unit tests", () => { should.equal(result.legacyId, testChallengeData.legacyId); should.equal(result.forumId, testChallengeData.forumId); should.equal(result.status, testChallengeData.status); - should.equal(result.approvalStatus, "PENDING_APPROVAL"); + should.equal(result.approvalStatus, "APPROVED"); should.equal(result.funChallenge, testChallengeData.funChallenge); should.equal(result.createdBy, "testuser"); should.exist(result.startDate); @@ -2091,6 +2091,7 @@ describe("challenge service unit tests", () => { challengeData.name = `${challengeData.name} Billing Lock ${Date.now()}`; challengeData.legacyId = Math.floor(Math.random() * 1000000); challengeData.status = ChallengeStatusEnum.NEW; + challengeData.funChallenge = false; challengeData.prizeSets = [ { type: PrizeSetTypeEnum.PLACEMENT, @@ -2866,6 +2867,59 @@ describe("challenge service unit tests", () => { } }); + it("update challenge - auto-approves and activates a persisted pending Fun challenge", async () => { + const activationChallenge = await createActivationChallenge(ChallengeStatusEnum.DRAFT); + const originalGetChallengeResources = helper.getChallengeResources; + const originalGetM2MToken = m2mHelper.getM2MToken; + const originalAxiosGet = axios.get; + const originalPostBusEvent = helper.postBusEvent; + await prisma.challenge.update({ + where: { id: activationChallenge.id }, + data: { + approvalStatus: "PENDING_APPROVAL", + funChallenge: true, + }, + }); + helper.getChallengeResources = async () => []; + helper.postBusEvent = async () => {}; + m2mHelper.getM2MToken = async () => "test-token"; + axios.get = async (url, options) => { + if (_.toString(url) === config.RESOURCE_ROLES_API_URL) { + return { data: [], status: 200, headers: {} }; + } + return originalAxiosGet(url, options); + }; + + try { + const updated = await service.updateChallenge( + { isMachine: true, sub: "sub-activate-fun", userId: 22838965 }, + activationChallenge.id, + { + status: ChallengeStatusEnum.ACTIVE, + reviewers: [ + { + phaseId: data.phase.id, + scorecardId: "activation-scorecard", + isMemberReview: true, + memberReviewerCount: 1, + shouldOpenOpportunity: false, + }, + ], + }, + ); + + should.equal(updated.status, ChallengeStatusEnum.ACTIVE); + should.equal(updated.approvalStatus, "APPROVED"); + should.equal(updated.funChallenge, true); + } finally { + helper.getChallengeResources = originalGetChallengeResources; + helper.postBusEvent = originalPostBusEvent; + m2mHelper.getM2MToken = originalGetM2MToken; + axios.get = originalAxiosGet; + await prisma.challenge.delete({ where: { id: activationChallenge.id } }); + } + }); + it("update challenge - prevent activating with an inactive project billing account", async () => { const activationChallenge = await createProjectActivationChallenge(ChallengeStatusEnum.DRAFT); const originalGetProjectBillingInformation = projectHelper.getProjectBillingInformation; diff --git a/test/unit/challenge-activation-billing.test.js b/test/unit/challenge-activation-billing.test.js index 4f6661c..fad844d 100644 --- a/test/unit/challenge-activation-billing.test.js +++ b/test/unit/challenge-activation-billing.test.js @@ -157,11 +157,22 @@ describe("challenge activation billing validation unit tests", () => { should.equal(shouldSkipChallengeApprovalFlow("80001061"), false); }); + it("skips approval flow for Fun challenges", () => { + config.TOPGEAR_BILLING_ACCOUNTS_ID = []; + + should.equal(shouldSkipChallengeApprovalFlow("80001061", true), true); + should.equal(shouldSkipChallengeApprovalFlow("80001061", false), false); + }); + it("does not block launch approval for configured Topgear billing accounts", () => { config.TOPGEAR_BILLING_ACCOUNTS_ID = ["80000062"]; should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80000062"), false); should.equal(shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061"), true); + should.equal( + shouldBlockChallengeLaunchForApproval("PENDING_APPROVAL", "80001061", true), + false, + ); should.equal(shouldBlockChallengeLaunchForApproval("APPROVED", "80001061"), false); });