d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1187 lines
42 KiB
TypeScript
1187 lines
42 KiB
TypeScript
/**
|
|
* Tests for schedule utility functions
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
type BlockState,
|
|
calculateNextRunTime,
|
|
createDateWithTimezone,
|
|
generateCronExpression,
|
|
getScheduleTimeValues,
|
|
getSubBlockValue,
|
|
parseCronToHumanReadable,
|
|
parseTimeString,
|
|
validateCronExpression,
|
|
} from '@/lib/workflows/schedules/utils'
|
|
|
|
describe('Schedule Utilities', () => {
|
|
describe('parseTimeString', () => {
|
|
it.concurrent('should parse valid time strings', () => {
|
|
expect(parseTimeString('09:30')).toEqual([9, 30])
|
|
expect(parseTimeString('23:45')).toEqual([23, 45])
|
|
expect(parseTimeString('00:00')).toEqual([0, 0])
|
|
})
|
|
|
|
it.concurrent('should return default values for invalid inputs', () => {
|
|
expect(parseTimeString('')).toEqual([9, 0])
|
|
expect(parseTimeString(null)).toEqual([9, 0])
|
|
expect(parseTimeString(undefined)).toEqual([9, 0])
|
|
expect(parseTimeString('invalid')).toEqual([9, 0])
|
|
})
|
|
|
|
it.concurrent('should handle malformed time strings', () => {
|
|
expect(parseTimeString('9:30')).toEqual([9, 30])
|
|
expect(parseTimeString('9:3')).toEqual([9, 3])
|
|
expect(parseTimeString('9:')).toEqual([9, 0])
|
|
expect(parseTimeString(':30')).toEqual([0, 30]) // Only has minutes
|
|
})
|
|
|
|
it.concurrent('should handle out-of-range time values', () => {
|
|
expect(parseTimeString('25:30')).toEqual([25, 30]) // Hours > 24
|
|
expect(parseTimeString('10:75')).toEqual([10, 75]) // Minutes > 59
|
|
expect(parseTimeString('99:99')).toEqual([99, 99]) // Both out of range
|
|
})
|
|
})
|
|
|
|
describe('getSubBlockValue', () => {
|
|
it.concurrent('should get values from block subBlocks', () => {
|
|
const block: BlockState = {
|
|
type: 'starter',
|
|
subBlocks: {
|
|
scheduleType: { value: 'daily' },
|
|
scheduleTime: { value: '09:30' },
|
|
emptyValue: { value: '' },
|
|
nullValue: { value: null },
|
|
},
|
|
} as BlockState
|
|
|
|
expect(getSubBlockValue(block, 'scheduleType')).toBe('daily')
|
|
expect(getSubBlockValue(block, 'scheduleTime')).toBe('09:30')
|
|
expect(getSubBlockValue(block, 'emptyValue')).toBe('')
|
|
expect(getSubBlockValue(block, 'nullValue')).toBe('')
|
|
expect(getSubBlockValue(block, 'nonExistent')).toBe('')
|
|
})
|
|
|
|
it.concurrent('should handle missing subBlocks', () => {
|
|
const block = {
|
|
type: 'starter',
|
|
subBlocks: {}, // Empty subBlocks
|
|
} as BlockState
|
|
|
|
expect(getSubBlockValue(block, 'anyField')).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('getScheduleTimeValues', () => {
|
|
it.concurrent('should extract all time values from a block', () => {
|
|
const block: BlockState = {
|
|
type: 'starter',
|
|
subBlocks: {
|
|
scheduleTime: { value: '09:30' },
|
|
minutesInterval: { value: '15' },
|
|
hourlyMinute: { value: '45' },
|
|
dailyTime: { value: '10:15' },
|
|
weeklyDay: { value: 'MON' },
|
|
weeklyDayTime: { value: '12:00' },
|
|
monthlyDay: { value: '15' },
|
|
monthlyTime: { value: '14:30' },
|
|
scheduleStartAt: { value: '' },
|
|
timezone: { value: 'UTC' },
|
|
},
|
|
} as BlockState
|
|
|
|
const result = getScheduleTimeValues(block)
|
|
|
|
expect(result).toEqual({
|
|
scheduleTime: '09:30',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 45,
|
|
dailyTime: [10, 15],
|
|
weeklyDay: 1, // MON = 1
|
|
weeklyTime: [12, 0],
|
|
monthlyDay: 15,
|
|
monthlyTime: [14, 30],
|
|
cronExpression: null,
|
|
})
|
|
})
|
|
|
|
it.concurrent('should use default values for missing fields', () => {
|
|
const block: BlockState = {
|
|
type: 'starter',
|
|
subBlocks: {
|
|
// Minimal config
|
|
scheduleType: { value: 'daily' },
|
|
},
|
|
} as BlockState
|
|
|
|
const result = getScheduleTimeValues(block)
|
|
|
|
expect(result).toEqual({
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15, // Default
|
|
hourlyMinute: 0, // Default
|
|
dailyTime: [9, 0], // Default
|
|
weeklyDay: 1, // Default (MON)
|
|
weeklyTime: [9, 0], // Default
|
|
monthlyDay: 1, // Default
|
|
monthlyTime: [9, 0], // Default
|
|
cronExpression: null,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('generateCronExpression', () => {
|
|
it.concurrent('should generate correct cron expressions for different schedule types', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '09:30',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 45,
|
|
dailyTime: [10, 15] as [number, number],
|
|
weeklyDay: 1, // Monday
|
|
weeklyTime: [12, 0] as [number, number],
|
|
monthlyDay: 15,
|
|
monthlyTime: [14, 30] as [number, number],
|
|
timezone: 'UTC',
|
|
cronExpression: null,
|
|
}
|
|
|
|
// Minutes (every 15 minutes)
|
|
expect(generateCronExpression('minutes', scheduleValues)).toBe('*/15 * * * *')
|
|
|
|
// Hourly (at minute 45)
|
|
expect(generateCronExpression('hourly', scheduleValues)).toBe('45 * * * *')
|
|
|
|
// Daily (at 10:15)
|
|
expect(generateCronExpression('daily', scheduleValues)).toBe('15 10 * * *')
|
|
|
|
// Weekly (Monday at 12:00)
|
|
expect(generateCronExpression('weekly', scheduleValues)).toBe('0 12 * * 1')
|
|
|
|
// Monthly (15th at 14:30)
|
|
expect(generateCronExpression('monthly', scheduleValues)).toBe('30 14 15 * *')
|
|
})
|
|
|
|
it.concurrent('should handle custom cron expressions', () => {
|
|
// For this simplified test, let's skip the complex mocking
|
|
// and just verify the 'custom' case is in the switch statement
|
|
|
|
// Create a mock block with custom cron expression
|
|
const mockBlock: BlockState = {
|
|
type: 'starter',
|
|
subBlocks: {
|
|
cronExpression: { value: '*/5 * * * *' },
|
|
},
|
|
}
|
|
|
|
// Create schedule values with the block as any since we're testing a special case
|
|
const scheduleValues = {
|
|
...getScheduleTimeValues(mockBlock),
|
|
// Override as BlockState to access the cronExpression
|
|
// This simulates what happens in the actual code
|
|
subBlocks: mockBlock.subBlocks,
|
|
} as any
|
|
|
|
// Now properly test the custom case
|
|
const result = generateCronExpression('custom', scheduleValues)
|
|
expect(result).toBe('*/5 * * * *')
|
|
|
|
// Also verify other schedule types still work
|
|
const standardScheduleValues = {
|
|
scheduleTime: '',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 30,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [10, 0] as [number, number],
|
|
monthlyDay: 15,
|
|
monthlyTime: [14, 30] as [number, number],
|
|
timezone: 'UTC',
|
|
cronExpression: null,
|
|
}
|
|
|
|
expect(generateCronExpression('minutes', standardScheduleValues)).toBe('*/15 * * * *')
|
|
})
|
|
|
|
it.concurrent('should throw for invalid schedule types', () => {
|
|
const scheduleValues = {} as any
|
|
expect(() => generateCronExpression('invalid-type', scheduleValues)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('calculateNextRunTime', () => {
|
|
beforeEach(() => {
|
|
// Mock Date.now for consistent testing
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-04-12T12:00:00.000Z')) // Noon on April 12, 2025
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it.concurrent('should calculate next run for minutes schedule using Croner', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
|
|
// Just check that it's a valid date in the future
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
|
|
// Croner will calculate based on the cron expression */15 * * * *
|
|
// The exact minute depends on Croner's calculation
|
|
})
|
|
|
|
it.concurrent('should handle scheduleStartAt with scheduleTime', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '14:30', // Specific start time
|
|
scheduleStartAt: '2025-04-15', // Future date
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
|
|
// Should return the future start date with time
|
|
expect(nextRun.getUTCFullYear()).toBe(2025)
|
|
expect(nextRun.getUTCMonth()).toBe(3) // April
|
|
expect(nextRun.getUTCDate()).toBe(15)
|
|
})
|
|
|
|
it.concurrent('should calculate next run for hourly schedule using Croner', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 30,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('hourly', scheduleValues)
|
|
|
|
// Verify it's a valid future date using Croner's calculation
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
// Croner calculates based on cron "30 * * * *"
|
|
expect(nextRun.getUTCMinutes()).toBe(30)
|
|
})
|
|
|
|
it.concurrent('should calculate next run for daily schedule using Croner with timezone', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
|
|
// Verify it's a future date at exactly 9:00 UTC using Croner
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
expect(nextRun.getUTCHours()).toBe(9)
|
|
expect(nextRun.getUTCMinutes()).toBe(0)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should calculate next run for weekly schedule using Croner with timezone',
|
|
() => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1, // Monday
|
|
weeklyTime: [10, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
|
|
|
// Should be next Monday at 10:00 AM UTC using Croner
|
|
expect(nextRun.getUTCDay()).toBe(1) // Monday
|
|
expect(nextRun.getUTCHours()).toBe(10)
|
|
expect(nextRun.getUTCMinutes()).toBe(0)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should calculate next run for monthly schedule using Croner with timezone',
|
|
() => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 15,
|
|
monthlyTime: [14, 30] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
|
|
// Current date is 2025-04-12 12:00, so next run should be 2025-04-15 14:30 UTC using Croner
|
|
expect(nextRun.getFullYear()).toBe(2025)
|
|
expect(nextRun.getUTCMonth()).toBe(3) // April (0-indexed)
|
|
expect(nextRun.getUTCDate()).toBe(15)
|
|
expect(nextRun.getUTCHours()).toBe(14)
|
|
expect(nextRun.getUTCMinutes()).toBe(30)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should work with lastRanAt parameter (though Croner calculates independently)',
|
|
() => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
|
|
// Croner calculates based on cron expression
|
|
// Just verify we get a future date
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
}
|
|
)
|
|
|
|
it.concurrent('should respect future scheduleStartAt date', () => {
|
|
const scheduleValues = {
|
|
scheduleStartAt: '2025-04-22T20:50:00.000Z', // April 22, 2025 at 8:50 PM
|
|
scheduleTime: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 10,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
|
|
// Should be exactly April 22, 2025 at 8:50 PM (the future start date)
|
|
expect(nextRun.toISOString()).toBe('2025-04-22T20:50:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should ignore past scheduleStartAt date', () => {
|
|
const scheduleValues = {
|
|
scheduleStartAt: '2025-04-10T20:50:00.000Z', // April 10, 2025 at 8:50 PM (in the past)
|
|
scheduleTime: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 10,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
|
|
// Should not use the past date but calculate normally
|
|
expect(nextRun > new Date()).toBe(true)
|
|
expect(nextRun.getUTCMinutes() % 10).toBe(0) // Should align with the interval
|
|
})
|
|
})
|
|
|
|
describe('validateCronExpression', () => {
|
|
it.concurrent('should validate correct cron expressions', () => {
|
|
expect(validateCronExpression('0 9 * * *')).toEqual({
|
|
isValid: true,
|
|
nextRun: expect.any(Date),
|
|
})
|
|
expect(validateCronExpression('*/15 * * * *')).toEqual({
|
|
isValid: true,
|
|
nextRun: expect.any(Date),
|
|
})
|
|
expect(validateCronExpression('30 14 15 * *')).toEqual({
|
|
isValid: true,
|
|
nextRun: expect.any(Date),
|
|
})
|
|
})
|
|
|
|
it.concurrent('should validate cron expressions with timezone', () => {
|
|
const result = validateCronExpression('0 9 * * *', 'America/Los_Angeles')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it.concurrent('should reject invalid cron expressions', () => {
|
|
expect(validateCronExpression('invalid')).toEqual({
|
|
isValid: false,
|
|
error: expect.stringContaining('invalid'),
|
|
})
|
|
expect(validateCronExpression('60 * * * *')).toEqual({
|
|
isValid: false,
|
|
error: expect.any(String),
|
|
})
|
|
expect(validateCronExpression('')).toEqual({
|
|
isValid: false,
|
|
error: 'Cron expression cannot be empty',
|
|
})
|
|
expect(validateCronExpression(' ')).toEqual({
|
|
isValid: false,
|
|
error: 'Cron expression cannot be empty',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should detect impossible cron expressions', () => {
|
|
// This would be February 31st - impossible date
|
|
expect(validateCronExpression('0 0 31 2 *')).toEqual({
|
|
isValid: false,
|
|
error: 'Cron expression produces no future occurrences',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('parseCronToHumanReadable', () => {
|
|
it.concurrent('should parse common cron patterns using cronstrue', () => {
|
|
// cronstrue produces "Every minute" for '* * * * *'
|
|
expect(parseCronToHumanReadable('* * * * *')).toBe('Every minute')
|
|
|
|
// cronstrue produces "Every 15 minutes" for '*/15 * * * *'
|
|
expect(parseCronToHumanReadable('*/15 * * * *')).toBe('Every 15 minutes')
|
|
|
|
// cronstrue produces "At 30 minutes past the hour" for '30 * * * *'
|
|
expect(parseCronToHumanReadable('30 * * * *')).toContain('30 minutes past the hour')
|
|
|
|
// cronstrue produces "At 9:00 AM" for '0 9 * * *' (no leading zero on hour)
|
|
expect(parseCronToHumanReadable('0 9 * * *')).toContain('9:00 AM')
|
|
|
|
// cronstrue produces "At 2:30 PM" for '30 14 * * *' (no leading zero on hour)
|
|
expect(parseCronToHumanReadable('30 14 * * *')).toContain('2:30 PM')
|
|
|
|
// cronstrue produces "At 09:00 AM, only on Monday" for '0 9 * * 1'
|
|
expect(parseCronToHumanReadable('0 9 * * 1')).toContain('Monday')
|
|
|
|
// cronstrue produces "At 02:30 PM, on day 15 of the month" for '30 14 15 * *'
|
|
expect(parseCronToHumanReadable('30 14 15 * *')).toContain('15')
|
|
})
|
|
|
|
it.concurrent('should describe the nth weekday of the month', () => {
|
|
expect(parseCronToHumanReadable('30 9 * * 1#3')).toContain('third Monday')
|
|
})
|
|
|
|
it.concurrent("should describe croner's last-weekday syntax without a null ordinal", () => {
|
|
const result = parseCronToHumanReadable('30 9 * * 1#L')
|
|
expect(result).toContain('last Monday')
|
|
expect(result).not.toContain('null')
|
|
})
|
|
|
|
it.concurrent('should include timezone information when provided', () => {
|
|
const resultPT = parseCronToHumanReadable('0 9 * * *', 'America/Los_Angeles')
|
|
// Intl.DateTimeFormat returns PST or PDT depending on DST
|
|
expect(resultPT).toMatch(/\(P[SD]T\)/)
|
|
expect(resultPT).toContain('9:00 AM')
|
|
|
|
const resultET = parseCronToHumanReadable('30 14 * * *', 'America/New_York')
|
|
// Intl.DateTimeFormat returns EST or EDT depending on DST
|
|
expect(resultET).toMatch(/\(E[SD]T\)/)
|
|
expect(resultET).toContain('2:30 PM')
|
|
|
|
const resultUTC = parseCronToHumanReadable('0 12 * * *', 'UTC')
|
|
expect(resultUTC).not.toContain('(UTC)') // UTC should not be explicitly shown
|
|
})
|
|
|
|
it.concurrent('should handle complex patterns with cronstrue', () => {
|
|
// cronstrue can handle complex patterns better than our custom parser
|
|
const result1 = parseCronToHumanReadable('0 9 * * 1-5')
|
|
expect(result1).toContain('Monday through Friday')
|
|
|
|
const result2 = parseCronToHumanReadable('0 9 1,15 * *')
|
|
expect(result2).toContain('day 1 and 15')
|
|
})
|
|
|
|
it.concurrent('should return a fallback for invalid patterns', () => {
|
|
const result = parseCronToHumanReadable('invalid cron')
|
|
// Should fallback to "Schedule: <expression>"
|
|
expect(result).toContain('Schedule:')
|
|
expect(result).toContain('invalid cron')
|
|
})
|
|
})
|
|
|
|
describe('Timezone-aware scheduling with Croner', () => {
|
|
it.concurrent('should calculate daily schedule in Pacific Time correctly', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/Los_Angeles',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number], // 9 AM Pacific
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
|
|
// 9 AM Pacific should be 16:00 or 17:00 UTC depending on DST
|
|
// Croner handles this automatically
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should handle DST transition for schedules', () => {
|
|
// Set a date during DST transition in March
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-03-08T10:00:00.000Z')) // Before DST
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/New_York',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [14, 0] as [number, number], // 2 PM Eastern
|
|
weeklyDay: 1,
|
|
weeklyTime: [14, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [14, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
|
|
// Croner should handle DST transition correctly
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it.concurrent('should calculate weekly schedule in Tokyo timezone', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'Asia/Tokyo',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1, // Monday
|
|
weeklyTime: [10, 0] as [number, number], // 10 AM Japan Time
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('weekly', scheduleValues)
|
|
|
|
// Verify it's a valid future date
|
|
// Tokyo is UTC+9, so 10 AM JST = 1 AM UTC
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should handle custom cron with timezone', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'Europe/London',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: '30 15 * * *', // 3:30 PM London time
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('custom', scheduleValues)
|
|
|
|
// Verify it's a valid future date
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should handle monthly schedule on last day of month', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-02-15T12:00:00.000Z'))
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/Chicago',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 28, // Last day of Feb (non-leap year)
|
|
monthlyTime: [12, 0] as [number, number], // Noon Central
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
|
|
// Should calculate Feb 28 at noon Central time
|
|
expect(nextRun.getUTCDate()).toBe(28)
|
|
expect(nextRun.getUTCMonth()).toBe(1) // February
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
})
|
|
|
|
describe('createDateWithTimezone', () => {
|
|
it.concurrent('should correctly handle UTC timezone', () => {
|
|
const date = createDateWithTimezone(
|
|
'2025-04-21T00:00:00.000Z',
|
|
'14:00', // 2:00 PM
|
|
'UTC'
|
|
)
|
|
expect(date.toISOString()).toBe('2025-04-21T14:00:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle America/Los_Angeles (UTC-7 during DST)', () => {
|
|
// April 21, 2025 is during DST for Los Angeles (PDT = UTC-7)
|
|
const date = createDateWithTimezone(
|
|
'2025-04-21', // Using date string without time/zone
|
|
'14:00', // 2:00 PM local time
|
|
'America/Los_Angeles'
|
|
)
|
|
// 2:00 PM PDT should be 21:00 UTC (14 + 7)
|
|
expect(date.toISOString()).toBe('2025-04-21T21:00:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle America/Los_Angeles (UTC-8 outside DST)', () => {
|
|
// January 10, 2025 is outside DST for Los Angeles (PST = UTC-8)
|
|
const date = createDateWithTimezone(
|
|
'2025-01-10',
|
|
'14:00', // 2:00 PM local time
|
|
'America/Los_Angeles'
|
|
)
|
|
// 2:00 PM PST should be 22:00 UTC (14 + 8)
|
|
expect(date.toISOString()).toBe('2025-01-10T22:00:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle America/New_York (UTC-4 during DST)', () => {
|
|
// June 15, 2025 is during DST for New York (EDT = UTC-4)
|
|
const date = createDateWithTimezone(
|
|
'2025-06-15',
|
|
'10:30', // 10:30 AM local time
|
|
'America/New_York'
|
|
)
|
|
// 10:30 AM EDT should be 14:30 UTC (10.5 + 4)
|
|
expect(date.toISOString()).toBe('2025-06-15T14:30:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle America/New_York (UTC-5 outside DST)', () => {
|
|
// December 20, 2025 is outside DST for New York (EST = UTC-5)
|
|
const date = createDateWithTimezone(
|
|
'2025-12-20',
|
|
'10:30', // 10:30 AM local time
|
|
'America/New_York'
|
|
)
|
|
// 10:30 AM EST should be 15:30 UTC (10.5 + 5)
|
|
expect(date.toISOString()).toBe('2025-12-20T15:30:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle Europe/London (UTC+1 during DST)', () => {
|
|
// August 5, 2025 is during DST for London (BST = UTC+1)
|
|
const date = createDateWithTimezone(
|
|
'2025-08-05',
|
|
'09:15', // 9:15 AM local time
|
|
'Europe/London'
|
|
)
|
|
// 9:15 AM BST should be 08:15 UTC (9.25 - 1)
|
|
expect(date.toISOString()).toBe('2025-08-05T08:15:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle Europe/London (UTC+0 outside DST)', () => {
|
|
// February 10, 2025 is outside DST for London (GMT = UTC+0)
|
|
const date = createDateWithTimezone(
|
|
'2025-02-10',
|
|
'09:15', // 9:15 AM local time
|
|
'Europe/London'
|
|
)
|
|
// 9:15 AM GMT should be 09:15 UTC (9.25 - 0)
|
|
expect(date.toISOString()).toBe('2025-02-10T09:15:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should correctly handle Asia/Tokyo (UTC+9)', () => {
|
|
// Tokyo does not observe DST (JST = UTC+9)
|
|
const date = createDateWithTimezone(
|
|
'2025-07-01',
|
|
'17:00', // 5:00 PM local time
|
|
'Asia/Tokyo'
|
|
)
|
|
// 5:00 PM JST should be 08:00 UTC (17 - 9)
|
|
expect(date.toISOString()).toBe('2025-07-01T08:00:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should handle date object input', () => {
|
|
// Using a Date object that represents midnight UTC on the target day
|
|
const dateInput = new Date(Date.UTC(2025, 3, 21)) // April 21, 2025
|
|
const date = createDateWithTimezone(dateInput, '14:00', 'America/Los_Angeles')
|
|
expect(date.toISOString()).toBe('2025-04-21T21:00:00.000Z')
|
|
})
|
|
|
|
it.concurrent('should handle time crossing midnight due to timezone offset', () => {
|
|
// Test case: 1:00 AM local time in Sydney (UTC+10/11)
|
|
// This might result in a UTC date that is the *previous* day.
|
|
const date = createDateWithTimezone(
|
|
'2025-10-15', // During DST for Sydney (AEDT = UTC+11)
|
|
'01:00', // 1:00 AM local time
|
|
'Australia/Sydney'
|
|
)
|
|
// 1:00 AM AEDT on Oct 15th should be 14:00 UTC on Oct 14th (1 - 11 = -10 -> previous day 14:00)
|
|
expect(date.toISOString()).toBe('2025-10-14T14:00:00.000Z')
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases and DST Transitions', () => {
|
|
describe('DST Transition Edge Cases', () => {
|
|
it.concurrent('should handle DST spring forward transition (2:00 AM skipped)', () => {
|
|
// In US timezones, DST spring forward happens at 2:00 AM -> jumps to 3:00 AM
|
|
// March 9, 2025 is DST transition day in America/New_York
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/New_York',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [2, 30] as [number, number], // 2:30 AM (during the skipped hour)
|
|
weeklyDay: 1,
|
|
weeklyTime: [2, 30] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [2, 30] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
// Should handle the skipped hour gracefully
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should handle DST fall back transition (1:00 AM repeated)', () => {
|
|
// In US timezones, DST fall back happens at 2:00 AM -> falls back to 1:00 AM
|
|
// November 2, 2025 is DST fall back day in America/Los_Angeles
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-11-01T12:00:00.000Z'))
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/Los_Angeles',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [1, 30] as [number, number], // 1:30 AM (during the repeated hour)
|
|
weeklyDay: 1,
|
|
weeklyTime: [1, 30] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [1, 30] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
})
|
|
|
|
describe('End of Month Edge Cases', () => {
|
|
it.concurrent('should handle February 29th in non-leap year', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-01-15T12:00:00.000Z')) // 2025 is not a leap year
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 29, // Feb doesn't have 29 days in 2025
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
// Should skip February and schedule for next valid month (March 29)
|
|
expect(nextRun.getUTCMonth()).not.toBe(1) // Not February
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it.concurrent('should handle February 29th in leap year', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z')) // 2024 is a leap year
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 29, // Feb has 29 days in 2024
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it.concurrent('should handle day 31 in months with only 30 days', () => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-04-05T12:00:00.000Z')) // April has 30 days
|
|
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 31, // April only has 30 days
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
// Should skip April and schedule for May 31
|
|
expect(nextRun.getUTCDate()).toBe(31)
|
|
expect(nextRun.getUTCMonth()).toBe(4) // May (0-indexed)
|
|
|
|
vi.useRealTimers()
|
|
})
|
|
})
|
|
|
|
describe('Timezone-specific Edge Cases', () => {
|
|
it.concurrent('should handle Australia/Lord_Howe with 30-minute DST shift', () => {
|
|
// Lord Howe Island has a unique 30-minute DST shift
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'Australia/Lord_Howe',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [14, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [14, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [14, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
expect(nextRun > new Date()).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should handle negative UTC offsets correctly', () => {
|
|
// America/Sao_Paulo (UTC-3)
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/Sao_Paulo',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [23, 30] as [number, number], // 11:30 PM local
|
|
weeklyDay: 1,
|
|
weeklyTime: [23, 30] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [23, 30] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun instanceof Date).toBe(true)
|
|
// 11:30 PM in UTC-3 should be 2:30 AM UTC next day
|
|
expect(nextRun.getUTCHours()).toBeGreaterThanOrEqual(2)
|
|
})
|
|
})
|
|
|
|
describe('Complex Cron Pattern Edge Cases', () => {
|
|
it.concurrent('should handle cron with specific days of month and week', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: '0 9 13 * 5', // Friday the 13th at 9:00 AM
|
|
}
|
|
|
|
const result = validateCronExpression(scheduleValues.cronExpression, 'UTC')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it.concurrent('should handle cron with multiple specific hours', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'America/New_York',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: '0 9,12,15,18 * * *', // At 9 AM, noon, 3 PM, 6 PM
|
|
}
|
|
|
|
const result = validateCronExpression(scheduleValues.cronExpression, 'America/New_York')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it.concurrent('should handle cron with step values in multiple fields', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'Europe/Paris',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: '*/15 */2 * * *', // Every 15 minutes, every 2 hours
|
|
}
|
|
|
|
const result = validateCronExpression(scheduleValues.cronExpression, 'Europe/Paris')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it.concurrent('should handle cron with ranges', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'Asia/Singapore',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: '0 9-17 * * 1-5', // Business hours (9 AM - 5 PM) on weekdays
|
|
}
|
|
|
|
const result = validateCronExpression(scheduleValues.cronExpression, 'Asia/Singapore')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
})
|
|
|
|
describe('Validation Edge Cases', () => {
|
|
it.concurrent('should reject cron with invalid day of week', () => {
|
|
const result = validateCronExpression('0 9 * * 8', 'UTC') // Day 8 doesn't exist (0-7)
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should reject cron with invalid month', () => {
|
|
const result = validateCronExpression('0 9 1 13 *', 'UTC') // Month 13 doesn't exist (1-12)
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should reject cron with invalid hour', () => {
|
|
const result = validateCronExpression('0 25 * * *', 'UTC') // Hour 25 doesn't exist (0-23)
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should reject cron with invalid minute', () => {
|
|
const result = validateCronExpression('60 9 * * *', 'UTC') // Minute 60 doesn't exist (0-59)
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should handle standard cron expressions correctly', () => {
|
|
// Croner requires proper spacing, so test with standard spaces
|
|
const result = validateCronExpression('0 9 * * *', 'UTC')
|
|
expect(result.isValid).toBe(true)
|
|
expect(result.nextRun).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it.concurrent('should reject cron with too few fields', () => {
|
|
const result = validateCronExpression('0 9 * *', 'UTC') // Missing day of week field
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should reject cron with too many fields', () => {
|
|
const result = validateCronExpression('0 0 9 * * * *', 'UTC') // Too many fields (has seconds)
|
|
expect(result.isValid).toBe(false)
|
|
expect(result.error).toBeDefined()
|
|
})
|
|
|
|
it.concurrent('should handle timezone with invalid IANA name', () => {
|
|
const result = validateCronExpression('0 9 * * *', 'Invalid/Timezone')
|
|
// Croner might handle this differently, but it should either reject or fall back
|
|
expect(result).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('Boundary Conditions', () => {
|
|
it.concurrent('should handle midnight (00:00)', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [0, 0] as [number, number], // Midnight
|
|
weeklyDay: 1,
|
|
weeklyTime: [0, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [0, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun.getUTCHours()).toBe(0)
|
|
expect(nextRun.getUTCMinutes()).toBe(0)
|
|
})
|
|
|
|
it.concurrent('should handle end of day (23:59)', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [23, 59] as [number, number], // One minute before midnight
|
|
weeklyDay: 1,
|
|
weeklyTime: [23, 59] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [23, 59] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('daily', scheduleValues)
|
|
expect(nextRun.getUTCHours()).toBe(23)
|
|
expect(nextRun.getUTCMinutes()).toBe(59)
|
|
})
|
|
|
|
it.concurrent('should handle first day of month', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 15,
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1, // First day of month
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('monthly', scheduleValues)
|
|
expect(nextRun.getUTCDate()).toBe(1)
|
|
expect(nextRun.getUTCHours()).toBe(9)
|
|
})
|
|
|
|
it.concurrent('should handle minimum interval (every minute)', () => {
|
|
const scheduleValues = {
|
|
scheduleTime: '',
|
|
scheduleStartAt: '',
|
|
timezone: 'UTC',
|
|
minutesInterval: 1, // Every minute
|
|
hourlyMinute: 0,
|
|
dailyTime: [9, 0] as [number, number],
|
|
weeklyDay: 1,
|
|
weeklyTime: [9, 0] as [number, number],
|
|
monthlyDay: 1,
|
|
monthlyTime: [9, 0] as [number, number],
|
|
cronExpression: null,
|
|
}
|
|
|
|
const nextRun = calculateNextRunTime('minutes', scheduleValues)
|
|
const now = Date.now()
|
|
// Should be within the next minute
|
|
expect(nextRun.getTime()).toBeGreaterThan(now)
|
|
expect(nextRun.getTime()).toBeLessThanOrEqual(now + 60 * 1000 + 1000)
|
|
})
|
|
})
|
|
})
|
|
})
|