summaryrefslogtreecommitdiffhomepage
path: root/packages/api/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:30:08 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:30:08 +0900
commit7128c624cf076d698bceb354526cf4cd3dfe5434 (patch)
tree7ddc5a9d57667a493842ec98e39ce3cc279a2587 /packages/api/tests
parentb8bc5c71076d0318291e120e056e8103344493ff (diff)
downloaddispatch-7128c624cf076d698bceb354526cf4cd3dfe5434.tar.gz
dispatch-7128c624cf076d698bceb354526cf4cd3dfe5434.zip
fix(api): wake scheduler — missed-wake recovery, retry consolidation, status surface
Bugs fixed - Missed wakes silently lost. The old loadScheduleFromDB just pushed any past next_wake_at to its 'next occurrence' in *server* local time, so a wake that fired while the API was down never ran — defeating the whole point of the panel (overnight task picks up after a 5h rate-window reset). Now: if missed by <= 2h we fire it on the next tick; either way the entry is rolled forward by 24h-multiple steps. - Server-TZ drift. nextOccurrenceAt15 used the server's local TZ, so on a UTC Docker host running for a user in PST the reschedule slowly migrated the fire time. Now we advance by 24h * N from the original client-supplied timestamp, preserving the user's wall-clock intent. - Retry storm. Every failed wake pushed a new entry into a retries[] array, all converging at the same +5min instant. Replaced with a single shared pending-retry slot whose budget resets on subsequent failures. - Retry race with fresh fires. If a tick fired AND a retry was due in the same iteration we'd double-hit the upstream. Now retries only run on ticks where no fresh wake fired. New behavior surfaced on /wake-schedule: { schedule, resetOffsetHours, lastWake, pendingRetry } POST /wake-schedule/toggle now also rejects non-integer hours (4.5, etc.) and returns the same snapshot shape so the client can stay in sync. Tests: 9 new HTTP route tests covering snapshot shape, add/remove, validation (range, integer, past timestamp, missing timestamp), and independent multi-hour scheduling.
Diffstat (limited to 'packages/api/tests')
-rw-r--r--packages/api/tests/routes.test.ts92
1 files changed, 92 insertions, 0 deletions
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index 9ab2afe..e3dff3d 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -397,3 +397,95 @@ describe("POST /chat/stop", () => {
expect(res.status).toBe(400);
});
});
+describe("Wake schedule routes", () => {
+ async function getSchedule() {
+ const res = await app.request("/models/wake-schedule");
+ expect(res.status).toBe(200);
+ return (await res.json()) as {
+ schedule: Record<string, number>;
+ resetOffsetHours: number;
+ lastWake: unknown;
+ pendingRetry: unknown;
+ };
+ }
+
+ async function toggle(body: Record<string, unknown>) {
+ return app.request("/models/wake-schedule/toggle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+
+ it("GET returns the full snapshot shape with resetOffsetHours, lastWake, pendingRetry", async () => {
+ const snap = await getSchedule();
+ expect(snap.schedule).toBeDefined();
+ // CLAUDE_RESET_OFFSET_HOURS is currently 5; keep this loose in case the
+ // product changes the constant, but verify it's a positive integer.
+ expect(Number.isInteger(snap.resetOffsetHours)).toBe(true);
+ expect(snap.resetOffsetHours).toBeGreaterThan(0);
+ expect(snap.lastWake).toBeNull();
+ expect(snap.pendingRetry).toBeNull();
+ });
+
+ it("POST toggle adds and removes a wake hour", async () => {
+ const future = Date.now() + 60 * 60 * 1000; // 1 h ahead
+
+ const addRes = await toggle({ hour: 9, timestamp: future });
+ expect(addRes.status).toBe(200);
+ const addBody = (await addRes.json()) as { schedule: Record<string, number> };
+ expect(addBody.schedule["9"]).toBe(future);
+
+ const removeRes = await toggle({ hour: 9 });
+ expect(removeRes.status).toBe(200);
+ const removeBody = (await removeRes.json()) as { schedule: Record<string, number> };
+ expect(removeBody.schedule["9"]).toBeUndefined();
+ });
+
+ it("POST toggle rejects out-of-range hour", async () => {
+ const res = await toggle({ hour: 24, timestamp: Date.now() + 60_000 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects negative hour", async () => {
+ const res = await toggle({ hour: -1, timestamp: Date.now() + 60_000 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects non-integer hour", async () => {
+ const res = await toggle({ hour: 4.5, timestamp: Date.now() + 60_000 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects past timestamp on add", async () => {
+ const res = await toggle({ hour: 7, timestamp: Date.now() - 1000 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects missing timestamp on add", async () => {
+ const res = await toggle({ hour: 8 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle: a delete does NOT require a timestamp", async () => {
+ const future = Date.now() + 60 * 60 * 1000;
+ const addRes = await toggle({ hour: 11, timestamp: future });
+ expect(addRes.status).toBe(200);
+ const delRes = await toggle({ hour: 11 });
+ expect(delRes.status).toBe(200);
+ const body = (await delRes.json()) as { schedule: Record<string, number> };
+ expect(body.schedule["11"]).toBeUndefined();
+ });
+
+ it("snapshot reflects multiple scheduled hours independently", async () => {
+ const future = Date.now() + 2 * 60 * 60 * 1000;
+ await toggle({ hour: 14, timestamp: future });
+ await toggle({ hour: 19, timestamp: future + 60_000 });
+ const snap = await getSchedule();
+ expect(snap.schedule["14"]).toBe(future);
+ expect(snap.schedule["19"]).toBe(future + 60_000);
+ // Cleanup so later tests start clean.
+ await toggle({ hour: 14 });
+ await toggle({ hour: 19 });
+ });
+});