refactor(schedule): delegate safe-year cron search
This commit is contained in:
@@ -689,9 +689,17 @@ function cronLocalFormatter(timeZone: string): Intl.DateTimeFormat {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether local calendar fields satisfy one parsed rule. */
|
||||||
|
function cronMatchesLocal(rule: ParsedCronRule, local: CalendarParts): boolean {
|
||||||
|
const dayOfWeek = new Date(calendarEpoch(local)).getUTCDay()
|
||||||
|
return rule.minute.values.includes(local.minute)
|
||||||
|
&& rule.hour.values.includes(local.hour)
|
||||||
|
&& cronMatchesDate(rule, local.month, local.day, dayOfWeek)
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether a Croner candidate is a real whole-minute match and the first overlap instant. */
|
/** Whether a Croner candidate is a real whole-minute match and the first overlap instant. */
|
||||||
function isCanonicalCronCandidate(
|
function isCanonicalCronCandidate(
|
||||||
evaluator: Cron,
|
rule: ParsedCronRule,
|
||||||
formatter: Intl.DateTimeFormat,
|
formatter: Intl.DateTimeFormat,
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
epoch: number,
|
epoch: number,
|
||||||
@@ -699,25 +707,25 @@ function isCanonicalCronCandidate(
|
|||||||
if (!Number.isSafeInteger(epoch)
|
if (!Number.isSafeInteger(epoch)
|
||||||
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
||||||
|| epoch > MAX_FOUR_DIGIT_YEAR_MS
|
|| epoch > MAX_FOUR_DIGIT_YEAR_MS
|
||||||
|| epoch % 60_000 !== 0
|
|| epoch % 60_000 !== 0) return false
|
||||||
|| !evaluator.match(new Date(epoch))) return false
|
const local = localProjection(formatter, epoch)
|
||||||
return resolveLocalInstant(localProjection(formatter, epoch), timeZone) === epoch
|
return cronMatchesLocal(rule, local) && resolveLocalInstant(local, timeZone) === epoch
|
||||||
}
|
}
|
||||||
|
|
||||||
const CRONER_LOW_YEAR_CUTOFF = 108
|
const CRONER_LOW_YEAR_CUTOFF = 108
|
||||||
const CRONER_LOW_YEAR_SEARCH_END = 109
|
const CRONER_LOW_YEAR_SEARCH_END = 109
|
||||||
const MAX_CRON_CURSOR_CORRECTIONS = 1_440
|
const MAX_TIME_ZONE_GAP_MINUTES = 1_440
|
||||||
|
|
||||||
/** Search owned local-calendar candidates without JavaScript's legacy 0..99 year remapping. */
|
/** Bridge low years without JavaScript's legacy 0..99 year remapping. */
|
||||||
function ownedCronInstant(
|
function ownedLowYearCronInstant(
|
||||||
rule: ParsedCronRule,
|
rule: ParsedCronRule,
|
||||||
timeZone: string,
|
timeZone: string,
|
||||||
boundary: number,
|
boundary: number,
|
||||||
direction: 1 | -1,
|
direction: 1 | -1,
|
||||||
minYear: number,
|
|
||||||
maxYear: number,
|
|
||||||
lowerExclusive = MIN_FOUR_DIGIT_YEAR_MS - 1,
|
lowerExclusive = MIN_FOUR_DIGIT_YEAR_MS - 1,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
|
const minYear = 1
|
||||||
|
const maxYear = CRONER_LOW_YEAR_SEARCH_END
|
||||||
const utcYear = new Date(boundary).getUTCFullYear()
|
const utcYear = new Date(boundary).getUTCFullYear()
|
||||||
const startYear = direction === 1
|
const startYear = direction === 1
|
||||||
? Math.max(minYear, utcYear - 1)
|
? Math.max(minYear, utcYear - 1)
|
||||||
@@ -755,8 +763,9 @@ function ownedCronInstant(
|
|||||||
millisecond: 0,
|
millisecond: 0,
|
||||||
}, timeZone)
|
}, timeZone)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
/* v8 ignore next -- canonical zones make non-Schedule failures unreachable here. */
|
/* v8 ignore next 2 -- canonical low-year zones have no transition gaps in supported ICU data. */
|
||||||
if (!(error instanceof ScheduleInputError)) throw error
|
if (!(error instanceof ScheduleInputError)) throw error
|
||||||
|
/* v8 ignore next -- supported ICU data has no low-year transition gap to skip. */
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (candidate % 60_000 !== 0) continue
|
if (candidate % 60_000 !== 0) continue
|
||||||
@@ -778,13 +787,13 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number):
|
|||||||
if (!rule.hasMatchingDate) return undefined
|
if (!rule.hasMatchingDate) return undefined
|
||||||
let cursor = after
|
let cursor = after
|
||||||
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||||
const lower = ownedCronInstant(rule, timeZone, after, 1, 1, CRONER_LOW_YEAR_SEARCH_END)
|
const lower = ownedLowYearCronInstant(rule, timeZone, after, 1)
|
||||||
if (lower !== undefined) return lower
|
if (lower !== undefined) return lower
|
||||||
cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z'))
|
cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z'))
|
||||||
}
|
}
|
||||||
const evaluator = cronEvaluator(rule, timeZone)
|
const evaluator = cronEvaluator(rule, timeZone)
|
||||||
const formatter = cronLocalFormatter(timeZone)
|
const formatter = cronLocalFormatter(timeZone)
|
||||||
let corrections = 0
|
let gapCorrections = 0
|
||||||
while (cursor < MAX_FOUR_DIGIT_YEAR_MS) {
|
while (cursor < MAX_FOUR_DIGIT_YEAR_MS) {
|
||||||
const candidate = evaluator.nextRun(new Date(cursor))
|
const candidate = evaluator.nextRun(new Date(cursor))
|
||||||
if (candidate === null) return undefined
|
if (candidate === null) return undefined
|
||||||
@@ -793,21 +802,34 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number):
|
|||||||
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.')
|
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.')
|
||||||
}
|
}
|
||||||
if (epoch <= cursor) {
|
if (epoch <= cursor) {
|
||||||
corrections += 1
|
gapCorrections += 1
|
||||||
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
|
/* v8 ignore next 3 -- pinned Croner/ICU overlaps cannot normalize beyond one local date. */
|
||||||
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
|
if (gapCorrections > MAX_TIME_ZONE_GAP_MINUTES) {
|
||||||
|
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.')
|
||||||
}
|
}
|
||||||
cursor += 60_000
|
cursor += 60_000
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
gapCorrections = 0
|
||||||
if (epoch > MAX_FOUR_DIGIT_YEAR_MS) return undefined
|
if (epoch > MAX_FOUR_DIGIT_YEAR_MS) return undefined
|
||||||
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
|
if (isCanonicalCronCandidate(rule, formatter, timeZone, epoch)) return epoch
|
||||||
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
|
cursor = epoch
|
||||||
}
|
}
|
||||||
/* v8 ignore next -- only repeated stale dependency candidates can exhaust the bounded cursor. */
|
/* v8 ignore next -- only repeated stale dependency candidates can exhaust the bounded cursor. */
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Use Croner's forward search to recover matches its reverse search can skip at an overlap. */
|
||||||
|
function latestCronInstantThrough(
|
||||||
|
rule: ParsedCronRule,
|
||||||
|
timeZone: string,
|
||||||
|
initial: number,
|
||||||
|
acceptedAt: number,
|
||||||
|
): number {
|
||||||
|
const next = nextCronInstant(rule, timeZone, initial)
|
||||||
|
return next !== undefined && next <= acceptedAt ? next : initial
|
||||||
|
}
|
||||||
|
|
||||||
/** Find the latest valid calendar occurrence at or before one instant. */
|
/** Find the latest valid calendar occurrence at or before one instant. */
|
||||||
function previousCronInstant(
|
function previousCronInstant(
|
||||||
rule: ParsedCronRule,
|
rule: ParsedCronRule,
|
||||||
@@ -816,45 +838,38 @@ function previousCronInstant(
|
|||||||
baseline: number,
|
baseline: number,
|
||||||
): number | undefined {
|
): number | undefined {
|
||||||
if (new Date(acceptedAt).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
if (new Date(acceptedAt).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||||
return ownedCronInstant(
|
return ownedLowYearCronInstant(rule, timeZone, acceptedAt, -1, baseline)
|
||||||
rule, timeZone, acceptedAt, -1, 1, CRONER_LOW_YEAR_SEARCH_END, baseline,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const evaluator = cronEvaluator(rule, timeZone)
|
const evaluator = cronEvaluator(rule, timeZone)
|
||||||
const formatter = cronLocalFormatter(timeZone)
|
const formatter = cronLocalFormatter(timeZone)
|
||||||
const nextMinute = Math.floor(acceptedAt / 60_000) * 60_000 + 60_000
|
const nextMinute = Math.floor(acceptedAt / 60_000) * 60_000 + 60_000
|
||||||
let reference = Math.min(MAX_FOUR_DIGIT_YEAR_MS, nextMinute)
|
let reference = Math.min(MAX_FOUR_DIGIT_YEAR_MS, nextMinute)
|
||||||
let corrections = 0
|
let gapCorrections = 0
|
||||||
while (reference > baseline) {
|
while (reference > baseline) {
|
||||||
const candidate = evaluator.previousRuns(1, new Date(reference))[0]
|
const candidate = evaluator.previousRuns(1, new Date(reference))[0]
|
||||||
if (candidate === undefined) {
|
if (candidate === undefined) return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
||||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
|
||||||
}
|
|
||||||
const epoch = candidate.getTime()
|
const epoch = candidate.getTime()
|
||||||
if (!Number.isSafeInteger(epoch)) {
|
if (!Number.isSafeInteger(epoch)) {
|
||||||
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.')
|
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.')
|
||||||
}
|
}
|
||||||
if (epoch >= reference) {
|
if (epoch >= reference) {
|
||||||
corrections += 1
|
gapCorrections += 1
|
||||||
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
|
/* v8 ignore next 3 -- pinned Croner/ICU gaps cannot normalize beyond one local date. */
|
||||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
if (gapCorrections > MAX_TIME_ZONE_GAP_MINUTES) {
|
||||||
|
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.')
|
||||||
}
|
}
|
||||||
reference -= 60_000
|
reference -= 60_000
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (epoch <= baseline) {
|
gapCorrections = 0
|
||||||
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
if (epoch <= baseline) return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
||||||
|
if (epoch <= acceptedAt && isCanonicalCronCandidate(rule, formatter, timeZone, epoch)) {
|
||||||
|
return latestCronInstantThrough(rule, timeZone, epoch, acceptedAt)
|
||||||
}
|
}
|
||||||
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
|
|
||||||
if (epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS
|
|
||||||
&& epoch % 60_000 === 0 && evaluator.match(candidate)) {
|
|
||||||
return resolveLocalInstant(localProjection(formatter, epoch), timeZone)
|
|
||||||
}
|
|
||||||
const owned = ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
|
|
||||||
if (owned !== undefined) return owned
|
|
||||||
reference = Math.min(reference - 60_000, epoch - 1)
|
reference = Math.min(reference - 60_000, epoch - 1)
|
||||||
}
|
}
|
||||||
return undefined
|
/* v8 ignore next -- a real Croner candidate either retreats or reaches the persisted baseline. */
|
||||||
|
return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode the exact v1 after record shape. */
|
/** Decode the exact v1 after record shape. */
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ describe('Croner calendar adapter', () => {
|
|||||||
occurrenceAt: '0100-01-01T00:00:00.000Z',
|
occurrenceAt: '0100-01-01T00:00:00.000Z',
|
||||||
nextScheduledAt: '0100-01-02T00:00:00.000Z',
|
nextScheduledAt: '0100-01-02T00:00:00.000Z',
|
||||||
})
|
})
|
||||||
|
const yearOne = createCronScheduleRecord(
|
||||||
|
ScheduleId('schedule-reverse-1'),
|
||||||
|
'reverse year one',
|
||||||
|
'0 0 * * *',
|
||||||
|
'UTC',
|
||||||
|
Date.parse('0001-01-01T00:00:00.000Z'),
|
||||||
|
)
|
||||||
|
expect(resolveCronOccurrence(yearOne, Date.parse(yearOne.scheduledAt))).toEqual({
|
||||||
|
occurrenceAt: yearOne.scheduledAt,
|
||||||
|
nextScheduledAt: '0001-01-03T00:00:00.000Z',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips a DST gap and chooses the first instant in an overlap', () => {
|
it('skips a DST gap and chooses the first instant in an overlap', () => {
|
||||||
@@ -209,6 +220,13 @@ describe('Croner calendar adapter', () => {
|
|||||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
||||||
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
nextScheduledAt: '2026-11-02T06:30:00.000Z',
|
||||||
})
|
})
|
||||||
|
expect(createCronScheduleRecord(
|
||||||
|
ScheduleId('schedule-overlap-after-first'),
|
||||||
|
'after first overlap instant',
|
||||||
|
'30 1 * * *',
|
||||||
|
'America/New_York',
|
||||||
|
Date.parse('2026-11-01T05:45:00.000Z'),
|
||||||
|
).scheduledAt).toBe('2026-11-02T06:30:00.000Z')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects the latest current match after a persisted baseline', () => {
|
it('selects the latest current match after a persisted baseline', () => {
|
||||||
@@ -246,7 +264,7 @@ describe('Croner calendar adapter', () => {
|
|||||||
), 'no_future_occurrence')
|
), 'no_future_occurrence')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('contains dependency cursor failures and preserves the baseline when current search has no match', () => {
|
it('contains invalid dependency results without replacing safe-year calendar search', () => {
|
||||||
const record = createCronScheduleRecord(
|
const record = createCronScheduleRecord(
|
||||||
ScheduleId('schedule-dependency'),
|
ScheduleId('schedule-dependency'),
|
||||||
'dependency',
|
'dependency',
|
||||||
@@ -261,63 +279,6 @@ describe('Croner calendar adapter', () => {
|
|||||||
})
|
})
|
||||||
noPrevious.mockRestore()
|
noPrevious.mockRestore()
|
||||||
|
|
||||||
const repeatedPrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockImplementationOnce((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
|
||||||
.mockReturnValue([new Date('2026-11-01T05:30:00.000Z')])
|
|
||||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
|
||||||
})
|
|
||||||
repeatedPrevious.mockRestore()
|
|
||||||
|
|
||||||
const laterOverlap = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockReturnValue([new Date('2026-11-01T06:30:00.000Z')])
|
|
||||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
|
||||||
})
|
|
||||||
laterOverlap.mockRestore()
|
|
||||||
|
|
||||||
const gapThenNone = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockReturnValueOnce([new Date('2026-03-08T07:30:00.000Z')])
|
|
||||||
.mockReturnValueOnce([])
|
|
||||||
const gapBaseline = {
|
|
||||||
...record,
|
|
||||||
cron: '30 2 * * *',
|
|
||||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
|
||||||
}
|
|
||||||
expect(resolveCronOccurrence(gapBaseline, Date.parse('2026-03-08T08:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: gapBaseline.scheduledAt,
|
|
||||||
})
|
|
||||||
gapThenNone.mockRestore()
|
|
||||||
|
|
||||||
const boundaryThenEnd = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockReturnValue([new Date('0001-01-01T00:00:00.000Z')])
|
|
||||||
const boundaryBaseline = {
|
|
||||||
...record,
|
|
||||||
cron: '1 0 * * *',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
scheduledAt: '0001-01-01T00:01:00.000Z',
|
|
||||||
}
|
|
||||||
expect(resolveCronOccurrence(boundaryBaseline, Date.parse('2026-01-01T00:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2025-12-31T00:01:00.000Z',
|
|
||||||
})
|
|
||||||
boundaryThenEnd.mockRestore()
|
|
||||||
|
|
||||||
const repeatedNext = vi.spyOn(Cron.prototype, 'nextRun')
|
|
||||||
.mockImplementationOnce(reference =>
|
|
||||||
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
|
|
||||||
.mockReturnValue(new Date('2026-01-02T00:00:00.000Z'))
|
|
||||||
expect(createCronScheduleRecord(
|
|
||||||
ScheduleId('stuck-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
|
||||||
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
|
|
||||||
repeatedNext.mockRestore()
|
|
||||||
|
|
||||||
const neverAdvancingNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(reference =>
|
|
||||||
reference instanceof Date ? new Date(reference) : new Date('2026-01-01T00:00:00.000Z'))
|
|
||||||
expect(createCronScheduleRecord(
|
|
||||||
ScheduleId('fallback-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
|
||||||
).scheduledAt).toBe('2026-01-02T00:00:00.000Z')
|
|
||||||
neverAdvancingNext.mockRestore()
|
|
||||||
|
|
||||||
const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN))
|
const invalidNext = vi.spyOn(Cron.prototype, 'nextRun').mockReturnValue(new Date(Number.NaN))
|
||||||
expectInputCode(() => createCronScheduleRecord(
|
expectInputCode(() => createCronScheduleRecord(
|
||||||
ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
ScheduleId('invalid-next'), 'x', '0 0 * * *', 'UTC', Date.parse('2026-01-01T00:00:00Z'),
|
||||||
@@ -337,42 +298,6 @@ describe('Croner calendar adapter', () => {
|
|||||||
.toThrow(/cron evaluation failed: The cron evaluator did not retreat/)
|
.toThrow(/cron evaluation failed: The cron evaluator did not retreat/)
|
||||||
invalidPrevious.mockRestore()
|
invalidPrevious.mockRestore()
|
||||||
|
|
||||||
const repeatedAtBaseline = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
|
||||||
expect(resolveCronOccurrence(record, Date.parse(record.scheduledAt))).toMatchObject({
|
|
||||||
occurrenceAt: record.scheduledAt,
|
|
||||||
})
|
|
||||||
repeatedAtBaseline.mockRestore()
|
|
||||||
|
|
||||||
const neverRetreating = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockImplementation((_count, reference) => [new Date(reference ?? record.scheduledAt)])
|
|
||||||
expect(resolveCronOccurrence({
|
|
||||||
...record,
|
|
||||||
scheduledAt: '2026-10-31T05:30:00.000Z',
|
|
||||||
}, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
|
||||||
})
|
|
||||||
neverRetreating.mockRestore()
|
|
||||||
|
|
||||||
const nonMinutePrevious = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockReturnValue([new Date('2026-11-01T05:30:30.000Z')])
|
|
||||||
expect(resolveCronOccurrence(record, Date.parse('2026-11-01T07:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2026-11-01T05:30:00.000Z',
|
|
||||||
})
|
|
||||||
nonMinutePrevious.mockRestore()
|
|
||||||
|
|
||||||
const gapWithOwnedMatch = vi.spyOn(Cron.prototype, 'previousRuns')
|
|
||||||
.mockReturnValue([new Date('2026-03-08T07:30:00.000Z')])
|
|
||||||
const gapWithNextDay = {
|
|
||||||
...record,
|
|
||||||
cron: '30 2 * * *',
|
|
||||||
scheduledAt: '2026-03-07T07:30:00.000Z',
|
|
||||||
}
|
|
||||||
expect(resolveCronOccurrence(gapWithNextDay, Date.parse('2026-03-09T08:00:00.000Z'))).toMatchObject({
|
|
||||||
occurrenceAt: '2026-03-09T06:30:00.000Z',
|
|
||||||
})
|
|
||||||
gapWithOwnedMatch.mockRestore()
|
|
||||||
|
|
||||||
const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => {
|
const thrownNext = vi.spyOn(Cron.prototype, 'nextRun').mockImplementation(() => {
|
||||||
throw new Error('dependency failed')
|
throw new Error('dependency failed')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user