summaryrefslogtreecommitdiffhomepage
path: root/packages/api/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:53:12 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:53:12 +0900
commit1c800eb35b48b28a82a64e8b2b3ce666323f2c42 (patch)
treec26044a38f0099fbbe98680a02b73bc2417f1eb0 /packages/api/tests
parentc351719ec81a1b87565abc0656756f4aa9e473d0 (diff)
downloaddispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.tar.gz
dispatch-1c800eb35b48b28a82a64e8b2b3ce666323f2c42.zip
feat(wake): probe 4 times per marked hour (:00 :15 :30 :45), coalesce same-tick fires
Marking an hour on the Claude Wake Schedule panel now schedules FOUR probes within that hour instead of one. Rate-window edges are unforgiving — a single probe at :15 can miss the actual reset moment by up to 14 minutes; hitting :00 / :15 / :30 / :45 puts us within ~7 minutes of any reset that happens during that hour. When multiple slots come due in the same 30s scheduler tick (or recover together at boot), they coalesce into a SINGLE upstream wake call — no point hitting Anthropic 4× in the same window. DB schema - wake_schedule is now (hour, slot_minute, next_wake_at) PK (hour, slot_minute). Destructive migration: detect old single-row-per-hour schema by absence of the slot_minute column and DROP TABLE. No other table is touched. Per user direction: no back-compat for old rows. API - POST /models/wake-schedule/toggle add: { hour, timestamps: { '0': ms, '15': ms, '30': ms, '45': ms } } — all 4 slots required, all must be future Unix ms. Delete shape unchanged ({ hour }). - GET /models/wake-schedule shape: schedule: { '9': { '0': ts, '15': ts, '30': ts, '45': ts }, ... } probeSlotMinutes: [0, 15, 30, 45] resetOffsetHours, lastWake, pendingRetry (unchanged from prior commit) Frontend - Computes 4 timestamps client-side (next occurrence of HH:MM in local TZ) and sends them in one request. - markedHours summary now says 'Probes :00 :15 :30 :45 → reset by ~Xh later'. - Same in-flight tracking / current-hour ring / status row as before. Tests - wake-scheduler.test.ts unchanged (pure helpers still correct; added PROBE_SLOT_MINUTES + isProbeSlotMinute exports). - routes.test.ts rewritten for the new payload shape: 12 wake-schedule tests covering snapshot shape, add/remove (full 4-slot round-trip), validation (range, integer, past-slot, missing slot, non-object, missing timestamps), independent multi-hour scheduling, and re-toggle replacement. 417 tests total (was 414).
Diffstat (limited to 'packages/api/tests')
-rw-r--r--packages/api/tests/routes.test.ts113
1 files changed, 87 insertions, 26 deletions
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index e3dff3d..090131c 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -402,8 +402,9 @@ describe("Wake schedule routes", () => {
const res = await app.request("/models/wake-schedule");
expect(res.status).toBe(200);
return (await res.json()) as {
- schedule: Record<string, number>;
+ schedule: Record<string, Record<string, number>>;
resetOffsetHours: number;
+ probeSlotMinutes: number[];
lastWake: unknown;
pendingRetry: unknown;
};
@@ -417,75 +418,135 @@ describe("Wake schedule routes", () => {
});
}
- it("GET returns the full snapshot shape with resetOffsetHours, lastWake, pendingRetry", async () => {
+ /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */
+ function buildTimestamps(base: number): Record<string, number> {
+ return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 };
+ }
+
+ it("GET returns the full snapshot shape with probeSlotMinutes, 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.probeSlotMinutes).toEqual([0, 15, 30, 45]);
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
+ it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => {
+ const base = Date.now() + 60 * 60 * 1000; // 1 h ahead
+ const timestamps = buildTimestamps(base);
- const addRes = await toggle({ hour: 9, timestamp: future });
+ const addRes = await toggle({ hour: 9, timestamps });
expect(addRes.status).toBe(200);
- const addBody = (await addRes.json()) as { schedule: Record<string, number> };
- expect(addBody.schedule["9"]).toBe(future);
+ const addBody = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(addBody.schedule["9"]).toEqual({
+ "0": base,
+ "15": base + 60_000,
+ "30": base + 120_000,
+ "45": base + 180_000,
+ });
const removeRes = await toggle({ hour: 9 });
expect(removeRes.status).toBe(200);
- const removeBody = (await removeRes.json()) as { schedule: Record<string, number> };
+ const removeBody = (await removeRes.json()) as {
+ schedule: Record<string, 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 });
+ const res = await toggle({
+ hour: 24,
+ timestamps: buildTimestamps(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 });
+ const res = await toggle({
+ hour: -1,
+ timestamps: buildTimestamps(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 });
+ const res = await toggle({
+ hour: 4.5,
+ timestamps: buildTimestamps(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 });
+ it("POST toggle rejects past timestamp in any slot", async () => {
+ const future = Date.now() + 60_000;
+ const badTimestamps: Record<string, number> = {
+ "0": future,
+ "15": Date.now() - 1000, // past
+ "30": future + 60_000,
+ "45": future + 120_000,
+ };
+ const res = await toggle({ hour: 7, timestamps: badTimestamps });
expect(res.status).toBe(400);
});
- it("POST toggle rejects missing timestamp on add", async () => {
+ it("POST toggle rejects missing timestamps 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 });
+ it("POST toggle rejects missing slot in timestamps object", async () => {
+ const res = await toggle({
+ hour: 8,
+ timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 },
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects non-object timestamps", async () => {
+ const res = await toggle({ hour: 8, timestamps: 12345 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle: a delete does NOT require timestamps", async () => {
+ const base = Date.now() + 60 * 60 * 1000;
+ const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) });
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> };
+ const body = (await delRes.json()) as { schedule: Record<string, unknown> };
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 });
+ it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => {
+ const base1 = Date.now() + 2 * 60 * 60 * 1000;
+ const base2 = base1 + 60 * 60 * 1000;
+ await toggle({ hour: 14, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 19, timestamps: buildTimestamps(base2) });
const snap = await getSchedule();
- expect(snap.schedule["14"]).toBe(future);
- expect(snap.schedule["19"]).toBe(future + 60_000);
+ expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(snap.schedule["14"]?.["0"]).toBe(base1);
+ expect(snap.schedule["19"]?.["0"]).toBe(base2);
// Cleanup so later tests start clean.
await toggle({ hour: 14 });
await toggle({ hour: 19 });
});
+
+ it("re-toggling the same hour replaces all 4 slot timestamps", async () => {
+ const base1 = Date.now() + 60 * 60 * 1000;
+ const base2 = base1 + 30 * 60 * 1000;
+ await toggle({ hour: 5, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 5 }); // remove
+ const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) });
+ const body = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(body.schedule["5"]?.["0"]).toBe(base2);
+ expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000);
+ await toggle({ hour: 5 });
+ });
});