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:
@@ -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, /\.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");
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user