Skip to content
Merged
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
48 changes: 38 additions & 10 deletions src/services/ChallengeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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
);
}
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -2645,6 +2663,7 @@ async function createChallenge(currentUser, challenge, userToken) {
const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass(
challenge,
approvalBillingAccountId,
challenge.funChallenge === true,
);

if (!skipsChallengeApprovalFlow) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -3762,6 +3785,7 @@ async function updateChallenge(currentUser, challengeId, data, options: any = {}
const skipsChallengeApprovalFlow = applyChallengeApprovalFlowBypass(
data,
approvalBillingAccountId,
effectiveFunChallenge,
);

if (!skipsChallengeApprovalFlow) {
Expand Down Expand Up @@ -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.",
Expand Down
56 changes: 55 additions & 1 deletion test/unit/ChallengeService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions test/unit/challenge-activation-billing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading