/** * workspace 首页、board 创建/重命名与 URL 的请求契约单元测试。request 替身记录调用次序和 payload,不运行 HTTP 或数据库。 * 首次创建 workspace 与 board 不是一个服务端事务:workspace 成功后需先通知调用者保留身份,即使后续 board 失败。 */ import { test } from "node:test"; import assert from "node:assert/strict"; import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome } from "../src/lib/workspace.ts"; // 把两个请求都挂起后检查启动记录,证明 profile/workspaces 无串行瀑布;分别释放再检查结果整合和头像。 test("profile and workspace reads both start before either completes", async () => { const calls = []; const resolve = {}; const pending = loadWorkspaceHome((path) => { calls.push(path); return new Promise((done) => { resolve[path] = done; }); }); assert.deepEqual(calls, ["profile", "workspaces"]); resolve.profile({ id: "user", avatar_url: "https://lh3.googleusercontent.com/test-avatar" }); resolve.workspaces({ items: [{ id: "space", name: "个人空间" }] }); const result = await pending; assert.equal(result.profile.id, "user"); assert.equal(result.profile.avatar_url, "https://lh3.googleusercontent.com/test-avatar"); assert.equal(result.workspaces[0].id, "space"); }); // 没有 workspace 时先创建个人空间并回调选中,再提交 board;标题去首尾空白,不能在通知调用者前继续第二步。 test("a first board creates its personal workspace before posting the board", async () => { const calls = []; let selected = ""; const result = await createBoard( async (path, options) => { calls.push([path, JSON.parse(options.body)]); if (path === "workspaces") return { id: "space", name: "个人空间" }; assert.equal(selected, "space"); return { id: "board" }; }, " 周末的灵感 ", "", (w) => { selected = w.id; }, ); assert.equal(result.id, "board"); assert.deepEqual( calls.map(([p]) => p), ["workspaces", "workspaces/space/materials"], ); assert.equal(calls[1][1].title, "周末的灵感"); }); // 第一次 board 失败仍保留回调给出的 workspace ID;重试只创建 board,个人空间计数仍为一。 test("a board failure retains the new workspace, so retry creates no duplicate space", async () => { let selected = ""; let spaces = 0; let fail = true; const request = async (path) => { if (path === "workspaces") { spaces++; return { id: "space", name: "个人空间" }; } if (fail) throw new Error("upstream_unavailable"); return { id: "board" }; }; await assert.rejects( createBoard(request, "作品", selected, (w) => { selected = w.id; }), ); fail = false; await createBoard(request, "作品", selected, () => assert.fail("duplicate workspace"), ); assert.equal(spaces, 1); }); // 已有 workspace 直接向其 materials 提交,创建空间回调不得执行。 test("an existing workspace posts a board directly", async () => { const calls = []; await createBoard( async (path) => { calls.push(path); return { id: "board" }; }, "作品", "existing", () => assert.fail("unexpected workspace"), ); assert.deepEqual(calls, ["workspaces/existing/materials"]); }); // 空白名称回退默认图谱标题,payload 明确 kind/content schema;返回身份采用服务分配 ID,不能另造本地替代 ID。 test("new graphs use a default name and retain the Foundry-assigned identity", async () => { const created = await createBoard(async (path, options) => { assert.equal(path, "workspaces/space/materials"); assert.deepEqual(JSON.parse(options.body), { kind: "board", title: DEFAULT_BOARD_TITLE, content: { schema_version: 1 }, }); return { id: "server-generated-id", workspace_id: "space", kind: "board", version: 1 }; }, " ", "space", () => assert.fail("unexpected workspace creation")); assert.equal(created.id, "server-generated-id"); assert.equal(created.workspace_id, "space"); assert.equal(created.title, "未命名图谱"); assert.equal(boardHref(created.workspace_id, created.id), "/dashboard/space/server-generated-id"); }); // workspace 和 board 分段编码,特殊斜线/问号不能改变路由结构;demo 查询参数单独追加。 test("graph URLs preserve workspace identity and safely encode route segments", () => { assert.equal(boardHref("space/a", "id?b"), "/dashboard/space%2Fa/id%3Fb"); assert.equal(boardHref("demo", "board", true), "/dashboard/demo/board?demo=1"); }); // 重命名前读取最新版本,以 If-Match=7 PATCH,保留 description/content;响应为空时 helper 合成标题及 version=8 返回值,非服务端实际版本证明。 test("renaming reads the latest version and preserves description and content", async () => { const calls = []; const content = { schema_version: 1, preserved: { key: "value" } }; const renamed = await renameBoard(async (path, options) => { calls.push(path); if (!options) return { id: "board", workspace_id: "space", kind: "board", title: "Old", version: 7, description: "Keep this", content }; assert.equal(options.method, "PATCH"); assert.equal(options.headers["If-Match"], '"7"'); assert.deepEqual(JSON.parse(options.body), { title: "新名称", description: "Keep this", content }); return null; }, "space", "board", " 新名称 "); assert.deepEqual(calls, ["workspaces/space/materials/board", "workspaces/space/materials/board"]); assert.equal(renamed.title, "新名称"); assert.equal(renamed.version, 8); }); // 空白或超过 100 字符的标题在请求前拒绝;用 assert.fail 确保没有任何 API 调用。 test("rename rejects empty or oversized names without any API calls", async () => { for (const name of [" ", "字".repeat(101)]) { await assert.rejects(renameBoard(() => assert.fail("unexpected API call"), "space", "board", name), /invalid_title/); } }); // PATCH 版本冲突原样抛出且只调用一次,不能自动重试覆盖新内容。 test("rename surfaces a version conflict without retrying or overwriting", async () => { let patches = 0; await assert.rejects(renameBoard(async (_path, options) => { if (!options) return { id: "board", workspace_id: "space", kind: "board", title: "Old", version: 1 }; patches++; throw new Error("version_conflict"); }, "space", "board", "New"), /version_conflict/); assert.equal(patches, 1); });