test(hub): Vitest suite + 三组 unit 测试(22 tests)

用 Vitest 组织测试,对齐 Courseware 半边的测试纪律(非散脚本):
- test/unit/workspace.test.ts: 路径 confinement(读写回环/..逃逸/绝对路径逃逸/
  父目录创建/列举/不存在 error JSON)—— 11 tests
- test/unit/prompt.test.ts: @bot 提取(单 mention/多 mention/纯@bot/无 mention/
  非 JSON/无 text 字段)—— 8 tests
- test/unit/permission.test.ts: roleGrantsEdit 单调性(READ 不含 / EDIT 含 /
  MANAGE 含,ADR-0004 can_mono)—— 3 tests
extractPrompt 导出供测试。修了 test helper 默认参数 bug(显式传 undefined 触发
默认值,绕过 mentions 检查)。
vitest run 22/22 通过;tsc rc=0。
Integration 层(trigger 全链路 + lock WellFormed + 真 cph)待本地 postgres
后再起。
This commit is contained in:
2026-07-07 15:26:53 +08:00
parent ce767e9cab
commit 09cadd0148
7 changed files with 1428 additions and 4 deletions
+1242 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -17,7 +17,8 @@
"devDependencies": {
"prisma": "^6.19.3",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^4.1.10"
},
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": {
@@ -28,6 +29,8 @@
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"deploy": "bash deploy/deploy_platform.sh"
"deploy": "bash deploy/deploy_platform.sh",
"test": "vitest run",
"test:watch": "vitest"
}
}
+1 -1
View File
@@ -213,7 +213,7 @@ function statusReply(status: "completed" | "length" | "failed"): string {
* Returns null if the message is empty after stripping or doesn't mention the bot.
* Lark text content is JSON: `{"text":"@_user_1 do something"}`.
*/
function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
export function extractPrompt(msg: MessageReceiveEvent["message"]): string | null {
// Check for bot mention — mentions[].id.open_id should include the bot.
// The dispatcher already routes only message events; we further require
// that the bot is among the mentions (if no mentions, it's not @bot).
+16
View File
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { roleGrantsEdit } from "../../src/permission.js";
describe("roleGrantsEdit (ADR-0004 monotonicity)", () => {
it("READ does not grant edit", () => {
expect(roleGrantsEdit("READ")).toBe(false);
});
it("EDIT grants edit", () => {
expect(roleGrantsEdit("EDIT")).toBe(true);
});
it("MANAGE grants edit (manage ⊇ edit, spec can_mono)", () => {
expect(roleGrantsEdit("MANAGE")).toBe(true);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { extractPrompt } from "../../src/feishu/trigger.js";
import type { MessageReceiveEvent } from "../../src/feishu/client.js";
const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" };
function msg(text: string, mentions: typeof bot[] | undefined = [bot]): MessageReceiveEvent["message"] {
return {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text }),
mentions,
};
}
describe("extractPrompt (@bot mention parsing)", () => {
it("extracts the prompt after @bot", () => {
expect(extractPrompt(msg("@_user_1 帮我写教案"))).toBe("帮我写教案");
});
it("strips multiple mentions", () => {
const other = { key: "@_user_2", id: { open_id: "ou_other" }, name: "Other" };
expect(extractPrompt(msg("@_user_1 @_user_2 检查例题2", [bot, other]))).toBe("检查例题2");
});
it("returns null when only @bot with no prompt", () => {
expect(extractPrompt(msg("@_user_1"))).toBeNull();
});
it("returns null when @bot followed by whitespace only", () => {
expect(extractPrompt(msg("@_user_1 "))).toBeNull();
});
it("returns null when no mentions", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m", chat_id: "c", chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
mentions: undefined,
};
expect(extractPrompt(m)).toBeNull();
});
it("returns null for non-text message_type (content still parseable)", () => {
const m = msg("{}", [bot]);
// message_type check is the caller's job; extractPrompt only parses content.
// But with empty text it returns null.
expect(extractPrompt({ ...m, content: JSON.stringify({ text: "" }) })).toBeNull();
});
it("returns null when content is not valid JSON", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: "not json",
mentions: [bot],
};
expect(extractPrompt(m)).toBeNull();
});
it("returns null when JSON has no text field", () => {
const m: MessageReceiveEvent["message"] = {
message_id: "m",
chat_id: "c",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ foo: "bar" }),
mentions: [bot],
};
expect(extractPrompt(m)).toBeNull();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ToolRegistry } from "../../src/agent/tools.js";
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
describe("workspace path confinement", () => {
let ws: string;
let tools: ToolRegistry;
beforeEach(async () => {
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
tools = new ToolRegistry();
tools.register(readFileTool());
tools.register(writeFileTool());
tools.register(listFilesTool());
await mkdir(join(ws, "sub"));
await writeFile(join(ws, "a.txt"), "hello");
await writeFile(join(ws, "sub", "b.txt"), "world");
});
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
it("reads a file inside the workspace", async () => {
expect(await tools.execute("read_file", { path: "a.txt" }, ctx())).toBe("hello");
});
it("reads nested files", async () => {
expect(await tools.execute("read_file", { path: "sub/b.txt" }, ctx())).toBe("world");
});
it("rejects .. escapes as error JSON, not a throw", async () => {
const out = await tools.execute("read_file", { path: "../../etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("rejects absolute paths outside the workspace", async () => {
const out = await tools.execute("read_file", { path: "/etc/passwd" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
it("writes a file and reads it back", async () => {
await tools.execute("write_file", { path: "sub/c.txt", content: "new" }, ctx());
expect(await tools.execute("read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
});
it("creates parent dirs on write", async () => {
await tools.execute("write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
expect(await tools.execute("read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
});
it("lists the workspace root", async () => {
const out = await tools.execute("list_files", {}, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
});
it("lists a subdirectory", async () => {
const out = await tools.execute("list_files", { path: "sub" }, ctx());
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
});
it("PathEscape is an Error subclass", () => {
expect(new PathEscape("../x", "/ws")).toBeInstanceOf(Error);
expect(new PathEscape("../x", "/ws").name).toBe("PathEscape");
});
it("read of nonexistent file returns error JSON", async () => {
const out = await tools.execute("read_file", { path: "nope.txt" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
});
it("write with .. escape returns error JSON", async () => {
const out = await tools.execute("write_file", { path: "../escape.txt", content: "x" }, ctx());
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
});
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
// Unit tests run without a DB; integration tests set their own env.
env: { NODE_ENV: "test" },
},
});