From 00665b58161f4f7d1c43b438ef54d26fcf876b61 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 26 May 2026 12:08:01 +1000 Subject: [PATCH 1/3] PM-5138: Sync standard submission phase dates What was broken Development challenges that use the standard Submission phase did not update the derived submission start/end date fields when phase schedules changed. Root cause (if identifiable) The API already had Submission in the priority list, but the response enrichment and Prisma update conversion only checked the first two priority entries: Topgear Submission and Topcoder Submission. What was changed Updated both challenge response enrichment and Prisma phase conversion to select any phase in the submission priority list, including standard Submission. Any added/updated tests Added unit tests for response enrichment and Prisma phase conversion with a standard Submission phase. --- src/common/challenge-helper.js | 6 ++--- src/common/prisma-helper.js | 4 +-- test/unit/challenge-helper.test.js | 33 ++++++++++++++++++++++++ test/unit/prisma-helper.test.js | 40 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 test/unit/challenge-helper.test.js create mode 100644 test/unit/prisma-helper.test.js diff --git a/src/common/challenge-helper.js b/src/common/challenge-helper.js index 896f689..abe318e 100644 --- a/src/common/challenge-helper.js +++ b/src/common/challenge-helper.js @@ -806,9 +806,9 @@ class ChallengeHelper { enrichChallengeForResponse(challenge, track, type, options = {}) { if (challenge.phases && challenge.phases.length > 0) { const registrationPhase = _.find(challenge.phases, (p) => p.name === "Registration"); - const submissionPhase = - _.find(challenge.phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[0]) || - _.find(challenge.phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[1]); + const submissionPhase = _.find(challenge.phases, (p) => + _.includes(SUBMISSION_PHASE_PRIORITY, p.name) + ); // select last started open phase as current phase _.forEach(challenge.phases, (p) => { diff --git a/src/common/prisma-helper.js b/src/common/prisma-helper.js index f658f1d..14e5327 100644 --- a/src/common/prisma-helper.js +++ b/src/common/prisma-helper.js @@ -31,9 +31,7 @@ function convertChallengePhaseSchema(challenge, result, auditFields) { result.currentPhaseNames = _.map(_.filter(phases, (p) => p.isOpen === true), "name"); const registrationPhase = _.find(phases, (p) => p.name === "Registration"); - const submissionPhase = - _.find(phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[0]) || - _.find(phases, (p) => p.name === SUBMISSION_PHASE_PRIORITY[1]); + const submissionPhase = _.find(phases, (p) => _.includes(SUBMISSION_PHASE_PRIORITY, p.name)); if (registrationPhase) { result.registrationStartDate = diff --git a/test/unit/challenge-helper.test.js b/test/unit/challenge-helper.test.js new file mode 100644 index 0000000..4f508a3 --- /dev/null +++ b/test/unit/challenge-helper.test.js @@ -0,0 +1,33 @@ +const chai = require('chai') + +const challengeHelper = require('../../src/common/challenge-helper') + +chai.should() + +describe('challenge response helper', () => { + it('enriches submission dates from the standard Submission phase', () => { + const submissionStartDate = '2026-05-22T08:00:00.000Z' + const submissionEndDate = '2026-05-27T08:00:00.000Z' + const challenge = { + phases: [ + { + name: 'Registration', + phaseId: 'registration-phase', + scheduledEndDate: '2026-05-27T08:00:00.000Z', + scheduledStartDate: '2026-05-22T08:00:00.000Z' + }, + { + name: 'Submission', + phaseId: 'submission-phase', + scheduledEndDate: submissionEndDate, + scheduledStartDate: submissionStartDate + } + ] + } + + challengeHelper.enrichChallengeForResponse(challenge) + + challenge.submissionStartDate.should.equal(submissionStartDate) + challenge.submissionEndDate.should.equal(submissionEndDate) + }) +}) diff --git a/test/unit/prisma-helper.test.js b/test/unit/prisma-helper.test.js new file mode 100644 index 0000000..9773096 --- /dev/null +++ b/test/unit/prisma-helper.test.js @@ -0,0 +1,40 @@ +const chai = require('chai') + +const prismaHelper = require('../../src/common/prisma-helper') + +chai.should() + +describe('prisma helper unit tests', () => { + it('derives submission dates from the standard Submission phase', () => { + const result = {} + const submissionStartDate = '2026-05-22T08:00:00.000Z' + const submissionEndDate = '2026-05-27T08:00:00.000Z' + + prismaHelper.convertChallengePhaseSchema( + { + phases: [ + { + name: 'Registration', + phaseId: 'registration-phase', + scheduledEndDate: '2026-05-27T08:00:00.000Z', + scheduledStartDate: '2026-05-22T08:00:00.000Z' + }, + { + name: 'Submission', + phaseId: 'submission-phase', + scheduledEndDate: submissionEndDate, + scheduledStartDate: submissionStartDate + } + ] + }, + result, + { + createdBy: 'test-user', + updatedBy: 'test-user' + } + ) + + result.submissionStartDate.should.equal(submissionStartDate) + result.submissionEndDate.should.equal(submissionEndDate) + }) +}) From a09e820ae96972978c48e64cec5c6ce71f94d262 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 26 May 2026 12:49:57 +1000 Subject: [PATCH 2/3] PM-4831: Count latest member submissions What was broken Challenge listing and detail responses used stored submission counters that represented every submission attempt. Members with multiple attempts inflated the Community app submission badge. Root cause The challenge response relied on Challenge.numOfSubmissions and Challenge.numOfCheckpointSubmissions counters instead of deriving the latest-member count from review submissions. What was changed Challenge search and detail responses now override submission counters from the review submission table by counting distinct submitting members per challenge and submission type. If the review count lookup is unavailable, the API keeps the stored counters so challenge reads still succeed. Any added/updated tests Added a ChallengeService regression test that inserts repeated contest and checkpoint submissions, then verifies both challenge detail and listing responses return latest-member counts. --- src/services/ChallengeService.js | 97 ++++++++++++++++++++++++++++++ test/unit/ChallengeService.test.js | 78 ++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 59347ad..9e9ed52 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -63,6 +63,7 @@ const CHALLENGE_APPROVAL_ACTION_STATUSES = new Set([ ]); const DEFAULT_ESTIMATED_SUBMISSIONS_COUNT = 2; +const CHECKPOINT_SUBMISSION_TYPE = "CHECKPOINT_SUBMISSION"; // Provide aliases for friendlier sortBy query params const sortByAliases = { @@ -96,6 +97,96 @@ function isCancelledChallengeStatus(status) { return CANCELLED_CHALLENGE_STATUSES.has(status); } +/** + * Loads submission counters for challenge responses from the review submission table. + * + * Community app badges show the number of members with submissions, not the + * number of attempts, so repeated uploads by the same member are counted once. + * + * @param {Array} challengeIds challenge identifiers to count submissions for + * @returns {Promise>} + * counts keyed by challenge id + * @throws {Error} when the review database query fails + */ +async function getLatestSubmissionCountsByChallenge(challengeIds) { + const ids = _.uniq( + (challengeIds || []) + .map((challengeId) => _.toString(challengeId).trim()) + .filter((challengeId) => !!challengeId), + ); + const countsByChallenge = new Map(); + + if (!ids.length || !config.REVIEW_DB_URL) { + return countsByChallenge; + } + + const reviewSchema = _.toString(config.REVIEW_DB_SCHEMA || "").trim(); + const submissionTable = reviewSchema + ? Prisma.raw(`"${reviewSchema.replace(/"/g, '""')}"."submission"`) + : Prisma.raw('"submission"'); + const reviewClient = getReviewClient(); + + const rows = await reviewClient.$queryRaw` + SELECT + "challengeId", + COUNT(DISTINCT CASE + WHEN "type"::text = ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId" + END)::int AS "numOfCheckpointSubmissions", + COUNT(DISTINCT CASE + WHEN "type"::text <> ${CHECKPOINT_SUBMISSION_TYPE} THEN "memberId" + END)::int AS "numOfSubmissions" + FROM ${submissionTable} + WHERE "challengeId" IN (${Prisma.join(ids)}) + AND "memberId" IS NOT NULL + GROUP BY "challengeId" + `; + + rows.forEach((row) => { + countsByChallenge.set(_.toString(row.challengeId), { + numOfSubmissions: Number(row.numOfSubmissions || 0), + numOfCheckpointSubmissions: Number(row.numOfCheckpointSubmissions || 0), + }); + }); + + return countsByChallenge; +} + +/** + * Applies latest-member submission counts to challenge records before response conversion. + * + * If the review query succeeds, challenges without submission rows are reset to + * zero so stale stored counters are not shown. If the query cannot run, callers + * keep the stored counters and log a warning instead of failing challenge reads. + * + * @param {Array} challenges challenge records being returned by the API + * @returns {Promise} + */ +async function applyLatestSubmissionCounts(challenges) { + const records = (challenges || []).filter((challenge) => challenge && challenge.id); + if (!records.length || !config.REVIEW_DB_URL) { + return; + } + + let countsByChallenge; + try { + countsByChallenge = await getLatestSubmissionCountsByChallenge( + records.map((challenge) => challenge.id), + ); + } catch (err) { + logger.warn(`Failed to load latest submission counts: ${err.message}`); + return; + } + + records.forEach((challenge) => { + const counts = countsByChallenge.get(_.toString(challenge.id)) || { + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + }; + challenge.numOfSubmissions = counts.numOfSubmissions; + challenge.numOfCheckpointSubmissions = counts.numOfCheckpointSubmissions; + }); +} + function normalizeStatusSortValue(statusValue) { if (_.isNil(statusValue)) { return null; @@ -1977,6 +2068,8 @@ async function searchChallenges(currentUser, criteria) { }); } + await applyLatestSubmissionCounts(challenges); + challenges.forEach((challenge) => { prismaHelper.convertModelToResponse(challenge); }); @@ -2754,6 +2847,8 @@ async function getChallenge(currentUser, id, checkIfExists) { _.unset(challenge, "payments"); } + await applyLatestSubmissionCounts([challenge]); + prismaHelper.convertModelToResponse(challenge); // Enrich skills data with full details from standardized skills API @@ -5297,6 +5392,8 @@ module.exports = { shouldSkipChallengeApprovalFlow, syncChallengeBillingAccountLock, validateChallengeActivationBillingAccount, + getLatestSubmissionCountsByChallenge, + applyLatestSubmissionCounts, }, searchChallenges, createChallenge, diff --git a/test/unit/ChallengeService.test.js b/test/unit/ChallengeService.test.js index f3a899c..a7c23c3 100644 --- a/test/unit/ChallengeService.test.js +++ b/test/unit/ChallengeService.test.js @@ -24,6 +24,7 @@ const { getReviewClient } = require("../../src/common/review-prisma"); const prisma = getClient(); const reviewSchema = config.get("REVIEW_DB_SCHEMA"); const reviewTableName = `"${reviewSchema}"."review"`; +const submissionTableName = `"${reviewSchema}"."submission"`; const should = chai.should(); let reviewClient; @@ -78,6 +79,31 @@ describe("challenge service unit tests", () => { ADD COLUMN IF NOT EXISTS "scorecardId" varchar(255) `); await reviewClient.$executeRawUnsafe(`DELETE FROM ${reviewTableName}`); + await reviewClient.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS ${submissionTableName} ( + "id" varchar(64) PRIMARY KEY, + "challengeId" varchar(255), + "memberId" varchar(255), + "type" varchar(64), + "status" varchar(64), + "submittedDate" timestamp, + "createdAt" timestamp DEFAULT now(), + "updatedAt" timestamp DEFAULT now() + ) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "challengeId" varchar(255) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "memberId" varchar(255) + `); + await reviewClient.$executeRawUnsafe(` + ALTER TABLE ${submissionTableName} + ADD COLUMN IF NOT EXISTS "type" varchar(64) + `); + await reviewClient.$executeRawUnsafe(`DELETE FROM ${submissionTableName}`); testChallengeData = { typeId: data.challenge.typeId, @@ -622,6 +648,58 @@ describe("challenge service unit tests", () => { should.equal(result.numOfRegistrants, 0); }); + it("returns latest-member submission counters for challenge detail and listing", async () => { + const challengeId = data.challenge.id; + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + numOfSubmissions: 5, + numOfCheckpointSubmissions: 2, + }, + }); + + await reviewClient.$executeRawUnsafe(` + INSERT INTO ${submissionTableName} + ("id", "challengeId", "memberId", "type", "status", "submittedDate") + VALUES + ('pm4831a1', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-01T00:00:00Z'), + ('pm4831a2', '${challengeId}', 'member-1', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-02T00:00:00Z'), + ('pm4831b1', '${challengeId}', 'member-2', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-03T00:00:00Z'), + ('pm4831c1', '${challengeId}', 'member-3', 'CONTEST_SUBMISSION', 'ACTIVE', '2026-01-04T00:00:00Z'), + ('pm4831d1', '${challengeId}', 'member-4', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-05T00:00:00Z'), + ('pm4831d2', '${challengeId}', 'member-4', 'CHECKPOINT_SUBMISSION', 'ACTIVE', '2026-01-06T00:00:00Z') + `); + + try { + const detail = await service.getChallenge({ isMachine: true }, challengeId); + should.equal(detail.numOfSubmissions, 3); + should.equal(detail.numOfCheckpointSubmissions, 1); + + const listing = await service.searchChallenges( + { isMachine: true }, + { + id: challengeId, + page: 1, + perPage: 10, + }, + ); + should.equal(listing.result.length, 1); + should.equal(listing.result[0].numOfSubmissions, 3); + should.equal(listing.result[0].numOfCheckpointSubmissions, 1); + } finally { + await reviewClient.$executeRawUnsafe( + `DELETE FROM ${submissionTableName} WHERE "challengeId" = '${challengeId}'`, + ); + await prisma.challenge.update({ + where: { id: challengeId }, + data: { + numOfSubmissions: 0, + numOfCheckpointSubmissions: 0, + }, + }); + } + }); + it("get challenge preserves billing for project write users", async () => { const originalUserHasProjectWriteAccess = helper.userHasProjectWriteAccess; From 0603846345728699d10c06e8f93e59cf9b75424f Mon Sep 17 00:00:00 2001 From: jmgasper Date: Wed, 27 May 2026 10:46:13 +1000 Subject: [PATCH 3/3] PM-5138: Preserve schedule end date updates What was broken Challenge update requests could include edited phase scheduledEndDate values, but the API dropped those fields during sanitization and recalculated phase ends from the previous duration. Root cause (if identifiable) The earlier PM-5138 fix updated derived submission date mapping, but the challenge update path still only allowed scheduledStartDate and duration through for phases. When Work sent an edited registration or submission end date, populatePhasesForChallengeUpdate never saw it. What was changed Allowed scheduledEndDate in the challenge update phase schema and sanitizer. Phase schedule recalculation now applies an explicit scheduledEndDate when provided, derives duration from the resulting start/end range, and cascades successor starts from the preserved predecessor end. Any added/updated tests Added phase-helper unit coverage for Development-style Registration/Submission phases and MM Registration/MM Submission phases with edited scheduled end dates. --- src/common/phase-helper.js | 70 +++++++++++-- src/services/ChallengeService.js | 9 +- test/unit/phase-helper.test.js | 166 +++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 test/unit/phase-helper.test.js diff --git a/src/common/phase-helper.js b/src/common/phase-helper.js index 0cb8c65..0274604 100644 --- a/src/common/phase-helper.js +++ b/src/common/phase-helper.js @@ -10,6 +10,61 @@ const prisma = require("../common/prisma").getClient(); const SUBMISSION_PHASE_PRIORITY = ["Topgear Submission", "Topcoder Submission", "Submission"]; +/** + * Apply an explicit scheduled end date to a phase and update its duration. + * + * @param {Object} phase challenge phase being recalculated + * @param {Date|String} scheduledEndDate scheduled end date supplied by the update payload + * @returns {Boolean} true when the scheduled end date was applied + * @throws {BadRequestError} when the supplied end date is invalid or not after the phase start + */ +function applyScheduledEndDate(phase, scheduledEndDate) { + if (_.isNil(scheduledEndDate)) { + return false; + } + + const scheduledStart = moment(phase.scheduledStartDate); + const scheduledEnd = moment(scheduledEndDate); + + if (!scheduledEnd.isValid()) { + throw new errors.BadRequestError( + `scheduledEndDate: ${scheduledEndDate} should be a valid date` + ); + } + + if (!scheduledEnd.isAfter(scheduledStart)) { + throw new errors.BadRequestError( + `scheduledEndDate: ${scheduledEndDate} should be after scheduledStartDate: ${phase.scheduledStartDate}` + ); + } + + phase.scheduledEndDate = scheduledEnd.toDate().toISOString(); + phase.duration = scheduledEnd.diff(scheduledStart, "seconds"); + return true; +} + +/** + * Recalculate a phase's scheduled end date from either explicit input or duration. + * + * @param {Object} phase challenge phase being recalculated + * @returns {undefined} mutates the provided phase + * @throws {BadRequestError} when an explicit scheduled end date is invalid + */ +function recalculateScheduledEndDate(phase) { + if (!_.isNil(phase.actualEndDate)) { + return; + } + + if (applyScheduledEndDate(phase, phase.requestedScheduledEndDate)) { + return; + } + + phase.scheduledEndDate = moment(phase.scheduledStartDate) + .add(phase.duration, "seconds") + .toDate() + .toISOString(); +} + class ChallengePhaseHelper { phaseDefinitionMap = {}; timelineTemplateMap = {}; @@ -141,6 +196,7 @@ class ChallengePhaseHelper { ...phase, predecessor: resolvedPredecessor, description: phaseDefinition.description, + requestedScheduledEndDate: _.get(newPhase, "scheduledEndDate"), }; if (updatedPhase.name === "Post-Mortem") { updatedPhase.predecessor = "a93544bc-c165-4af4-b55e-18f3593b457a"; @@ -166,10 +222,7 @@ class ChallengePhaseHelper { } else if (_.isNil(phase.actualStartDate)) { updatedPhase.scheduledStartDate = moment(scheduledStartDate).toDate().toISOString(); } - updatedPhase.scheduledEndDate = moment(updatedPhase.scheduledStartDate) - .add(updatedPhase.duration, "seconds") - .toDate() - .toISOString(); + recalculateScheduledEndDate(updatedPhase); } if (_.isNil(phase.actualEndDate) && !_.isNil(newPhase) && !_.isNil(newPhase.constraints)) { updatedPhase.constraints = newPhase.constraints; @@ -221,14 +274,9 @@ class ChallengePhaseHelper { } else if (_.isNil(phase.actualStartDate)) { phase.scheduledStartDate = predecessorPhase.scheduledEndDate; } - if (_.isNil(phase.actualEndDate)) { - phase.scheduledEndDate = moment(phase.scheduledStartDate) - .add(phase.duration, "seconds") - .toDate() - .toISOString(); - } + recalculateScheduledEndDate(phase); } - return updatedPhases; + return _.map(updatedPhases, (phase) => _.omit(phase, "requestedScheduledEndDate")); } handlePhasesAfterCancelling(phases) { diff --git a/src/services/ChallengeService.js b/src/services/ChallengeService.js index 9e9ed52..f741e08 100644 --- a/src/services/ChallengeService.js +++ b/src/services/ChallengeService.js @@ -4347,6 +4347,7 @@ updateChallenge.schema = { isOpen: Joi.boolean(), actualEndDate: Joi.date().allow(null), scheduledStartDate: Joi.date().allow(null), + scheduledEndDate: Joi.date().allow(null), constraints: Joi.array() .items( Joi.object() @@ -4869,7 +4870,13 @@ function sanitizeChallenge(challenge) { } if (challenge.phases) { sanitized.phases = _.map(challenge.phases, (phase) => - _.pick(phase, ["phaseId", "duration", "scheduledStartDate", "constraints"]), + _.pick(phase, [ + "phaseId", + "duration", + "scheduledStartDate", + "scheduledEndDate", + "constraints", + ]), ); } if (challenge.prizeSets) { diff --git a/test/unit/phase-helper.test.js b/test/unit/phase-helper.test.js new file mode 100644 index 0000000..dc3e84c --- /dev/null +++ b/test/unit/phase-helper.test.js @@ -0,0 +1,166 @@ +const chai = require('chai') + +const phaseHelper = require('../../src/common/phase-helper') + +chai.should() + +describe('phase helper unit tests', () => { + const originalGetTemplateAndTemplateMap = phaseHelper.getTemplateAndTemplateMap + const originalGetPhaseDefinitionsAndMap = phaseHelper.getPhaseDefinitionsAndMap + + afterEach(() => { + phaseHelper.getTemplateAndTemplateMap = originalGetTemplateAndTemplateMap + phaseHelper.getPhaseDefinitionsAndMap = originalGetPhaseDefinitionsAndMap + }) + + /** + * Install phase/template lookup stubs for schedule recalculation tests. + * + * @param {Array} phaseDefinitions phase records returned by phase lookup. + * @param {Array} templatePhases template phase records returned by timeline lookup. + * @returns {undefined} mutates the shared helper singleton for the current test. + */ + function stubPhaseLookups (phaseDefinitions, templatePhases) { + const phaseDefinitionMap = new Map(phaseDefinitions.map((phase) => [phase.id, phase])) + const timelineTemplateMap = new Map(templatePhases.map((phase) => [phase.phaseId, phase])) + + phaseHelper.getPhaseDefinitionsAndMap = async () => ({ + phaseDefinitionMap, + phaseDefinitions + }) + phaseHelper.getTemplateAndTemplateMap = async () => ({ + timelineTemplateMap, + timelineTempate: templatePhases + }) + } + + it('uses scheduled end dates from update payload when recalculating phases', async () => { + const registrationPhaseId = 'registration-phase' + const submissionPhaseId = 'submission-phase' + const staleDuration = 24 * 60 * 60 + const registrationStartDate = '2026-05-26T05:14:00.000Z' + const registrationEndDate = '2026-05-29T05:14:00.000Z' + const submissionEndDate = '2026-06-02T05:14:00.000Z' + + stubPhaseLookups( + [ + { id: registrationPhaseId, name: 'Registration', description: 'Registration phase' }, + { id: submissionPhaseId, name: 'Submission', description: 'Submission phase' } + ], + [ + { phaseId: registrationPhaseId, defaultDuration: staleDuration }, + { + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + defaultDuration: staleDuration + } + ] + ) + + const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate( + [ + { + duration: staleDuration, + name: 'Registration', + phaseId: registrationPhaseId, + scheduledStartDate: registrationStartDate, + scheduledEndDate: '2026-05-27T05:14:00.000Z' + }, + { + duration: staleDuration, + name: 'Submission', + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + scheduledStartDate: '2026-05-27T05:14:00.000Z', + scheduledEndDate: '2026-05-28T05:14:00.000Z' + } + ], + [ + { + duration: staleDuration, + phaseId: registrationPhaseId, + scheduledEndDate: registrationEndDate, + scheduledStartDate: registrationStartDate + }, + { + duration: staleDuration, + phaseId: submissionPhaseId, + scheduledEndDate: submissionEndDate + } + ], + 'timeline-template-id', + false + ) + + updatedPhases[0].scheduledEndDate.should.equal(registrationEndDate) + updatedPhases[0].duration.should.equal(3 * 24 * 60 * 60) + updatedPhases[1].scheduledStartDate.should.equal(registrationEndDate) + updatedPhases[1].scheduledEndDate.should.equal(submissionEndDate) + updatedPhases[1].duration.should.equal(4 * 24 * 60 * 60) + }) + + it('uses scheduled end dates from update payload for MM phases', async () => { + const registrationPhaseId = 'mm-registration-phase' + const submissionPhaseId = 'mm-submission-phase' + const staleDuration = 12 * 60 * 60 + const registrationStartDate = '2026-06-01T00:00:00.000Z' + const registrationEndDate = '2026-06-03T00:00:00.000Z' + const submissionEndDate = '2026-06-06T00:00:00.000Z' + + stubPhaseLookups( + [ + { id: registrationPhaseId, name: 'MM Registration', description: 'MM Registration phase' }, + { id: submissionPhaseId, name: 'MM Submission', description: 'MM Submission phase' } + ], + [ + { phaseId: registrationPhaseId, defaultDuration: staleDuration }, + { + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + defaultDuration: staleDuration + } + ] + ) + + const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate( + [ + { + duration: staleDuration, + name: 'MM Registration', + phaseId: registrationPhaseId, + scheduledStartDate: registrationStartDate, + scheduledEndDate: '2026-06-01T12:00:00.000Z' + }, + { + duration: staleDuration, + name: 'MM Submission', + phaseId: submissionPhaseId, + predecessor: registrationPhaseId, + scheduledStartDate: '2026-06-01T12:00:00.000Z', + scheduledEndDate: '2026-06-02T00:00:00.000Z' + } + ], + [ + { + duration: staleDuration, + phaseId: registrationPhaseId, + scheduledEndDate: registrationEndDate, + scheduledStartDate: registrationStartDate + }, + { + duration: staleDuration, + phaseId: submissionPhaseId, + scheduledEndDate: submissionEndDate + } + ], + 'timeline-template-id', + false + ) + + updatedPhases[0].scheduledEndDate.should.equal(registrationEndDate) + updatedPhases[0].duration.should.equal(2 * 24 * 60 * 60) + updatedPhases[1].scheduledStartDate.should.equal(registrationEndDate) + updatedPhases[1].scheduledEndDate.should.equal(submissionEndDate) + updatedPhases[1].duration.should.equal(3 * 24 * 60 * 60) + }) +})