diff options
Diffstat (limited to 'packages/api')
| -rw-r--r-- | packages/api/src/routes/models.ts | 32 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 90 |
2 files changed, 113 insertions, 9 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 5f47231..03c079a 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -901,6 +901,7 @@ function scheduleSnapshot(): { modelsRoutes.post("/wake-schedule/toggle", async (c) => { const body = await c.req.json<{ hour?: unknown; + action?: unknown; timestamps?: unknown; }>(); const hour = body.hour; @@ -911,13 +912,32 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => { return c.json({ error: "hour must be an integer 0-23" }, 400); } - if (wakeSchedule[hour] !== undefined) { - // Toggle off — remove all 4 slots for this hour. - deleteHour(wakeSchedule, hour); + // The action is the CLIENT'S DECLARED INTENT. Previously the server + // derived add-vs-remove from its own in-memory state, which meant a UI + // that had become stale (e.g. due to a snapshot race) would have its + // clicks silently inverted: user clicks to turn ON an hour the UI shows + // as OFF, server sees it as already-ON, deletes it. Requiring an explicit + // action makes the request idempotent and self-describing — a stale UI's + // click is now either a redundant no-op (action matches server state) or + // a recoverable replace (action="on" against an already-on hour just + // refreshes its timestamps to the new values). + const action = body.action; + if (action !== "on" && action !== "off") { + return c.json({ error: "action must be 'on' or 'off'" }, 400); + } + + if (action === "off") { + // Idempotent: removing an already-removed hour is a no-op success. + if (wakeSchedule[hour] !== undefined) { + deleteHour(wakeSchedule, hour); + } } else { - // Toggle on — require a `timestamps` object with one absolute Unix ms - // per probe slot (0, 15, 30, 45). The client is the source of truth - // for the *local* wall-clock intent of each probe. + // action === "on" — require a `timestamps` object with one absolute + // Unix ms per probe slot (0, 15, 30, 45). The client is the source + // of truth for the *local* wall-clock intent of each probe. + // Idempotent: turning ON an already-on hour replaces its timestamps + // (so a UI recovering from a desync can re-assert the correct wall- + // clock intent without first deleting). const timestamps = body.timestamps; if (timestamps === null || typeof timestamps !== "object") { return c.json( diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index c44f473..f92b94c 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -410,11 +410,21 @@ describe("Wake schedule routes", () => { }; } + /** + * Auto-derives `action` from `timestamps` presence: + * - body has `timestamps` → action = "on" + * - body has no `timestamps` → action = "off" + * Tests can override by passing `action` explicitly. This keeps the + * intent-vs-state contract enforced (every request carries an explicit + * action) while keeping the existing test bodies short. + */ async function toggle(body: Record<string, unknown>) { + const withAction: Record<string, unknown> = + "action" in body ? body : { ...body, action: body.timestamps !== undefined ? "on" : "off" }; return app.request("/models/wake-schedule/toggle", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + body: JSON.stringify(withAction), }); } @@ -533,8 +543,11 @@ describe("Wake schedule routes", () => { } }); - it("POST toggle rejects missing timestamps on add", async () => { - const res = await toggle({ hour: 8 }); + it("POST toggle rejects action='on' with missing timestamps", async () => { + // Under the explicit-action contract, the helper would otherwise + // auto-derive { action: 'off' } for a body with no timestamps. Pass + // action explicitly so we exercise the on-without-timestamps reject. + const res = await toggle({ hour: 8, action: "on" }); expect(res.status).toBe(400); }); @@ -616,4 +629,75 @@ describe("Wake schedule routes", () => { await toggle({ hour: 1 }); await toggle({ hour: 3 }); }); + + it("POST toggle requires explicit action: 'on' | 'off' (Gemini round-2 #2)", async () => { + // The server must reject a request that omits `action`. This is the + // contract that closes the desync-causes-inverted-clicks failure mode: + // the server is no longer allowed to guess the user's intent from its + // own (possibly stale-relative-to-UI) in-memory state. + const now = Date.now(); + const raw = await app.request("/models/wake-schedule/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hour: 6, timestamps: buildTimestamps(now + 60_000) }), + }); + expect(raw.status).toBe(400); + + for (const bad of ["toggle", "ON", "OFF", "", true, 1, null]) { + const res = await app.request("/models/wake-schedule/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hour: 6, action: bad, timestamps: buildTimestamps(now + 60_000) }), + }); + expect(res.status, `bad action: ${JSON.stringify(bad)}`).toBe(400); + } + }); + + it("POST toggle action='off' is idempotent on an already-off hour (no error)", async () => { + // Stale UI scenario: the UI thinks hour 4 is on and clicks to turn it + // off, but server state had already deleted it. Must succeed quietly, + // not 400 or change anything else. + const before = await getSchedule(); + expect(before.schedule["4"]).toBeUndefined(); + const res = await toggle({ hour: 4 }); // helper auto-derives action='off' + expect(res.status).toBe(200); + const body = (await res.json()) as { schedule: Record<string, unknown> }; + expect(body.schedule["4"]).toBeUndefined(); + }); + + it("POST toggle action='on' on an already-on hour REPLACES timestamps (recovery from desync)", async () => { + // Stale UI scenario: the UI thinks hour 12 is off and clicks to turn + // it on, but server state had it already on (from a snapshot the UI + // missed). Old behavior would have INVERTED the click (turning it + // off); new behavior replaces the timestamps with the user's freshly + // computed wall-clock intent and keeps the hour on. + const base1 = Date.now() + 60 * 60 * 1000; + const base2 = base1 + 7 * 60 * 60 * 1000; + const addRes = await toggle({ hour: 12, timestamps: buildTimestamps(base1) }); + expect(addRes.status).toBe(200); + const reAddRes = await toggle({ hour: 12, timestamps: buildTimestamps(base2) }); + expect(reAddRes.status).toBe(200); + const body = (await reAddRes.json()) as { + schedule: Record<string, Record<string, number>>; + }; + // Hour still present (NOT inverted to off), AND timestamps refreshed. + expect(body.schedule["12"]?.["0"]).toBe(base2); + expect(body.schedule["12"]?.["45"]).toBe(base2 + 180_000); + await toggle({ hour: 12 }); // cleanup + }); + + it("POST toggle action='off' ignores timestamps payload (off doesn't need them)", async () => { + // Accepting extra fields on an off request is fine; we only fail if + // an action='on' lacks timestamps. + const base = Date.now() + 60 * 60 * 1000; + await toggle({ hour: 13, timestamps: buildTimestamps(base) }); + const res = await app.request("/models/wake-schedule/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hour: 13, action: "off", timestamps: buildTimestamps(base) }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { schedule: Record<string, unknown> }; + expect(body.schedule["13"]).toBeUndefined(); + }); }); |
