From 620a982e9edca36b7875ecd48b4a7ccd34b63a89 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 17:11:59 +0800 Subject: [PATCH] test(06-07): failing scheduled() tick suite GC-C1..GC-C9 (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - due-card send captured through createBot(env, {baseFetch}): envelope chat_id only, glyph URL, HTML parse mode, ?g= deep link - R2 mini-graph fake → fixture street; R2-null fake → bare-verb card, send still fires; non-due → 0 sends; fail-closed unconfigured; pagination; throwing read skipped; failed send continues --- bot/test/guidance-cron.test.mjs | 320 ++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs index 7041751..60c157e 100644 --- a/bot/test/guidance-cron.test.mjs +++ b/bot/test/guidance-cron.test.mjs @@ -325,3 +325,323 @@ test("GC8 structural: no console in the pure module; classifyTurn + snapToGraph assert.doesNotMatch(code, /\.put\(/, "the cron path performs ZERO KV writes (the stateless cadence law, Pitfall 3)"); assert.doesNotMatch(code, /destinationName/, "the envelope's destinationName never reaches the caption composer"); }); + +/* ———————————————————————————————————————————————————————————————————————————— + * scheduled() — the wired tick (task 2; GC-C1..GC-C7). + * + * The ExportedHandler's scheduled arm, invoked DIRECTLY offline: controller = + * { cron, scheduledTime } fake, env = Map-fake KV (list-paginated) + R2 fake + + * BOT_TOKEN + MINI_APP_URL, ctx = waitUntil-recording fake, grammY sends + * captured through createBot(env, { baseFetch }). The fake KV's put THROWS — + * any KV write inside the tick fails the suite behaviorally, on top of the + * structural scan (GC-C6). + * ———————————————————————————————————————————————————————————————————————————— */ + +const TEST_TOKEN = "12345:TESTTOKEN"; // the initdata/webhook test signer token +const ORIGIN = "https://araharah-bot.araharah-bot.workers.dev"; // the wrangler vars value + +/** A waitUntil-recording ExecutionContext fake. */ +function makeCtx() { + const pending = []; + return { + ctx: { waitUntil: (p) => pending.push(Promise.resolve(p).catch(() => {})) }, + drain: () => Promise.all(pending), + }; +} + +/** Console-capture helper (the withConsole precedent). */ +async function withConsole(fn) { + const logs = []; + const orig = console.log; + console.log = (...args) => logs.push(args.join(" ")); + try { + return { value: await fn(), logs }; + } finally { + console.log = orig; + } +} + +/** + * A list-capable Map-fake KV: pages are consumed in order (list_complete + + * cursor respected upstream); put THROWS — the cron tick must never write. + */ +function makeListKV(map, pages) { + let pageIdx = 0; + let listCalls = 0; + return { + async get(key) { + if (this.throwOnGet !== undefined && key === this.throwOnGet) throw new Error("KV read failed"); + return map.has(key) ? map.get(key) : null; + }, + async put() { + throw new Error("the cron tick performed a KV write — the stateless law forbids it"); + }, + async delete() {}, + async list() { + listCalls += 1; + return pageIdx < pages.length ? pages[pageIdx++] : { keys: [], list_complete: true }; + }, + get listCalls() { + return listCalls; + }, + map, + }; +} + +/** An R2 fake over one fixed binary or absent (the webhook.test.mjs shape). */ +function r2With(maybeBuf) { + let gets = 0; + return { + gets: () => gets, + async get() { + gets += 1; + if (maybeBuf === null) return null; + const buf = maybeBuf; + return { arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) }; + }, + }; +} + +function makeEnv(kv, r2, overrides = {}) { + return { + BOT_TOKEN: TEST_TOKEN, + WEBHOOK_SECRET: "wk-test-secret", + chat_sessions: kv, + ROAD_GRAPH: r2, + MINI_APP_URL: ORIGIN, + ...overrides, + }; +} + +function controllerFor(nowSec) { + return { cron: "*/1 * * * *", scheduledTime: nowSec * 1000 }; +} + +async function sentPhotos(calls) { + const out = []; + for (const call of calls) { + if (!call.url.includes("/sendPhoto")) continue; + const body = call.init?.body; + let params; + if (typeof body === "string") params = JSON.parse(body); + else if (body && typeof body.entries === "function") { + params = {}; + for (const [k, v] of body.entries()) params[k] = v; + } + if (typeof params?.reply_markup === "string") params.reply_markup = JSON.parse(params.reply_markup); + out.push(params); + } + return out; +} + +const TICK_SEC = T0 + 180; // % 180 === 0 → a due tick + +test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-bearing from the mini-graph R2 fake", async () => { + const mod = await import("../src/webhook.ts"); + assert.equal(typeof mod.createExportedHandler, "function", "the handler is built by an injectable factory (offline send capture)"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 202 }); + const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 303 }); + const map = new Map(); + map.set(`guidance:${envA.id}`, JSON.stringify(envA)); + map.set(`guidance:${envB.id}`, JSON.stringify(envB)); // dead trip — never carded + map.set(`guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000c3`, "not json"); // malformed + const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envB.id}` }, { name: "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000c3" }], list_complete: true }]); + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: { message_id: 1 } }) }; + }; + const { ctx, drain } = makeCtx(); + const worker = mod.createExportedHandler({ baseFetch }); + const { logs } = await withConsole(() => + worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx), + ); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(photos.length, 1, "exactly the due, alive envelope sends"); + assert.equal(photos[0].chat_id, 202, "chat_id comes ONLY from the envelope (T-06-02)"); + assert.equal(photos[0].photo, `${ORIGIN}/glyphs/left.png`, "the committed glyph PNG rides its public static URL"); + assert.equal(photos[0].parse_mode, "HTML"); + assert.ok(photos[0].caption.includes("TURN LEFT ONTO JALAN MINI 1"), "street-bearing card through the shared graph memo"); + assert.ok(photos[0].caption.includes(strings.en.cardHonesty)); + assert.equal(photos[0].reply_markup.inline_keyboard[0][0].text, "OPEN GUIDANCE"); + assert.equal( + photos[0].reply_markup.inline_keyboard[0][0].web_app.url, + `${ORIGIN}/?g=${envA.id}`, + "the OPEN deep link carries the envelope id (?g=, GUID-06 bridge)", + ); + assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: malformed")), "malformed is a category-only skip"); + const categoryLogs = logs.filter((l) => l.startsWith("[")); + for (const line of categoryLogs) { + assert.match(line, /^\[guidance-cron\] (skipped|send failed)/, `every log line is a guidance-cron category: ${line}`); + } +}); + +test("GC-C2 non-due tick: zero sends (the scan still runs; the cadence gates the card)", async () => { + const mod = await import("../src/webhook.ts"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 }); + const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]); + const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]); + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + const worker = mod.createExportedHandler({ baseFetch }); + await withConsole(() => worker.scheduled(controllerFor(TICK_SEC + 60), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(photos.length, 0, "non-due → no send"); +}); + +test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradation, not silence)", async () => { + const { default: worker } = await import("../src/webhook.ts"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 }); + const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]); + const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]); + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx)); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(photos.length, 1, "the send fires even without a graph"); + assert.ok(photos[0].caption.includes("— TURN LEFT"), "bare verb (graph absent)"); + assert.doesNotMatch(photos[0].caption, /ONTO|JALAN MINI 1/, "no street text is guessed"); +}); + +test("GC-C4 fail-closed: missing BOT_TOKEN → skipped: unconfigured, zero sends, zero KV touches", async () => { + const { default: worker } = await import("../src/webhook.ts"); + const map = new Map(); + const kv = makeListKV(map, []); + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + const { logs } = await withConsole(() => + worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null), { BOT_TOKEN: "" }), ctx), + ); + await drain(); + assert.equal(kv.listCalls, 0, "the tick never touches an unconfigured namespace"); + assert.equal((await sentPhotos(rec.calls)).length, 0, "no sends"); + assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: unconfigured")), "the webhook fail-closed posture"); +}); + +test("GC-C5 pagination: list_complete respected — envelopes across two pages all processed", async () => { + const { default: worker } = await import("../src/webhook.ts"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 204 }); + const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 205 }); + const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 206 }); + const map = new Map(); + map.set(`guidance:${envA.id}`, JSON.stringify(envA)); + map.set(`guidance:${envA2.id}`, JSON.stringify(envA2)); + map.set(`guidance:${envB.id}`, JSON.stringify(envB)); + const kv = makeListKV(map, [ + { keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envA2.id}` }], list_complete: false, cursor: "page-2" }, + { keys: [{ name: `guidance:${envB.id}` }], list_complete: true }, + ]); + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(kv.listCalls, 2, "the pagination loop fetched both pages"); + assert.equal(photos.length, 2, "both alive envelopes carded across pages"); + assert.deepEqual(photos.map((p) => p.chat_id).sort(), [204, 205]); +}); + +test("GC-C6 KV-budget pin (structural): ZERO .put( in cron.ts AND webhook.ts (PATTERNS PG style)", () => { + for (const file of [CRON_SRC, path.join(here, "..", "src", "webhook.ts")]) { + const code = stripComments(readFileSync(file, "utf8")); + assert.doesNotMatch(code, /\.put\(/, `${file} — a KV write in the cron path (Pitfall 3: the 1,000-writes/day budget)`); + } +}); + +test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROAD_GRAPH reader in bot/src", () => { + const webhookCode = stripComments(readFileSync(path.join(here, "..", "src", "webhook.ts"), "utf8")); + assert.ok(webhookCode.includes("loadGraph"), "scheduled() awaits the worker-router memo"); + assert.ok(webhookCode.includes("makeStreetAt"), "the graph-fed street reconstruction is wired"); + assert.ok(webhookCode.includes("projectTurnCards"), "the projection is the decision authority"); + const readdir = (dir) => require("node:fs").readdirSync(dir, { withFileTypes: true }); + const walk = (dir, acc = []) => { + for (const e of readdir(dir)) { + const full = dir + "/" + e.name; + if (e.isDirectory()) walk(full, acc); + else if (e.name.endsWith(".ts")) acc.push(full); + } + return acc; + }; + const files = walk(here + "/../src"); + assert.ok(files.length >= 8, `scanned a real tree (found ${files.length} files)`); + for (const file of files) { + const code = stripComments(readFileSync(file, "utf8")); + if (!code.includes("ROAD_GRAPH")) continue; + assert.ok( + file.endsWith("config.ts") || file.endsWith("worker-router.ts"), + `${file} mentions ROAD_GRAPH — only the Env contract (config.ts) and the one memo loader (worker-router.ts) may`, + ); + } +}); + +test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the rest still card", async () => { + const { default: worker } = await import("../src/webhook.ts"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 207 }); + const BAD_KEY = "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000b8"; + const map = new Map(); + map.set(`guidance:${envA.id}`, JSON.stringify(envA)); + const kv = makeListKV(map, [{ keys: [{ name: BAD_KEY }, { name: `guidance:${envA.id}` }], list_complete: true }]); + kv.throwOnGet = BAD_KEY; + const rec = { calls: [] }; + const baseFetch = async (url, init) => { + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(photos.length, 1, "the tick survived the throwing read"); + assert.equal(photos[0].chat_id, 207); + assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: malformed")), "a failed read is a category-only skip"); +}); + +test("GC-C9 a failed send logs and continues — delivery variance is documented best-effort (D-13)", async () => { + const { default: worker } = await import("../src/webhook.ts"); + const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 208 }); + const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 209 }); + const map = new Map(); + map.set(`guidance:${envA.id}`, JSON.stringify(envA)); + map.set(`guidance:${envA2.id}`, JSON.stringify(envA2)); + const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envA2.id}` }], list_complete: true }]); + const rec = { calls: [] }; + let sent = 0; + const baseFetch = async (url, init) => { + if (String(url).includes("/sendPhoto")) { + sent += 1; + if (sent === 1) { + // A 4xx Telegram rejection — grammY throws without retrying. + return { ok: true, json: async () => ({ ok: false, error_code: 400, description: "Bad Request: chat not found" }) }; + } + } + rec.calls.push({ url: String(url), init }); + return { ok: true, json: async () => ({ ok: true, result: {} }) }; + }; + const { ctx, drain } = makeCtx(); + const { logs } = await withConsole(() => worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx)); + await drain(); + const photos = await sentPhotos(rec.calls); + assert.equal(photos.length, 1, "the failed first send did not stop the second"); + assert.equal(photos[0].chat_id, 209); + assert.ok(logs.some((l) => l.includes("[guidance-cron] send failed")), "the send failure is a category-only log"); +}); +