Ctrl K
ComunidadGuías…

test/cron.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect } from 'vitest';
import { parseCron, isValidCron, nextCronTime, nextCronIso, CRON_MACROS, MAX_SCHEDULE_LEN, describeCron } from '../lib/cron.js';

// Local time throughout, because that is what the parser answers in. Building
// the reference points with `new Date(y, m, d, ...)` keeps the tests honest
// wherever the suite runs: an ISO literal would be UTC and would drift.
const at = (y, m, d, hh = 0, mm = 0) => new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
const next = (expr, from) => new Date(nextCronTime(expr, from));

describe('the day-field star rule', () => {
  const at = (y, m, d, hh = 0, mm = 0) => new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
  const next = (expr, from) => new Date(nextCronTime(expr, from));

  it('treats a step like */2 as a star, the way Vixie cron does', () => {
    // dow begins with `*`, so BOTH day conditions must hold: the 1st of a
    // month AND an even weekday. The OR reading would return the very next
    // even weekday instead of waiting for a matching 1st.
    const d = next('0 0 1 * */2', at(2026, 5, 15));
    expect(d.getDate()).toBe(1);
    expect([0, 2, 4, 6]).toContain(d.getDay());
  });

  it('applies the same rule with the step on the day-of-month side', () => {
    const d = next('0 0 */2 * 1', at(2026, 5, 15));
    expect(d.getDay()).toBe(1);          // a Monday
    expect(d.getDate() % 2).toBe(1);     // on an odd day (*/2 from 1)
  });
});

describe('parseCron', () => {
  it('expands each field into the values it matches', () => {
    const p = parseCron('0 9 * * 1-5');
    expect(p.error).toBeUndefined();
    expect([...p.minute]).toEqual([0]);
    expect([...p.hour]).toEqual([9]);
    expect([...p.dow].sort()).toEqual([1, 2, 3, 4, 5]);
    expect(p.domStar).toBe(true);
    expect(p.dowStar).toBe(false);
  });

  it('handles steps, ranges, lists, and a bare number with a step', () => {
    expect([...parseCron('*/15 * * * *').minute]).toEqual([0, 15, 30, 45]);
    expect([...parseCron('0-10/5 * * * *').minute]).toEqual([0, 5, 10]);
    expect([...parseCron('0,30 * * * *').minute]).toEqual([0, 30]);
    // "5/20" is crontab for "from 5 to the end of the field, every 20".
    expect([...parseCron('5/20 * * * *').minute]).toEqual([5, 25, 45]);
  });

  it('accepts 7 as Sunday, the same day 0 already is', () => {
    expect([...parseCron('0 0 * * 7').dow]).toEqual([0]);
    expect([...parseCron('0 0 * * 0,7').dow]).toEqual([0]);
  });

  it('expands every documented shorthand', () => {
    for (const [macro, expanded] of Object.entries(CRON_MACROS)) {
      expect(isValidCron(macro)).toBe(true);
      expect(nextCronTime(macro, at(2026, 3, 4, 12))).toBe(nextCronTime(expanded, at(2026, 3, 4, 12)));
    }
    // Case-insensitively, since the field is free text.
    expect(isValidCron('@DAILY')).toBe(true);
  });

  it('explains what is wrong rather than just failing', () => {
    expect(parseCron('').error).toMatch(/needs a cron schedule/i);
    expect(parseCron('0 9 * *').error).toMatch(/five fields/i);
    expect(parseCron('0 9 * * * *').error).toMatch(/five fields/i);
    expect(parseCron('60 * * * *').error).toMatch(/minute must be between 0 and 59/i);
    expect(parseCron('0 24 * * *').error).toMatch(/hour must be between 0 and 23/i);
    expect(parseCron('0 0 0 * *').error).toMatch(/day of month must be between 1 and 31/i);
    expect(parseCron('0 0 * 13 *').error).toMatch(/month must be between 1 and 12/i);
    expect(parseCron('0 0 * * 8').error).toMatch(/day of week must be between 0 and 7/i);
    expect(parseCron('10-5 * * * *').error).toMatch(/backwards/i);
    expect(parseCron('*/0 * * * *').error).toMatch(/step/i);
    expect(parseCron('*/x * * * *').error).toMatch(/step/i);
    expect(parseCron('1/2/3 * * * *').error).toMatch(/not a valid/i);
    expect(parseCron('mon * * * *').error).toMatch(/not a valid minute/i);
    expect(parseCron('0,,5 * * * *').error).toMatch(/empty minute/i);
    expect(parseCron('@nightly').error).toMatch(/unknown schedule shorthand/i);
    expect(parseCron('0 '.repeat(MAX_SCHEDULE_LEN)).error).toMatch(/too long/i);
  });
});

