diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:30:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:30:08 +0900 |
| commit | 7128c624cf076d698bceb354526cf4cd3dfe5434 (patch) | |
| tree | 7ddc5a9d57667a493842ec98e39ce3cc279a2587 /packages/api/tests | |
| parent | b8bc5c71076d0318291e120e056e8103344493ff (diff) | |
| download | dispatch-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.ts | 92 |
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 }); + }); +}); |
