Skip to content

Commit ff2ddb2

Browse files
authored
Merge pull request #146 from topcoder-platform/PM-5194
PM-5194: Bypass approval for Fun challenges
2 parents c1fb84a + 0f56032 commit ff2ddb2

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)