wutongyuonce 48a8978b7b
feat(scheduler): add interval schedule type (#5291)
* feat(scheduler): add interval schedule type

Allow scheduled tasks to fire every N seconds from last dispatch, not
only wall-clock cron or a single run_at. Cadence is UTC now+N with no
missed-beat catch-up, bounded by min_once_delay_seconds and 30 days.

* fix(scheduler): let interval tasks create, edit, and keep next run

Create/edit now keep every_seconds. Unchanged interval spec no longer
resets next_run_at, including timezone-only PATCH.

* fix(scheduler): keep non-minute intervals on edit

Stop rounding every_seconds to whole minutes in the form. Values that
are not whole minutes or hours now use a seconds unit so edit/duplicate
round-trips the stored cadence instead of rewriting it and resetting
next_run_at. Document that min_once_delay_seconds is also the interval
floor.

* fix(scheduler): clamp interval seconds to the default 60s floor

The new seconds unit allowed 1–59, which the API rejects under the
default min_once_delay_seconds. Clamp the form to >= 60 and show the
floor next to the preview. Also mention interval in the scheduler
field_doc, matching config.example.yaml.

* fix(scheduler): do not clamp interval amount while typing

Keystroke clamp made 90 become 9 -> 60, then 600, and backspace could
not leave 60. Keep the raw field text and apply the 60s floor on blur
and emit only.

* test(scheduler): cover interval input editing

* fix(frontend): preserve saved interval cadence until edited

* style(tests): format scheduled task router tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-10 16:46:01 +08:00

461 lines
12 KiB
TypeScript

import { describe, expect, test } from "@rstest/core";
import {
describeSchedule,
hasScheduleSpec,
clampIntervalAmount,
intervalToSeconds,
minIntervalAmount,
parseCron,
secondsToInterval,
serializeCron,
utcToZonedLocalInput,
zonedLocalToUtcIso,
type CronParts,
} from "@/core/scheduled-tasks/cron";
describe("serializeCron", () => {
test("hourly emits minute + star fields", () => {
expect(serializeCron("hourly", { minute: 30 } as CronParts)).toBe(
"30 * * * *",
);
});
test("daily emits minute + hour", () => {
expect(serializeCron("daily", { minute: 0, hour: 9 } as CronParts)).toBe(
"0 9 * * *",
);
});
test("weekly emits comma-joined weekday numbers in cron order (0=sun)", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["mon", "wed"],
} as CronParts),
).toBe("0 9 * * 1,3");
});
test("weekly sorts + dedupes out-of-order / duplicate weekdays", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["wed", "mon", "wed"],
} as CronParts),
).toBe("0 9 * * 1,3");
});
test("weekly maps sunday to 0", () => {
expect(
serializeCron("weekly", {
minute: 0,
hour: 9,
weekdays: ["sun"],
} as CronParts),
).toBe("0 9 * * 0");
});
test("monthly emits day-of-month", () => {
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 1,
} as CronParts),
).toBe("0 9 1 * *");
});
test("custom returns raw expression", () => {
expect(serializeCron("custom", { raw: "*/5 * * * *" } as CronParts)).toBe(
"*/5 * * * *",
);
});
test("clamps out-of-range minute / hour / day-of-month", () => {
expect(serializeCron("daily", { minute: 99, hour: 24 } as CronParts)).toBe(
"59 23 * * *",
);
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 32,
} as CronParts),
).toBe("0 9 31 * *");
expect(
serializeCron("monthly", {
minute: 0,
hour: 9,
dayOfMonth: 0,
} as CronParts),
).toBe("0 9 1 * *");
});
});
describe("parseCron", () => {
test("hourly: M * * * *", () => {
expect(parseCron("30 * * * *").preset).toBe("hourly");
expect(parseCron("30 * * * *").parts.minute).toBe(30);
});
test("daily: M H * * *", () => {
const r = parseCron("0 9 * * *");
expect(r.preset).toBe("daily");
expect(r.parts).toMatchObject({ minute: 0, hour: 9 });
});
test("weekly: M H * * DOW", () => {
const r = parseCron("0 9 * * 1,3");
expect(r.preset).toBe("weekly");
expect(r.parts.weekdays).toEqual(["mon", "wed"]);
});
test("weekly maps 0 and 7 to sunday", () => {
expect(parseCron("0 9 * * 0").parts.weekdays).toEqual(["sun"]);
expect(parseCron("0 9 * * 7").parts.weekdays).toEqual(["sun"]);
});
test("monthly: M H DOM * *", () => {
const r = parseCron("0 9 1 * *");
expect(r.preset).toBe("monthly");
expect(r.parts.dayOfMonth).toBe(1);
});
test("non-canonical forms fall back to custom", () => {
expect(parseCron("*/5 * * * *").preset).toBe("custom");
expect(parseCron("0 9,10 * * *").preset).toBe("custom");
expect(parseCron("0 9 * * 1-5").preset).toBe("custom");
expect(parseCron("garbage").preset).toBe("custom");
expect(parseCron("garbage").parts.raw).toBe("garbage");
});
});
describe("describeSchedule", () => {
const baseCron = {
minute: 0,
hour: 9,
weekdays: [],
dayOfMonth: 1,
} as CronParts;
test("once renders wall time + timezone (en)", () => {
expect(
describeSchedule(
{
scheduleType: "once",
runAtLocal: "2026-07-02T09:00",
timezone: "Asia/Shanghai",
},
"en",
),
).toBe("Once at 2026-07-02 09:00 (Asia/Shanghai)");
});
test("daily en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "daily",
parts: baseCron,
timezone: "UTC",
},
"en",
),
).toBe("Every day at 09:00 (UTC)");
});
test("daily zh", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "daily",
parts: baseCron,
timezone: "UTC",
},
"zh",
),
).toBe("每天 09:00 (UTC)");
});
test("weekly en lists weekday abbreviations", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: ["mon", "wed"] },
timezone: "UTC",
},
"en",
),
).toBe("Every Mon, Wed at 09:00 (UTC)");
});
test("weekly zh lists 周X", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: ["mon", "wed", "fri"] },
timezone: "UTC",
},
"zh",
),
).toBe("每周 周一、周三、周五 09:00 (UTC)");
});
test("weekly with no weekdays falls back to daily wording", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "weekly",
parts: { ...baseCron, weekdays: [] },
timezone: "UTC",
},
"en",
),
).toBe("Every day at 09:00 (UTC)");
});
test("hourly en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "hourly",
parts: { minute: 30 },
timezone: "UTC",
},
"en",
),
).toBe("Every hour at :30 (UTC)");
});
test("monthly en", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "monthly",
parts: { minute: 0, hour: 9, dayOfMonth: 1 },
timezone: "UTC",
},
"en",
),
).toBe("On day 1 of every month at 09:00 (UTC)");
});
test("custom en echoes the expression", () => {
expect(
describeSchedule(
{
scheduleType: "cron",
preset: "custom",
parts: { raw: "*/5 * * * *" },
timezone: "UTC",
},
"en",
),
).toBe("Custom: */5 * * * * (UTC)");
});
test("interval minutes en/zh omit timezone", () => {
expect(
describeSchedule(
{
scheduleType: "interval",
intervalAmount: 90,
intervalUnit: "minutes",
timezone: "Asia/Shanghai",
},
"en",
),
).toBe("Every 90 minutes");
expect(
describeSchedule(
{
scheduleType: "interval",
intervalAmount: 90,
intervalUnit: "minutes",
timezone: "Asia/Shanghai",
},
"zh",
),
).toBe("每 90 分钟");
});
test("interval singular hour en", () => {
expect(
describeSchedule(
{
scheduleType: "interval",
intervalAmount: 1,
intervalUnit: "hours",
timezone: "UTC",
},
"en",
),
).toBe("Every hour");
});
test("interval seconds en/zh omit timezone", () => {
expect(
describeSchedule(
{
scheduleType: "interval",
intervalAmount: 90,
intervalUnit: "seconds",
timezone: "Asia/Shanghai",
},
"en",
),
).toBe("Every 90 seconds");
expect(
describeSchedule(
{
scheduleType: "interval",
intervalAmount: 90,
intervalUnit: "seconds",
timezone: "Asia/Shanghai",
},
"zh",
),
).toBe("每 90 秒");
});
});
describe("interval conversion", () => {
test("minutes and hours convert to seconds", () => {
expect(intervalToSeconds(90, "seconds")).toBe(90);
expect(intervalToSeconds(90, "minutes")).toBe(5400);
expect(intervalToSeconds(2, "hours")).toBe(7200);
});
test("whole hours stay in hours; whole minutes stay in minutes", () => {
expect(secondsToInterval(7200)).toEqual({ amount: 2, unit: "hours" });
expect(secondsToInterval(5400)).toEqual({ amount: 90, unit: "minutes" });
expect(secondsToInterval(120)).toEqual({ amount: 2, unit: "minutes" });
});
test("edit/duplicate round-trip keeps intervals that are not whole minutes", () => {
const stored = 90;
const displayed = secondsToInterval(stored);
expect(displayed).toEqual({ amount: 90, unit: "seconds" });
expect(intervalToSeconds(displayed.amount, displayed.unit)).toBe(stored);
});
test("seconds unit clamps below the default 60s server floor", () => {
expect(minIntervalAmount("seconds")).toBe(60);
expect(minIntervalAmount("minutes")).toBe(1);
expect(minIntervalAmount("hours")).toBe(1);
expect(clampIntervalAmount(30, "seconds")).toBe(60);
expect(clampIntervalAmount(90, "seconds")).toBe(90);
expect(clampIntervalAmount(1, "minutes")).toBe(1);
});
test("hasScheduleSpec accepts interval every_seconds", () => {
expect(hasScheduleSpec({ every_seconds: 90 })).toBe(true);
expect(hasScheduleSpec({ cron: "0 9 * * *" })).toBe(true);
expect(hasScheduleSpec({ run_at: "2026-07-02T01:00:00+00:00" })).toBe(true);
expect(hasScheduleSpec({})).toBe(false);
expect(hasScheduleSpec({ every_seconds: 0 })).toBe(false);
});
});
describe("zonedLocalToUtcIso", () => {
test("Asia/Shanghai is UTC-8 (wall 09:00 -> 01:00Z)", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai")).toBe(
"2026-07-02T01:00:00+00:00",
);
});
test("UTC passes through", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "UTC")).toBe(
"2026-07-02T09:00:00+00:00",
);
});
test("America/New_York July is EDT (-04:00)", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "America/New_York")).toBe(
"2026-07-02T13:00:00+00:00",
);
});
test("America/New_York January is EST (-05:00) — DST season flip", () => {
expect(zonedLocalToUtcIso("2026-01-15T09:00", "America/New_York")).toBe(
"2026-01-15T14:00:00+00:00",
);
});
test("Asia/Kolkata half-hour offset UTC+5:30", () => {
expect(zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Kolkata")).toBe(
"2026-07-02T03:30:00+00:00",
);
});
});
describe("utcToZonedLocalInput", () => {
test("Shanghai +8: 01:00Z -> 09:00 wall", () => {
expect(
utcToZonedLocalInput("2026-07-02T01:00:00+00:00", "Asia/Shanghai"),
).toBe("2026-07-02T09:00");
});
test("New_York EDT: 13:00Z -> 09:00 wall", () => {
expect(
utcToZonedLocalInput("2026-07-02T13:00:00+00:00", "America/New_York"),
).toBe("2026-07-02T09:00");
});
test("invalid -> empty string", () => {
expect(utcToZonedLocalInput("not-a-date", "UTC")).toBe("");
});
test("round-trips with zonedLocalToUtcIso", () => {
const iso = zonedLocalToUtcIso("2026-07-02T09:00", "Asia/Shanghai");
expect(utcToZonedLocalInput(iso, "Asia/Shanghai")).toBe("2026-07-02T09:00");
});
});
describe("zonedLocalToUtcIso DST transitions", () => {
// US spring-forward 2026: clocks jump 02:00 -> 03:00 EST->EDT on 2026-03-08.
test("New_York wall time after spring-forward uses the post-transition offset", () => {
// 03:30 EDT (-4) is 07:30Z; the stale pre-transition offset (-5) would say 08:30Z.
expect(zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York")).toBe(
"2026-03-08T07:30:00+00:00",
);
});
test("New_York wall time before spring-forward keeps the EST offset", () => {
expect(zonedLocalToUtcIso("2026-03-08T01:30", "America/New_York")).toBe(
"2026-03-08T06:30:00+00:00",
);
});
// US fall-back 2026: clocks repeat 01:00-02:00 EDT->EST on 2026-11-01.
test("New_York ambiguous fall-back wall time resolves deterministically", () => {
expect(zonedLocalToUtcIso("2026-11-01T01:30", "America/New_York")).toBe(
"2026-11-01T05:30:00+00:00",
);
});
test("create -> edit round-trip survives spring-forward", () => {
const iso = zonedLocalToUtcIso("2026-03-08T03:30", "America/New_York");
expect(utcToZonedLocalInput(iso, "America/New_York")).toBe(
"2026-03-08T03:30",
);
});
test("no-DST timezone is unaffected", () => {
expect(zonedLocalToUtcIso("2026-03-08T03:30", "Asia/Shanghai")).toBe(
"2026-03-07T19:30:00+00:00",
);
});
});