158 lines
5.4 KiB
TypeScript
158 lines
5.4 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { Cron } from 'croner';
|
|
import { getNextRun, isValidCron } from '../src/lib/server/scheduler/cron-utils';
|
|
|
|
/**
|
|
* Tests that our cron utilities use AND logic for day-of-month + day-of-week,
|
|
* matching user expectations ("first Saturday") rather than Vixie cron OR logic.
|
|
*/
|
|
|
|
describe('getNextRun: AND logic for day-of-month + day-of-week', () => {
|
|
test('first Saturday of the month: 0 3 1-7 * 6', () => {
|
|
const next = getNextRun('0 3 1-7 * 6');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getDay()).toBe(6); // Saturday
|
|
expect(next!.getDate()).toBeGreaterThanOrEqual(1);
|
|
expect(next!.getDate()).toBeLessThanOrEqual(7);
|
|
});
|
|
|
|
test('first Monday of the month: 0 9 1-7 * 1', () => {
|
|
const next = getNextRun('0 9 1-7 * 1');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getDay()).toBe(1); // Monday
|
|
expect(next!.getDate()).toBeGreaterThanOrEqual(1);
|
|
expect(next!.getDate()).toBeLessThanOrEqual(7);
|
|
});
|
|
|
|
test('1st of month AND Monday: 0 0 1 * 1', () => {
|
|
const next = getNextRun('0 0 1 * 1');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getDate()).toBe(1);
|
|
expect(next!.getDay()).toBe(1);
|
|
});
|
|
|
|
test('day-of-week only: 0 3 * * 1 — next run is a Monday', () => {
|
|
const next = getNextRun('0 3 * * 1');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getDay()).toBe(1);
|
|
});
|
|
|
|
test('day-of-month only: 0 3 15 * * — next run is the 15th', () => {
|
|
const next = getNextRun('0 3 15 * *');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getDate()).toBe(15);
|
|
});
|
|
|
|
test('daily at 3am: 0 3 * * *', () => {
|
|
const next = getNextRun('0 3 * * *');
|
|
expect(next).not.toBeNull();
|
|
expect(next!.getHours()).toBe(3);
|
|
expect(next!.getMinutes()).toBe(0);
|
|
});
|
|
|
|
test('every 30 minutes: */30 * * * *', () => {
|
|
const next = getNextRun('*/30 * * * *');
|
|
expect(next).not.toBeNull();
|
|
const mins = next!.getMinutes();
|
|
expect(mins === 0 || mins === 30).toBe(true);
|
|
});
|
|
|
|
test('invalid expression returns null', () => {
|
|
expect(getNextRun('not a cron')).toBeNull();
|
|
});
|
|
|
|
test('timezone parameter works', () => {
|
|
const next = getNextRun('0 3 * * *', 'Europe/Warsaw');
|
|
expect(next).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isValidCron', () => {
|
|
test('valid 5-field expressions', () => {
|
|
expect(isValidCron('0 3 * * *')).toBe(true);
|
|
expect(isValidCron('*/30 * * * *')).toBe(true);
|
|
expect(isValidCron('0 3 1-7 * 6')).toBe(true);
|
|
expect(isValidCron('0 0 1 * 1')).toBe(true);
|
|
expect(isValidCron('5 4 * * 0')).toBe(true);
|
|
});
|
|
|
|
test('valid 6-field expressions (with seconds)', () => {
|
|
expect(isValidCron('0 0 3 * * *')).toBe(true);
|
|
expect(isValidCron('30 */5 * * * *')).toBe(true);
|
|
expect(isValidCron('0 0 0 1 * *')).toBe(true);
|
|
});
|
|
|
|
test('invalid expressions', () => {
|
|
expect(isValidCron('not a cron')).toBe(false);
|
|
expect(isValidCron('* * *')).toBe(false);
|
|
expect(isValidCron('60 * * * *')).toBe(false);
|
|
expect(isValidCron('* 25 * * *')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Schedule type detection from cron expression', () => {
|
|
// Mirrors logic from api/auto-update/[containerName]/+server.ts
|
|
function detectScheduleType(cronExpression: string): 'daily' | 'weekly' | 'custom' {
|
|
const parts = cronExpression.split(' ');
|
|
if (parts.length === 5) {
|
|
const [, , day, month, dow] = parts;
|
|
if (dow !== '*' && day === '*' && month === '*') return 'weekly';
|
|
if (day === '*' && month === '*' && dow === '*') return 'daily';
|
|
}
|
|
return 'custom';
|
|
}
|
|
|
|
test('detects daily schedule', () => {
|
|
expect(detectScheduleType('0 3 * * *')).toBe('daily');
|
|
expect(detectScheduleType('30 12 * * *')).toBe('daily');
|
|
});
|
|
|
|
test('detects weekly schedule', () => {
|
|
expect(detectScheduleType('0 3 * * 1')).toBe('weekly');
|
|
expect(detectScheduleType('0 0 * * 0')).toBe('weekly');
|
|
expect(detectScheduleType('0 3 * * 6')).toBe('weekly');
|
|
});
|
|
|
|
test('6-field expression is always custom', () => {
|
|
expect(detectScheduleType('0 0 3 * * *')).toBe('custom');
|
|
expect(detectScheduleType('30 0 3 * * 1')).toBe('custom');
|
|
});
|
|
|
|
test('monthly expressions are custom', () => {
|
|
expect(detectScheduleType('0 3 1 * *')).toBe('custom');
|
|
expect(detectScheduleType('0 0 15 6 *')).toBe('custom');
|
|
});
|
|
|
|
test('combined day-of-month and day-of-week is custom', () => {
|
|
expect(detectScheduleType('0 3 1-7 * 6')).toBe('custom');
|
|
});
|
|
|
|
test('every-N-minutes is daily (all day fields are wildcard)', () => {
|
|
expect(detectScheduleType('*/30 * * * *')).toBe('daily');
|
|
});
|
|
});
|
|
|
|
describe('AND vs OR verification', () => {
|
|
test('getNextRun produces different result than croner default for combined fields', () => {
|
|
// croner default (legacyMode:true) uses OR — our getNextRun should use AND
|
|
// With OR, "0 3 1-7 * 6" fires on days 1-7 OR any Saturday (~11/month)
|
|
// With AND, it fires only on Saturdays that are also days 1-7 (~1/month)
|
|
const orJob = new Cron('0 3 1-7 * 6'); // default = OR logic
|
|
const orRuns = orJob.nextRuns(12);
|
|
orJob.stop();
|
|
|
|
// Our getNextRun uses AND — verify all 12 runs satisfy both constraints
|
|
// We can't call getNextRun 12 times (it always returns the same next),
|
|
// but we can verify the single next run satisfies AND
|
|
const andNext = getNextRun('0 3 1-7 * 6');
|
|
expect(andNext).not.toBeNull();
|
|
expect(andNext!.getDay()).toBe(6);
|
|
expect(andNext!.getDate()).toBeGreaterThanOrEqual(1);
|
|
expect(andNext!.getDate()).toBeLessThanOrEqual(7);
|
|
|
|
// OR logic produces runs that are NOT all Saturdays
|
|
const nonSaturdayOrRuns = orRuns.filter((d) => d.getDay() !== 6);
|
|
expect(nonSaturdayOrRuns.length).toBeGreaterThan(0);
|
|
});
|
|
});
|