/** * 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: " 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) }) }) }) })