import { describe, expect, test } from 'bun:test' import { parseCronExpression, computeNextCronRun, cronToHuman } from '../cron' describe('parseCronExpression', () => { describe('valid expressions', () => { test('parses wildcard fields', () => { const result = parseCronExpression('* * * * *') expect(result).not.toBeNull() expect(result!.minute).toHaveLength(60) expect(result!.hour).toHaveLength(24) expect(result!.dayOfMonth).toHaveLength(31) expect(result!.month).toHaveLength(12) expect(result!.dayOfWeek).toHaveLength(7) }) test('parses specific values', () => { const result = parseCronExpression('30 14 1 6 3') expect(result).not.toBeNull() expect(result!.minute).toEqual([30]) expect(result!.hour).toEqual([14]) expect(result!.dayOfMonth).toEqual([1]) expect(result!.month).toEqual([6]) expect(result!.dayOfWeek).toEqual([3]) }) test('parses step syntax', () => { const result = parseCronExpression('*/5 * * * *') expect(result).not.toBeNull() expect(result!.minute).toEqual([ 0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, ]) }) test('parses range syntax', () => { const result = parseCronExpression('1-5 * * * *') expect(result).not.toBeNull() expect(result!.minute).toEqual([1, 2, 3, 4, 5]) }) test('parses range with step', () => { const result = parseCronExpression('1-10/3 * * * *') expect(result).not.toBeNull() expect(result!.minute).toEqual([1, 4, 7, 10]) }) test('parses comma-separated list', () => { const result = parseCronExpression('1,15,30 * * * *') expect(result).not.toBeNull() expect(result!.minute).toEqual([1, 15, 30]) }) test('parses day-of-week 7 as Sunday alias', () => { const result = parseCronExpression('0 0 * * 7') expect(result).not.toBeNull() expect(result!.dayOfWeek).toEqual([0]) }) test('parses range with day-of-week 7', () => { const result = parseCronExpression('0 0 * * 5-7') expect(result).not.toBeNull() expect(result!.dayOfWeek).toEqual([0, 5, 6]) }) test('parses complex combined expression', () => { const result = parseCronExpression('0,30 9-17 * * 1-5') expect(result).not.toBeNull() expect(result!.minute).toEqual([0, 30]) expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]) expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5]) }) }) describe('invalid expressions', () => { test('returns null for wrong field count', () => { expect(parseCronExpression('* * *')).toBeNull() }) test('returns null for out-of-range values', () => { expect(parseCronExpression('60 * * * *')).toBeNull() }) test('returns null for invalid step', () => { expect(parseCronExpression('*/0 * * * *')).toBeNull() }) test('returns null for reversed range', () => { expect(parseCronExpression('10-5 * * * *')).toBeNull() }) test('returns null for empty string', () => { expect(parseCronExpression('')).toBeNull() }) test('returns null for non-numeric tokens', () => { expect(parseCronExpression('abc * * * *')).toBeNull() }) test('returns null for undefined input without throwing', () => { // CronCreateTool.validateInput receives raw params from ExecuteExtraTool; // when the model passes a wrong field name (e.g. 'schedule' instead of // 'cron'), input.cron is undefined. Calling .trim() on undefined crashes // with "undefined is not an object" — parseCronExpression must fail // gracefully so the tool layer can return a clear validation error. expect(parseCronExpression(undefined as unknown as string)).toBeNull() expect(parseCronExpression(null as unknown as string)).toBeNull() }) }) describe('field range validation', () => { test('minute: 0-59', () => { expect(parseCronExpression('0 * * * *')).not.toBeNull() expect(parseCronExpression('59 * * * *')).not.toBeNull() expect(parseCronExpression('60 * * * *')).toBeNull() }) test('hour: 0-23', () => { expect(parseCronExpression('* 0 * * *')).not.toBeNull() expect(parseCronExpression('* 23 * * *')).not.toBeNull() expect(parseCronExpression('* 24 * * *')).toBeNull() }) test('dayOfMonth: 1-31', () => { expect(parseCronExpression('* * 1 * *')).not.toBeNull() expect(parseCronExpression('* * 31 * *')).not.toBeNull() expect(parseCronExpression('* * 0 * *')).toBeNull() expect(parseCronExpression('* * 32 * *')).toBeNull() }) test('month: 1-12', () => { expect(parseCronExpression('* * * 1 *')).not.toBeNull() expect(parseCronExpression('* * * 12 *')).not.toBeNull() expect(parseCronExpression('* * * 0 *')).toBeNull() expect(parseCronExpression('* * * 13 *')).toBeNull() }) test('dayOfWeek: 0-6 (plus 7 alias)', () => { expect(parseCronExpression('* * * * 0')).not.toBeNull() expect(parseCronExpression('* * * * 6')).not.toBeNull() expect(parseCronExpression('* * * * 7')).not.toBeNull() // alias for 0 expect(parseCronExpression('* * * * 8')).toBeNull() }) }) }) describe('computeNextCronRun', () => { test('finds next minute', () => { const fields = parseCronExpression('31 14 * * *')! const from = new Date(2026, 0, 15, 14, 30, 45) // 14:30:45 const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getHours()).toBe(14) expect(next!.getMinutes()).toBe(31) }) test('finds next hour', () => { const fields = parseCronExpression('0 15 * * *')! const from = new Date(2026, 0, 15, 14, 30) const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getHours()).toBe(15) expect(next!.getMinutes()).toBe(0) }) test('rolls to next day', () => { const fields = parseCronExpression('0 10 * * *')! const from = new Date(2026, 0, 15, 14, 30) const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getDate()).toBe(16) expect(next!.getHours()).toBe(10) }) test('is strictly after from date', () => { const fields = parseCronExpression('30 14 * * *')! const from = new Date(2026, 0, 15, 14, 30, 0) // exactly on cron time const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getTime()).toBeGreaterThan(from.getTime()) }) test('every 5 minutes from arbitrary time', () => { const fields = parseCronExpression('*/5 * * * *')! const from = new Date(2026, 0, 15, 14, 32) const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getMinutes()).toBe(35) }) test('every minute', () => { const fields = parseCronExpression('* * * * *')! const from = new Date(2026, 0, 15, 14, 32, 45) const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getMinutes()).toBe(33) }) test('handles step across midnight', () => { const fields = parseCronExpression('0 0 * * *')! const from = new Date(2026, 0, 15, 23, 59) const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() expect(next!.getHours()).toBe(0) expect(next!.getDate()).toBe(16) }) test('OR semantics when both dom and dow constrained', () => { // dom=15, dow=3(Wed) - matches 15th OR Wednesday const fields = parseCronExpression('0 0 15 * 3')! const from = new Date(2026, 0, 12, 0, 0) // Monday Jan 12 const next = computeNextCronRun(fields, from) expect(next).not.toBeNull() // Should match the first of either: next Wednesday(Jan 14) or 15th(Jan 15) const dayOfWeek = next!.getDay() const dayOfMonth = next!.getDate() expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true) }) }) describe('cronToHuman', () => { test('every N minutes', () => { expect(cronToHuman('*/5 * * * *')).toBe('Every 5 minutes') }) test('every minute', () => { expect(cronToHuman('*/1 * * * *')).toBe('Every minute') }) test('every hour at :00', () => { expect(cronToHuman('0 * * * *')).toBe('Every hour') }) test('every hour at :30', () => { expect(cronToHuman('30 * * * *')).toBe('Every hour at :30') }) test('every N hours', () => { expect(cronToHuman('0 */2 * * *')).toBe('Every 2 hours') }) test('daily at specific time', () => { const result = cronToHuman('30 9 * * *') expect(result).toContain('Every day at') expect(result).toContain('9:30') }) test('specific day of week', () => { const result = cronToHuman('0 9 * * 3') expect(result).toContain('Wednesday') expect(result).toContain('9:00') }) test('weekdays', () => { const result = cronToHuman('0 9 * * 1-5') expect(result).toContain('Weekdays') expect(result).toContain('9:00') }) test('returns raw cron for complex patterns', () => { expect(cronToHuman('0,30 9-17 * * 1-5')).toBe('0,30 9-17 * * 1-5') }) test('returns raw cron for wrong field count', () => { expect(cronToHuman('* * *')).toBe('* * *') }) })