describe('nextCronTime', () => {
  it('is strictly after the moment asked about, so a job cannot re-fire on itself', () => {
    // Exactly on a match: the answer is the NEXT one, not this one.
    expect(next('0 * * * *', at(2026, 6, 10, 14, 0)).getTime()).toBe(at(2026, 6, 10, 15, 0));
  });

  it('finds the next matching minute within the hour', () => {
    expect(next('*/15 * * * *', at(2026, 6, 10, 14, 3)).getTime()).toBe(at(2026, 6, 10, 14, 15));
  });

  it('rolls over the hour, the day, the month and the year', () => {
    expect(next('0 9 * * *', at(2026, 6, 10, 14, 0)).getTime()).toBe(at(2026, 6, 11, 9, 0));
    expect(next('0 0 1 * *', at(2026, 6, 10)).getTime()).toBe(at(2026, 7, 1, 0, 0));
    expect(next('0 0 1 1 *', at(2026, 6, 10)).getTime()).toBe(at(2027, 1, 1, 0, 0));
  });

  it('skips the weekend for a weekday schedule', () => {
    // 2026-08-28 is a Friday, so the next weekday 09:00 is the Monday.
    expect(next('0 9 * * 1-5', at(2026, 8, 28, 10, 0)).getTime()).toBe(at(2026, 8, 31, 9, 0));
  });

  it('ORs the two day fields when both are restricted, and ANDs nothing when one is *', () => {
    // Both restricted: the 13th OR any Friday. 2026-03-04 is a Wednesday, so
    // the Friday (the 6th) comes before the 13th.
    expect(next('0 0 13 * 5', at(2026, 3, 4, 12)).getTime()).toBe(at(2026, 3, 6, 0, 0));
    // Only day-of-month restricted: the 13th, and the weekday is irrelevant.
    expect(next('0 0 13 * *', at(2026, 3, 4, 12)).getTime()).toBe(at(2026, 3, 13, 0, 0));
  });

  it('reaches a date years away', () => {
    // 29 February: 2028 is the next leap year after 2026.
    expect(next('0 0 29 2 *', at(2026, 3, 1)).getTime()).toBe(at(2028, 2, 29, 0, 0));
  });

  it('gives up rather than looping on an expression that can never match', () => {
    // Syntactically fine, but 30 February is not a date.
    expect(nextCronTime('0 0 30 2 *', at(2026, 3, 1))).toBeNull();
    expect(nextCronIso('0 0 30 2 *', at(2026, 3, 1))).toBeNull();
  });

  it('skips an hour that does not exist on a daylight-saving day, and keeps going', () => {
    // 2027-03-14 02:30 never happens in a US spring-forward zone. Skipping it
    // is what cron does; what matters here is that the walk still terminates
    // and lands on the next real occurrence rather than spinning on local
    // arithmetic that cannot advance.
    const from = new Date(2027, 2, 13, 12, 0, 0).getTime();
    const hit = nextCronTime('30 2 * * *', from);
    expect(hit).not.toBeNull();
    expect(hit).toBeGreaterThan(from);
    // Either the 14th (in a zone with no transition) or the 15th (in one that
    // has it) — never a time before the 14th, and never a hang.
    expect(hit).toBeLessThanOrEqual(at(2027, 3, 15, 2, 30));
  });

  it('returns null for an expression it cannot parse', () => {
    expect(nextCronTime('nonsense')).toBeNull();
    expect(nextCronIso('nonsense')).toBeNull();
  });

  it('answers in ISO when asked to', () => {
    expect(nextCronIso('0 9 * * *', at(2026, 6, 10, 14))).toBe(new Date(at(2026, 6, 11, 9)).toISOString());
  });
});

describe('describeCron', () => {
  it.each([
    ['0 9 * * 1', 'Mondays at 9:00 AM'],
    ['0 9 * * 1-5', 'Weekdays at 9:00 AM'],
    ['30 17 * * 1,3,5', 'Mondays, Wednesdays and Fridays at 5:30 PM'],
    ['0 10 * * 0,6', 'Weekends at 10:00 AM'],
    ['@daily', 'Every day at midnight'],
    ['0 12 * * *', 'Every day at 12:00 PM'],
    ['*/15 * * * *', 'Every 15 minutes'],
    ['* * * * *', 'Every minute'],
    ['0 * * * *', 'Every hour'],
    ['@hourly', 'Every hour'],
    ['15 * * * *', 'Every hour at :15'],
    ['0 */6 * * *', 'Every 6 hours'],
    ['0 9 1 * *', 'Monthly on the 1st at 9:00 AM'],
    ['0 9 22 * *', 'Monthly on the 22nd at 9:00 AM'],
    ['@weekly', 'Sundays at midnight'],
  ])('%s -> %s', (expr, words) => expect(describeCron(expr)).toBe(words));

  it.each(['0 9 3 10 *', '0 9 1 * 1', '0,30 9 * * *', '9-17 * * * *', 'nonsense', '', '0 9 * * MON'])(
    'returns null for %j so the raw cron is shown', (expr) => expect(describeCron(expr)).toBeNull());
});