Skip to content

Commit 6de37fd

Browse files
authored
Merge pull request #128 from topcoder-platform/PM-5447
PM-5447: Validate phase shortening after schedule recalculation
2 parents b66979d + cce75e7 commit 6de37fd

2 files changed

Lines changed: 126 additions & 53 deletions

File tree

src/common/phase-helper.js

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -46,51 +46,6 @@ function isDesignTrack(track) {
4646
return normalizeTrackToken(track) === DESIGN_TRACK;
4747
}
4848

49-
/**
50-
* Resolve the requested scheduled start date from a phase update payload.
51-
*
52-
* @param {Object} phase existing challenge phase
53-
* @param {Object|null|undefined} newPhase phase update payload
54-
* @returns {Date|String|undefined} requested scheduled start date, falling back to current start
55-
*/
56-
function resolveRequestedScheduledStartDate(phase, newPhase) {
57-
if (_.isNil(newPhase) || _.isNil(_.get(newPhase, "scheduledStartDate"))) {
58-
return _.get(phase, "scheduledStartDate");
59-
}
60-
61-
return _.get(newPhase, "scheduledStartDate");
62-
}
63-
64-
/**
65-
* Resolve the requested scheduled end date from a phase update payload.
66-
*
67-
* @param {Object} phase existing challenge phase
68-
* @param {Object|null|undefined} newPhase phase update payload
69-
* @returns {Date|String|undefined} requested scheduled end date when the payload changes it
70-
*/
71-
function resolveRequestedScheduledEndDate(phase, newPhase) {
72-
if (_.isNil(newPhase)) {
73-
return undefined;
74-
}
75-
76-
if (!_.isNil(_.get(newPhase, "scheduledEndDate"))) {
77-
return _.get(newPhase, "scheduledEndDate");
78-
}
79-
80-
const requestedDuration = _.get(newPhase, "duration");
81-
const requestedScheduledStartDate = resolveRequestedScheduledStartDate(phase, newPhase);
82-
if (_.isNil(requestedDuration) || _.isNil(requestedScheduledStartDate)) {
83-
return undefined;
84-
}
85-
86-
const scheduledStart = moment(requestedScheduledStartDate);
87-
if (!scheduledStart.isValid()) {
88-
return undefined;
89-
}
90-
91-
return scheduledStart.add(requestedDuration, "seconds").toDate().toISOString();
92-
}
93-
9449
/**
9550
* Check whether a requested schedule reduces the phase duration.
9651
*
@@ -187,6 +142,58 @@ function validateActivePhaseScheduledEndDateChange(
187142
}
188143
}
189144

145+
/**
146+
* Validate recalculated schedules against persisted phase schedules.
147+
*
148+
* @param {Array<Object>} originalPhases persisted challenge phases before the update
149+
* @param {Array<Object>} updatedPhases recalculated challenge phases that will be persisted
150+
* @param {Object} options validation options forwarded to the phase schedule validator
151+
* @returns {undefined} validates only
152+
* @throws {BadRequestError} when a recalculated schedule violates shortening rules
153+
*/
154+
function validateRecalculatedPhaseSchedules(originalPhases, updatedPhases, options = {}) {
155+
const originalById = new Map();
156+
const originalByPhaseId = new Map();
157+
158+
_.each(originalPhases, (phase) => {
159+
if (_.isNil(phase)) {
160+
return;
161+
}
162+
163+
if (!_.isNil(phase.id)) {
164+
originalById.set(phase.id, phase);
165+
}
166+
if (!_.isNil(phase.phaseId)) {
167+
originalByPhaseId.set(phase.phaseId, phase);
168+
}
169+
});
170+
171+
_.each(updatedPhases, (updatedPhase) => {
172+
if (_.isNil(updatedPhase)) {
173+
return;
174+
}
175+
176+
const originalPhase = (
177+
!_.isNil(updatedPhase.id)
178+
? originalById.get(updatedPhase.id)
179+
: undefined
180+
) || originalByPhaseId.get(updatedPhase.phaseId);
181+
182+
if (_.isNil(originalPhase)) {
183+
return;
184+
}
185+
186+
validateActivePhaseScheduledEndDateChange(
187+
originalPhase,
188+
updatedPhase.scheduledEndDate,
189+
{
190+
...options,
191+
requestedScheduledStartDate: updatedPhase.scheduledStartDate,
192+
}
193+
);
194+
});
195+
}
196+
190197
/**
191198
* Apply an explicit scheduled end date to a phase and update its duration.
192199
*
@@ -384,14 +391,6 @@ class ChallengePhaseHelper {
384391
const phaseFromTemplate = timelineTemplateMap.get(phase.phaseId);
385392
const phaseDefinition = phaseDefinitionMap.get(phase.phaseId);
386393
const newPhase = findPhaseUpdate(newPhases, phase);
387-
validateActivePhaseScheduledEndDateChange(
388-
phase,
389-
resolveRequestedScheduledEndDate(phase, newPhase),
390-
{
391-
...options,
392-
requestedScheduledStartDate: resolveRequestedScheduledStartDate(phase, newPhase),
393-
}
394-
);
395394
const templatePredecessor = _.get(phaseFromTemplate, "predecessor");
396395
// Prefer template predecessor only when that phase exists on the challenge, otherwise keep the stored link.
397396
const resolvedPredecessor = _.isNil(phaseFromTemplate)
@@ -485,6 +484,7 @@ class ChallengePhaseHelper {
485484
}
486485
recalculateScheduledEndDate(phase);
487486
}
487+
validateRecalculatedPhaseSchedules(challengePhasesOrdered, updatedPhases, options);
488488
return _.map(updatedPhases, (phase) => _.omit(phase, "requestedScheduledEndDate"));
489489
}
490490

test/unit/phase-helper.test.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,79 @@ describe('phase helper unit tests', () => {
436436
updatedPhases[0].duration.should.equal(duration)
437437
})
438438

439+
it('allows active non-Design dependent phases to move earlier when duration is unchanged', async () => {
440+
const registrationPhaseId = 'development-registration-phase'
441+
const reviewPhaseId = 'development-review-phase'
442+
const duration = 24 * 60 * 60
443+
const currentRegistrationStartDate = '2099-05-26T05:14:00.000Z'
444+
const currentRegistrationEndDate = '2099-05-27T05:14:00.000Z'
445+
const currentReviewEndDate = '2099-05-28T05:14:00.000Z'
446+
const requestedRegistrationStartDate = '2099-05-25T05:14:00.000Z'
447+
const requestedRegistrationEndDate = '2099-05-26T05:14:00.000Z'
448+
const requestedReviewEndDate = '2099-05-27T05:14:00.000Z'
449+
450+
stubPhaseLookups(
451+
[
452+
{ id: registrationPhaseId, name: 'Registration', description: 'Registration phase' },
453+
{ id: reviewPhaseId, name: 'Review', description: 'Review phase' }
454+
],
455+
[
456+
{ phaseId: registrationPhaseId, defaultDuration: duration },
457+
{
458+
phaseId: reviewPhaseId,
459+
predecessor: registrationPhaseId,
460+
defaultDuration: duration
461+
}
462+
]
463+
)
464+
465+
const updatedPhases = await phaseHelper.populatePhasesForChallengeUpdate(
466+
[
467+
{
468+
duration,
469+
isOpen: true,
470+
name: 'Registration',
471+
phaseId: registrationPhaseId,
472+
scheduledStartDate: currentRegistrationStartDate,
473+
scheduledEndDate: currentRegistrationEndDate
474+
},
475+
{
476+
duration,
477+
name: 'Review',
478+
phaseId: reviewPhaseId,
479+
predecessor: registrationPhaseId,
480+
scheduledStartDate: currentRegistrationEndDate,
481+
scheduledEndDate: currentReviewEndDate
482+
}
483+
],
484+
[
485+
{
486+
duration,
487+
phaseId: registrationPhaseId,
488+
scheduledStartDate: requestedRegistrationStartDate,
489+
scheduledEndDate: requestedRegistrationEndDate
490+
},
491+
{
492+
duration,
493+
phaseId: reviewPhaseId,
494+
scheduledEndDate: requestedReviewEndDate
495+
}
496+
],
497+
'timeline-template-id',
498+
false,
499+
{
500+
allowActivePhaseShortening: false,
501+
preventPhaseShortening: true
502+
}
503+
)
504+
505+
updatedPhases[0].scheduledStartDate.should.equal(requestedRegistrationStartDate)
506+
updatedPhases[0].scheduledEndDate.should.equal(requestedRegistrationEndDate)
507+
updatedPhases[1].scheduledStartDate.should.equal(requestedRegistrationEndDate)
508+
updatedPhases[1].scheduledEndDate.should.equal(requestedReviewEndDate)
509+
updatedPhases[1].duration.should.equal(duration)
510+
})
511+
439512
it('rejects active non-Design phase updates that shorten duration after moving start earlier', async () => {
440513
const registrationPhaseId = 'development-registration-phase'
441514
const currentRegistrationStartDate = '2099-05-26T05:14:00.000Z'

0 commit comments

Comments
 (0)