test(06-07): failing scheduled() tick suite GC-C1..GC-C9 (RED)

- 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
This commit is contained in:
araharah-executor
2026-10-24 17:11:59 +08:00
parent f07021e271
commit 620a982e9e
+320
View File
@@ -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");
});