mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-04-17 13:21:31 +00:00
feat: scaffold plug-n-play telegram bot on cloudflare workers
grammY-based bot with a module plugin system loaded from the MODULES env var. Three command visibility levels (public/protected/private) share a unified command namespace with conflict detection at registry build. - 4 initial modules (util, wordle, loldle, misc); util fully implemented, others are stubs proving the plugin system end-to-end - util: /info (chat/thread/sender ids) + /help (pure renderer over the registry, HTML parse mode, escapes user-influenced strings) - KVStore interface with CFKVStore and a per-module prefixing factory; getJSON/putJSON convenience helpers; other backends drop in via one file - Webhook at POST /webhook with secret-token validation via grammY's webhookCallback; no admin HTTP surface - Post-deploy register script (npm run deploy = wrangler deploy && node --env-file=.env.deploy scripts/register.js) for setWebhook and setMyCommands; --dry-run flag for preview - 56 vitest unit tests across 7 suites covering registry, db wrapper, dispatcher, help renderer, validators, and HTML escaper - biome for lint + format; phased implementation plan under plans/
This commit is contained in:
85
tests/db/cf-kv-store.test.js
Normal file
85
tests/db/cf-kv-store.test.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CFKVStore } from "../../src/db/cf-kv-store.js";
|
||||
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
|
||||
|
||||
describe("CFKVStore", () => {
|
||||
let kv;
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
kv = makeFakeKv();
|
||||
store = new CFKVStore(kv);
|
||||
});
|
||||
|
||||
it("throws when given no binding", () => {
|
||||
expect(() => new CFKVStore(null)).toThrow(/required/);
|
||||
});
|
||||
|
||||
it("round-trips get/put/delete", async () => {
|
||||
expect(await store.get("k")).toBeNull();
|
||||
await store.put("k", "v");
|
||||
expect(await store.get("k")).toBe("v");
|
||||
await store.delete("k");
|
||||
expect(await store.get("k")).toBeNull();
|
||||
});
|
||||
|
||||
it("list() returns normalized shape with module keys stripped of wrappers", async () => {
|
||||
await store.put("a:1", "x");
|
||||
await store.put("a:2", "y");
|
||||
await store.put("b:1", "z");
|
||||
const res = await store.list({ prefix: "a:" });
|
||||
expect(res.keys.sort()).toEqual(["a:1", "a:2"]);
|
||||
expect(res.done).toBe(true);
|
||||
});
|
||||
|
||||
it("list() pagination — cursor propagates through", async () => {
|
||||
for (let i = 0; i < 5; i++) await store.put(`k${i}`, String(i));
|
||||
const page1 = await store.list({ limit: 2 });
|
||||
expect(page1.keys.length).toBe(2);
|
||||
expect(page1.done).toBe(false);
|
||||
expect(page1.cursor).toBeTruthy();
|
||||
const page2 = await store.list({ limit: 2, cursor: page1.cursor });
|
||||
expect(page2.keys.length).toBe(2);
|
||||
expect(page2.done).toBe(false);
|
||||
const page3 = await store.list({ limit: 2, cursor: page2.cursor });
|
||||
expect(page3.keys.length).toBe(1);
|
||||
expect(page3.done).toBe(true);
|
||||
expect(page3.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("put() with expirationTtl passes through to binding", async () => {
|
||||
await store.put("k", "v", { expirationTtl: 60 });
|
||||
expect(kv.putLog).toEqual([{ key: "k", value: "v", opts: { expirationTtl: 60 } }]);
|
||||
});
|
||||
|
||||
it("put() without options does NOT pass an options object", async () => {
|
||||
await store.put("k", "v");
|
||||
expect(kv.putLog[0]).toEqual({ key: "k", value: "v", opts: undefined });
|
||||
});
|
||||
|
||||
it("getJSON/putJSON round-trip", async () => {
|
||||
await store.putJSON("k", { a: 1, b: [2, 3] });
|
||||
expect(await store.getJSON("k")).toEqual({ a: 1, b: [2, 3] });
|
||||
});
|
||||
|
||||
it("getJSON returns null on missing key", async () => {
|
||||
expect(await store.getJSON("missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("getJSON returns null on corrupt JSON and logs a warning", async () => {
|
||||
kv.store.set("bad", "{not json");
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(await store.getJSON("bad")).toBeNull();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("putJSON throws on undefined value", async () => {
|
||||
await expect(store.putJSON("k", undefined)).rejects.toThrow(/undefined/);
|
||||
});
|
||||
|
||||
it("putJSON passes expirationTtl through", async () => {
|
||||
await store.putJSON("k", { a: 1 }, { expirationTtl: 120 });
|
||||
expect(kv.putLog[0].opts).toEqual({ expirationTtl: 120 });
|
||||
});
|
||||
});
|
||||
77
tests/db/create-store.test.js
Normal file
77
tests/db/create-store.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createStore } from "../../src/db/create-store.js";
|
||||
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
|
||||
|
||||
describe("createStore (prefixing factory)", () => {
|
||||
let kv;
|
||||
let env;
|
||||
|
||||
beforeEach(() => {
|
||||
kv = makeFakeKv();
|
||||
env = { KV: kv };
|
||||
});
|
||||
|
||||
it("rejects invalid module names", () => {
|
||||
expect(() => createStore("", env)).toThrow();
|
||||
expect(() => createStore("BadName", env)).toThrow(/invalid/);
|
||||
expect(() => createStore("has:colon", env)).toThrow(/invalid/);
|
||||
expect(() => createStore("ok_name", env)).not.toThrow();
|
||||
expect(() => createStore("kebab-ok", env)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing env.KV", () => {
|
||||
expect(() => createStore("wordle", {})).toThrow(/KV/);
|
||||
});
|
||||
|
||||
it("put() writes raw key with module: prefix", async () => {
|
||||
const store = createStore("wordle", env);
|
||||
await store.put("games_played", "42");
|
||||
expect(kv.store.get("wordle:games_played")).toBe("42");
|
||||
});
|
||||
|
||||
it("get() reads through prefix", async () => {
|
||||
const store = createStore("wordle", env);
|
||||
await store.put("k", "v");
|
||||
expect(await store.get("k")).toBe("v");
|
||||
});
|
||||
|
||||
it("delete() deletes the prefixed raw key", async () => {
|
||||
const store = createStore("wordle", env);
|
||||
await store.put("k", "v");
|
||||
await store.delete("k");
|
||||
expect(kv.store.has("wordle:k")).toBe(false);
|
||||
});
|
||||
|
||||
it("list() combines module prefix + caller prefix and strips the module prefix on return", async () => {
|
||||
kv.store.set("wordle:games:1", "a");
|
||||
kv.store.set("wordle:games:2", "b");
|
||||
kv.store.set("wordle:stats", "c");
|
||||
kv.store.set("loldle:games:1", "other");
|
||||
|
||||
const store = createStore("wordle", env);
|
||||
const res = await store.list({ prefix: "games:" });
|
||||
expect(res.keys.sort()).toEqual(["games:1", "games:2"]);
|
||||
});
|
||||
|
||||
it("two stores are fully isolated — one cannot see the other's keys", async () => {
|
||||
const wordle = createStore("wordle", env);
|
||||
const loldle = createStore("loldle", env);
|
||||
await wordle.put("secret", "w");
|
||||
await loldle.put("secret", "l");
|
||||
|
||||
expect(await wordle.get("secret")).toBe("w");
|
||||
expect(await loldle.get("secret")).toBe("l");
|
||||
|
||||
const wList = await wordle.list();
|
||||
const lList = await loldle.list();
|
||||
expect(wList.keys).toEqual(["secret"]);
|
||||
expect(lList.keys).toEqual(["secret"]);
|
||||
});
|
||||
|
||||
it("putJSON/getJSON through a prefixed store persist at <module>:<key>", async () => {
|
||||
const store = createStore("misc", env);
|
||||
await store.putJSON("last_ping", { at: 123 });
|
||||
expect(kv.store.get("misc:last_ping")).toBe('{"at":123}');
|
||||
expect(await store.getJSON("last_ping")).toEqual({ at: 123 });
|
||||
});
|
||||
});
|
||||
24
tests/fakes/fake-bot.js
Normal file
24
tests/fakes/fake-bot.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @file fake-bot — records bot.command() calls for dispatcher tests.
|
||||
*
|
||||
* We never import real grammY in unit tests — everything the dispatcher
|
||||
* touches on the bot object is recorded here for assertions.
|
||||
*/
|
||||
|
||||
export function makeFakeBot() {
|
||||
/** @type {Array<{name: string, handler: Function}>} */
|
||||
const commandCalls = [];
|
||||
/** @type {Array<{event: string, handler: Function}>} */
|
||||
const onCalls = [];
|
||||
|
||||
return {
|
||||
commandCalls,
|
||||
onCalls,
|
||||
command(name, handler) {
|
||||
commandCalls.push({ name, handler });
|
||||
},
|
||||
on(event, handler) {
|
||||
onCalls.push({ event, handler });
|
||||
},
|
||||
};
|
||||
}
|
||||
46
tests/fakes/fake-kv-namespace.js
Normal file
46
tests/fakes/fake-kv-namespace.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file fake-kv-namespace — in-memory `Map`-backed KVNamespace for tests.
|
||||
*
|
||||
* Matches the real CF KVNamespace shape that `CFKVStore` depends on:
|
||||
* get(key, {type: 'text'}) → string | null
|
||||
* put(key, value, opts?) → stores, records opts for assertion
|
||||
* delete(key) → removes
|
||||
* list({prefix, limit, cursor}) → { keys: [{name}], list_complete, cursor }
|
||||
*
|
||||
* `listLimit` is applied BEFORE `list_complete` is computed so tests can
|
||||
* exercise pagination.
|
||||
*/
|
||||
|
||||
export function makeFakeKv() {
|
||||
/** @type {Map<string, string>} */
|
||||
const store = new Map();
|
||||
/** @type {Array<{key: string, value: string, opts?: any}>} */
|
||||
const putLog = [];
|
||||
|
||||
return {
|
||||
store,
|
||||
putLog,
|
||||
async get(key, _opts) {
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
},
|
||||
async put(key, value, opts) {
|
||||
store.set(key, value);
|
||||
putLog.push({ key, value, opts });
|
||||
},
|
||||
async delete(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
async list({ prefix = "", limit = 1000, cursor } = {}) {
|
||||
const allKeys = [...store.keys()].filter((k) => k.startsWith(prefix)).sort();
|
||||
const start = cursor ? Number.parseInt(cursor, 10) : 0;
|
||||
const slice = allKeys.slice(start, start + limit);
|
||||
const nextStart = start + slice.length;
|
||||
const complete = nextStart >= allKeys.length;
|
||||
return {
|
||||
keys: slice.map((name) => ({ name })),
|
||||
list_complete: complete,
|
||||
cursor: complete ? undefined : String(nextStart),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
29
tests/fakes/fake-modules.js
Normal file
29
tests/fakes/fake-modules.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file fake-modules — fixture modules + a helper that builds an import-map
|
||||
* shape compatible with `loadModules(env, importMap)`.
|
||||
*
|
||||
* Using dependency injection (instead of `vi.mock`) sidesteps path-resolution
|
||||
* flakiness on Windows and keeps tests fully deterministic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Record<string, import("../../src/modules/registry.js").BotModule>} modules
|
||||
*/
|
||||
export function makeFakeImportMap(modules) {
|
||||
/** @type {Record<string, () => Promise<{default: any}>>} */
|
||||
const map = {};
|
||||
for (const [name, mod] of Object.entries(modules)) {
|
||||
map[name] = async () => ({ default: mod });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export const noopHandler = async () => {};
|
||||
|
||||
export function makeCommand(name, visibility, description = "fixture command") {
|
||||
return { name, visibility, description, handler: noopHandler };
|
||||
}
|
||||
|
||||
export function makeModule(name, commands, init) {
|
||||
return init ? { name, commands, init } : { name, commands };
|
||||
}
|
||||
56
tests/modules/dispatcher.test.js
Normal file
56
tests/modules/dispatcher.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { installDispatcher } from "../../src/modules/dispatcher.js";
|
||||
import { resetRegistry } from "../../src/modules/registry.js";
|
||||
import { makeFakeBot } from "../fakes/fake-bot.js";
|
||||
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
|
||||
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
|
||||
|
||||
// The dispatcher imports the registry's default static map at module-load
|
||||
// time, so to keep the test hermetic we need to stub env.MODULES with names
|
||||
// that resolve in the REAL map — OR we call buildRegistry directly with an
|
||||
// injected map and then simulate what installDispatcher does. Since phase-04
|
||||
// made loadModules accept an importMap injection but installDispatcher
|
||||
// internally calls buildRegistry WITHOUT an injection, we test the same
|
||||
// effect by wiring the bot manually against a registry built with a fake map.
|
||||
//
|
||||
// Keeping it simple: call installDispatcher against the real module map
|
||||
// (util + wordle + loldle + misc) so we exercise the actual production path.
|
||||
|
||||
describe("installDispatcher", () => {
|
||||
beforeEach(() => resetRegistry());
|
||||
|
||||
it("registers EVERY command via bot.command() regardless of visibility", async () => {
|
||||
const bot = makeFakeBot();
|
||||
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
|
||||
const reg = await installDispatcher(bot, env);
|
||||
|
||||
// Expect 11 total commands (5 public + 3 protected + 3 private).
|
||||
expect(bot.commandCalls).toHaveLength(11);
|
||||
expect(reg.allCommands.size).toBe(11);
|
||||
|
||||
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
|
||||
const expected = [...reg.allCommands.keys()].sort();
|
||||
expect(registeredNames).toEqual(expected);
|
||||
|
||||
// Assert private commands ARE registered (the whole point of unified routing).
|
||||
expect(registeredNames).toContain("konami");
|
||||
expect(registeredNames).toContain("ggwp");
|
||||
expect(registeredNames).toContain("fortytwo");
|
||||
});
|
||||
|
||||
it("does NOT install any bot.on() middleware — dispatcher is minimal", async () => {
|
||||
const bot = makeFakeBot();
|
||||
const env = { MODULES: "util", KV: makeFakeKv() };
|
||||
await installDispatcher(bot, env);
|
||||
expect(bot.onCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("each registered handler matches the source module command handler", async () => {
|
||||
const bot = makeFakeBot();
|
||||
const env = { MODULES: "util", KV: makeFakeKv() };
|
||||
const reg = await installDispatcher(bot, env);
|
||||
for (const { name, handler } of bot.commandCalls) {
|
||||
expect(handler).toBe(reg.allCommands.get(name).cmd.handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
154
tests/modules/registry.test.js
Normal file
154
tests/modules/registry.test.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildRegistry,
|
||||
getCurrentRegistry,
|
||||
loadModules,
|
||||
resetRegistry,
|
||||
} from "../../src/modules/registry.js";
|
||||
import { makeFakeKv } from "../fakes/fake-kv-namespace.js";
|
||||
import { makeCommand, makeFakeImportMap, makeModule } from "../fakes/fake-modules.js";
|
||||
|
||||
const makeEnv = (modules) => ({ MODULES: modules, KV: makeFakeKv() });
|
||||
|
||||
describe("registry", () => {
|
||||
beforeEach(() => resetRegistry());
|
||||
|
||||
describe("loadModules", () => {
|
||||
it("loads modules listed in env.MODULES", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
b: makeModule("b", [makeCommand("bar", "public")]),
|
||||
});
|
||||
const mods = await loadModules({ MODULES: "a,b" }, map);
|
||||
expect(mods.map((m) => m.name)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("trims, dedupes, skips empty", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
});
|
||||
const mods = await loadModules({ MODULES: " a , a , " }, map);
|
||||
expect(mods).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws on empty MODULES", async () => {
|
||||
await expect(loadModules({ MODULES: "" }, {})).rejects.toThrow(/empty/);
|
||||
await expect(loadModules({}, {})).rejects.toThrow(/empty/);
|
||||
});
|
||||
|
||||
it("throws on unknown module name", async () => {
|
||||
await expect(loadModules({ MODULES: "ghost" }, {})).rejects.toThrow(/unknown module: ghost/);
|
||||
});
|
||||
|
||||
it("throws when default export name mismatches key", async () => {
|
||||
const map = makeFakeImportMap({ a: makeModule("b", []) });
|
||||
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/mismatched name/);
|
||||
});
|
||||
|
||||
it("throws when module has no commands array", async () => {
|
||||
const map = {
|
||||
a: async () => ({ default: { name: "a" } }),
|
||||
};
|
||||
await expect(loadModules({ MODULES: "a" }, map)).rejects.toThrow(/commands array/);
|
||||
});
|
||||
|
||||
it("runs validateCommand on each command", async () => {
|
||||
const badMap = makeFakeImportMap({
|
||||
a: makeModule("a", [
|
||||
{ name: "/bad", visibility: "public", description: "x", handler: async () => {} },
|
||||
]),
|
||||
});
|
||||
await expect(loadModules({ MODULES: "a" }, badMap)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRegistry", () => {
|
||||
it("partitions commands across visibility maps + flat allCommands", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [
|
||||
makeCommand("pub", "public"),
|
||||
makeCommand("prot", "protected"),
|
||||
makeCommand("priv", "private"),
|
||||
]),
|
||||
});
|
||||
const reg = await buildRegistry(makeEnv("a"), map);
|
||||
expect([...reg.publicCommands.keys()]).toEqual(["pub"]);
|
||||
expect([...reg.protectedCommands.keys()]).toEqual(["prot"]);
|
||||
expect([...reg.privateCommands.keys()]).toEqual(["priv"]);
|
||||
expect([...reg.allCommands.keys()].sort()).toEqual(["priv", "prot", "pub"]);
|
||||
});
|
||||
|
||||
it("throws on same-visibility conflict", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
b: makeModule("b", [makeCommand("foo", "public")]),
|
||||
});
|
||||
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo.*a.*b/);
|
||||
});
|
||||
|
||||
it("throws on CROSS-visibility conflict (unified namespace)", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
b: makeModule("b", [makeCommand("foo", "private")]),
|
||||
});
|
||||
await expect(buildRegistry(makeEnv("a,b"), map)).rejects.toThrow(/conflict.*foo/);
|
||||
});
|
||||
|
||||
it("calls init({db, env}) exactly once per module, in order", async () => {
|
||||
const calls = [];
|
||||
const makeInit =
|
||||
(name) =>
|
||||
async ({ db, env }) => {
|
||||
calls.push({ name, hasDb: !!db, hasEnv: !!env });
|
||||
await db.put("sentinel", "x");
|
||||
};
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("one", "public")], makeInit("a")),
|
||||
b: makeModule("b", [makeCommand("two", "public")], makeInit("b")),
|
||||
});
|
||||
const env = makeEnv("a,b");
|
||||
await buildRegistry(env, map);
|
||||
expect(calls).toEqual([
|
||||
{ name: "a", hasDb: true, hasEnv: true },
|
||||
{ name: "b", hasDb: true, hasEnv: true },
|
||||
]);
|
||||
// Assert prefixing: keys landed as "a:sentinel" and "b:sentinel".
|
||||
expect(env.KV.store.get("a:sentinel")).toBe("x");
|
||||
expect(env.KV.store.get("b:sentinel")).toBe("x");
|
||||
});
|
||||
|
||||
it("wraps init errors with module name", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")], async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
});
|
||||
await expect(buildRegistry(makeEnv("a"), map)).rejects.toThrow(
|
||||
/module "a" init failed.*boom/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentRegistry / resetRegistry", () => {
|
||||
it("getCurrentRegistry throws before build", () => {
|
||||
expect(() => getCurrentRegistry()).toThrow(/not built/);
|
||||
});
|
||||
|
||||
it("getCurrentRegistry returns the same instance after build", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
});
|
||||
const reg = await buildRegistry(makeEnv("a"), map);
|
||||
expect(getCurrentRegistry()).toBe(reg);
|
||||
});
|
||||
|
||||
it("resetRegistry clears the memoized registry", async () => {
|
||||
const map = makeFakeImportMap({
|
||||
a: makeModule("a", [makeCommand("foo", "public")]),
|
||||
});
|
||||
await buildRegistry(makeEnv("a"), map);
|
||||
resetRegistry();
|
||||
expect(() => getCurrentRegistry()).toThrow(/not built/);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
tests/modules/util/help-command.test.js
Normal file
104
tests/modules/util/help-command.test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHelp } from "../../../src/modules/util/help-command.js";
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
/**
|
||||
* Build a synthetic registry directly — no loader involved. Lets us test
|
||||
* the pure renderer with exact inputs.
|
||||
*/
|
||||
function makeRegistry(modules) {
|
||||
const publicCommands = new Map();
|
||||
const protectedCommands = new Map();
|
||||
const privateCommands = new Map();
|
||||
const allCommands = new Map();
|
||||
|
||||
for (const mod of modules) {
|
||||
for (const cmd of mod.commands) {
|
||||
const entry = { module: mod, cmd, visibility: cmd.visibility };
|
||||
allCommands.set(cmd.name, entry);
|
||||
if (cmd.visibility === "public") publicCommands.set(cmd.name, entry);
|
||||
else if (cmd.visibility === "protected") protectedCommands.set(cmd.name, entry);
|
||||
else privateCommands.set(cmd.name, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
publicCommands,
|
||||
protectedCommands,
|
||||
privateCommands,
|
||||
allCommands,
|
||||
modules,
|
||||
};
|
||||
}
|
||||
|
||||
const cmd = (name, visibility, description) => ({
|
||||
name,
|
||||
visibility,
|
||||
description,
|
||||
handler: noop,
|
||||
});
|
||||
|
||||
describe("renderHelp", () => {
|
||||
it("groups commands by module in env.MODULES order", () => {
|
||||
const reg = makeRegistry([
|
||||
{ name: "a", commands: [cmd("one", "public", "A-one")] },
|
||||
{ name: "b", commands: [cmd("two", "public", "B-two")] },
|
||||
]);
|
||||
const out = renderHelp(reg);
|
||||
const aIdx = out.indexOf("<b>a</b>");
|
||||
const bIdx = out.indexOf("<b>b</b>");
|
||||
expect(aIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(bIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(aIdx).toBeLessThan(bIdx);
|
||||
});
|
||||
|
||||
it("appends (protected) suffix to protected commands", () => {
|
||||
const reg = makeRegistry([{ name: "a", commands: [cmd("admin", "protected", "Admin tool")] }]);
|
||||
const out = renderHelp(reg);
|
||||
expect(out).toContain("/admin — Admin tool (protected)");
|
||||
});
|
||||
|
||||
it("hides modules whose only commands are private", () => {
|
||||
const reg = makeRegistry([
|
||||
{ name: "a", commands: [cmd("visible", "public", "V")] },
|
||||
{ name: "b", commands: [cmd("hidden", "private", "H")] },
|
||||
]);
|
||||
const out = renderHelp(reg);
|
||||
expect(out).toContain("<b>a</b>");
|
||||
expect(out).not.toContain("<b>b</b>");
|
||||
expect(out).not.toContain("hidden");
|
||||
});
|
||||
|
||||
it("NEVER leaks private command names into output", () => {
|
||||
const reg = makeRegistry([
|
||||
{
|
||||
name: "a",
|
||||
commands: [cmd("show", "public", "Public"), cmd("secret", "private", "Secret")],
|
||||
},
|
||||
]);
|
||||
const out = renderHelp(reg);
|
||||
expect(out).toContain("/show");
|
||||
expect(out).not.toContain("/secret");
|
||||
expect(out).not.toContain("Secret");
|
||||
});
|
||||
|
||||
it("HTML-escapes module name and description", () => {
|
||||
const reg = makeRegistry([
|
||||
{
|
||||
name: "a&b",
|
||||
commands: [cmd("foo", "public", "runs <script>")],
|
||||
},
|
||||
]);
|
||||
const out = renderHelp(reg);
|
||||
expect(out).toContain("<b>a&b</b>");
|
||||
expect(out).toContain("runs <script>");
|
||||
// The literal unescaped sequence must NOT appear in output.
|
||||
expect(out).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("returns a placeholder when no commands are visible", () => {
|
||||
const reg = makeRegistry([{ name: "a", commands: [cmd("hidden", "private", "H")] }]);
|
||||
expect(renderHelp(reg)).toBe("no commands registered");
|
||||
});
|
||||
});
|
||||
59
tests/modules/validate-command.test.js
Normal file
59
tests/modules/validate-command.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateCommand } from "../../src/modules/validate-command.js";
|
||||
|
||||
const noop = async () => {};
|
||||
const base = (overrides = {}) => ({
|
||||
name: "foo",
|
||||
visibility: "public",
|
||||
description: "does foo",
|
||||
handler: noop,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("validateCommand", () => {
|
||||
it("accepts valid public/protected/private commands", () => {
|
||||
expect(() => validateCommand(base(), "m")).not.toThrow();
|
||||
expect(() => validateCommand(base({ visibility: "protected" }), "m")).not.toThrow();
|
||||
expect(() => validateCommand(base({ visibility: "private" }), "m")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects invalid visibility", () => {
|
||||
expect(() => validateCommand(base({ visibility: "secret" }), "m")).toThrow(/visibility/);
|
||||
});
|
||||
|
||||
it("rejects leading slash in name", () => {
|
||||
expect(() => validateCommand(base({ name: "/foo" }), "m")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects uppercase in name", () => {
|
||||
expect(() => validateCommand(base({ name: "Foo" }), "m")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects name > 32 chars", () => {
|
||||
expect(() => validateCommand(base({ name: "a".repeat(33) }), "m")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects missing or empty description for all visibilities", () => {
|
||||
expect(() => validateCommand(base({ description: "" }), "m")).toThrow(/description/);
|
||||
expect(() => validateCommand(base({ visibility: "private", description: "" }), "m")).toThrow(
|
||||
/description/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects description > 256 chars", () => {
|
||||
expect(() => validateCommand(base({ description: "x".repeat(257) }), "m")).toThrow(/256/);
|
||||
});
|
||||
|
||||
it("rejects non-function handler", () => {
|
||||
expect(() => validateCommand(base({ handler: null }), "m")).toThrow(/handler/);
|
||||
});
|
||||
|
||||
it("error messages include module + command name", () => {
|
||||
try {
|
||||
validateCommand(base({ name: "Bad" }), "wordle");
|
||||
} catch (err) {
|
||||
expect(err.message).toContain("wordle");
|
||||
expect(err.message).toContain("Bad");
|
||||
}
|
||||
});
|
||||
});
|
||||
22
tests/util/escape-html.test.js
Normal file
22
tests/util/escape-html.test.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeHtml } from "../../src/util/escape-html.js";
|
||||
|
||||
describe("escapeHtml", () => {
|
||||
it('escapes &, <, >, "', () => {
|
||||
expect(escapeHtml('&<>"')).toBe("&<>"");
|
||||
});
|
||||
|
||||
it("leaves safe characters alone", () => {
|
||||
expect(escapeHtml("hello world 123 /cmd — émoji 🎉")).toBe("hello world 123 /cmd — émoji 🎉");
|
||||
});
|
||||
|
||||
it("escapes in order so ampersand doesn't double-escape later entities", () => {
|
||||
// Input has literal & and <, output should have & and < — NOT &lt;
|
||||
expect(escapeHtml("a & <b>")).toBe("a & <b>");
|
||||
});
|
||||
|
||||
it("coerces non-strings", () => {
|
||||
expect(escapeHtml(42)).toBe("42");
|
||||
expect(escapeHtml(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user