Files
uncle6me-web 614fe44812 fix(kbdb): 藏書地圖加 entry_count——triplet_count=0 不再被誤讀成「沒有知識」(Arcrun#87 三次收尾)
leo21c 實測:kbdb_get_map() 9 庫裡 8 庫 triplet_count:0/top_entities:[](kb 以外全部),
一個全新 session 讀到這份地圖會合理但錯誤地判定「這些庫沒有知識」而放棄查詢——但
kbdb_search(mode=keyword) 找得到 42 筆真內容(來源 gitea:Leo/Arcrun@…/gitea:Leo/mira@…等),
其中 15 筆 entries 的 metadata_json.library 已正確標成 arcrun/mira/arcrun-harness/arcrun-rag。

查證(唯讀,未動 leo21c 任何寫入):
- entries(原始 ingest 內容)與 triplet(從 entries 萃取出的三元組)是兩個不同存放處。
- kb 以外 7 庫:entries 有、library 標記正確;triplet 一筆都沒有——不是「三元組沒貼標」,
  是「三元組從沒被萃取」(另一條偵察線在查斷在哪一段,跨 repo,本 PR 不處理)。
- general 庫(未標 library 的三元組兜底分類)即時計數也是 0 ⇒ 沒有任何「有三元組但沒標庫」
  的候選 ⇒ Leo/Arcrun#87 先前那條批次補標通道(PR #114)在目前資料現況下會補到 0 筆,
  它解的是另一個問題,不是這次「地圖看起來是空的」的真因。

本次修法(純讀端加欄位,不碰任何寫入/部署/線上資源):
- kbdb/src/actions/library-map.ts:新增 liveEntryCountsByLibrary(),依 entries 自己的
  metadata_json.$.library 分組即時計數(排除 entry_type='value' 儲存碎片與地圖自己的歷史
  摘要 block,避免自我膨脹)。listLibraryMaps/getLibraryMapDetail/recomputeLibraryMap
  的回傳都加上 entry_count,與 triplet_count 並排、互不覆蓋。
- mcp/src/lib/library-map.ts:renderLibraryMapLines(MCP 連線開場注入的那份地圖原文)
  triplet_count=0 但 entry_count>0 時改印「0 triplets/N 筆原始內容(尚未萃取關係,
  kbdb_search 查得到)」,不再只印「0 triplets」。
- mcp/src/tools/kbdb_map.ts:kbdb_get_map 工具的全館/單庫回應都帶 entry_count,並在符合
  條件時附加提示,明講「triplet_count=0 不代表沒有知識」。
- console-ui/public/console/index.html:藏書地圖看板卡片同步顯示,人類看的畫面同一件事。

順手修掉一個真 SQL bug:entry_count 排除條件原寫
`NOT (entry_type='block' AND json_extract(...)='library_map')`,SQL 三值邏輯下
metadata_json 沒有 $.kind 欄位時 json_extract 回 NULL、`NULL = 'library_map'` 為 NULL
(非 false),整條 WHERE 判定 NULL 而把所有列濾掉——改用 COALESCE(...,'') 修正
(新增測試以 sqlite 實跑驗證抓到並鎖住這個修法)。

測試:kbdb 21/21(新增 2 案,全套 215/215);mcp kbdb-map 33/33(新增 7 案,全套 120/120)。
cypher-executor 既有 map/portal-data 相關測試(library-map-scope-108/kbdb-map-proxy/
portal-data)103/104 綠,唯一失敗(/portal HTML 殼 404)在未動過的 main checkout 上同樣
失敗,環境既有問題、與本次改動無關。

CP:◐ 半通。程式碼在此分支,測試綠燈,未併 main、未部署 leo21c——entry_count 要讓
leo21c 的真實使用者看到,需部署 kbdb+mcp(cypher-executor 未改動,portal-data.ts
是透明轉發不需重部署)。三元組萃取斷在哪一段(真因)與 23/42 entries 未 embed
(語意搜尋覆蓋率)兩件不在本 PR 範圍,分別交給另一條偵察線與 embed reconcile 管線。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:36:03 +08:00

535 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import { registerGetMap } from "../../../src/tools/kbdb_map.js";
import {
buildLibraryMapInstructions,
renderLibraryMapLines,
__resetLibraryMapInstructionsCacheForTests,
} from "../../../src/lib/library-map.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
const SERVICE: KnowledgeIdentity = { kind: "service" };
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
};
/** 本次改版前簽發的舊 token(沒有身分)。 */
const STALE: KnowledgeIdentity = { kind: "stale" };
/** 假 CYPHER_EXECUTOR bindingportal 資料面用)。 */
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
CYPHER_EXECUTOR: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
} as unknown as Env;
return { env, calls };
}
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function makeServer() {
const tools = new Map<string, { description: string; handler: ToolHandler }>();
const server = {
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
tools.set(name, { description, handler });
},
};
return { server: server as unknown as McpServer, tools };
}
// ── 假 KBDB service binding:記錄請求、回預設 response ─────────────────────────────
function makeEnv(respond: (url: URL, init?: RequestInit) => Response) {
const calls: { url: URL; init?: RequestInit }[] = [];
const env = {
KBDB: {
fetch: async (input: string, init?: RequestInit) => {
const url = new URL(input);
calls.push({ url, init });
return respond(url, init);
},
},
KBDB_INTERNAL_TOKEN: "test-token",
} as unknown as Env;
return { env, calls };
}
function parseResult(r: { content: { text: string }[] }) {
return JSON.parse(r.content[0].text) as Record<string, unknown>;
}
const KB_ROW = {
library: "kb",
narrative: "leo 的知識庫主庫",
top_entities: ["00-INDEX", "kb/00-INDEX", "Gitea"],
triplet_count: 111,
updated_at: 1784451880,
};
describe("kbdb_get_map: registration", () => {
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("{}"));
registerGetMap(server, env, SERVICE);
expect(tools.has("kbdb_get_map")).toBe(true);
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
});
});
describe("kbdb_get_map: 全館地圖(無參數)", () => {
it("hits GET /map via KBDB binding and returns per-library rows", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/map");
// kbdbFetch 慣例:KBDB_INTERNAL_TOKEN 注入 Authorization
expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe("Bearer test-token");
const body = parseResult(res);
expect(body.ok).toBe(true);
const data = body.data as { libraries: { library: string; top_entities: string[] }[]; count: number };
expect(data.count).toBe(1);
expect(data.libraries[0].library).toBe("kb");
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "kb/00-INDEX", "Gitea"]);
});
it("owner_id is forwarded as query param", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env, SERVICE);
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 的庫附上「別當成沒有知識」的提示", async () => {
// leo21c 實測現況:kb 以外幾乎每庫都長這樣——entries 有(且 library 標對),三元組萃取沒跑過。
const emptyTripletRow = {
library: "arcrun",
narrative: null,
top_entities: [],
triplet_count: 0,
entry_count: 15,
updated_at: 1786330534,
};
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [emptyTripletRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { library: string; triplet_count: number; entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(15);
expect(data.libraries[0].triplet_count).toBe(0);
// 提示要點名該庫、講清楚「有內容只是沒萃取」,別讓 AI 讀到 0 triplets 就跳過這個庫。
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("entry_count");
expect(hints).toContain("kbdb_search");
});
it("entry_count 缺席(舊部署未帶此欄位)→ 容錯當 0,不 crash、不誤發提示", async () => {
const legacyRow = { ...KB_ROW }; // KB_ROW 本身沒有 entry_count 欄位(模擬舊部署回應)
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [legacyRow], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const data = body.data as { libraries: { entry_count: number }[] };
expect(data.libraries[0].entry_count).toBe(0);
});
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
const { server, tools } = makeServer();
const row = {
...KB_ROW,
top_entities: '[{"name":"00-INDEX","degree":29},{"name":"Gitea","degree":7}]',
triplet_count: "111", // slot 底層存字串 → 要轉數字
};
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const data = parseResult(res).data as {
libraries: { top_entities: string[]; triplet_count: number }[];
};
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "Gitea"]);
expect(data.libraries[0].triplet_count).toBe(111);
});
it("空庫誠實回報+提示跑 POST /map/recompute backfill(鐵律:不假綠不 crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
expect(body.ok).toBe(true);
expect((body.data as { count: number }).count).toBe(0);
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
});
// 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
// (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
// 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
const body = parseResult(res);
const hintsText = JSON.stringify(body.hints);
expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
expect(hintsText).not.toContain("(M3)");
expect(hintsText).not.toContain("M3");
// 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
});
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_fetch_failed");
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
});
});
describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
const DETAIL = {
record_id: "e_123",
library: "kb",
narrative: "leo 的知識庫主庫",
content: "kbleo 的知識庫主庫。核心:00-INDEX",
top_entities: [{ name: "00-INDEX", degree: 29 }],
relation_profile: [{ predicate: "連結至", count: 48 }],
bridges: [{ entity: "Gitea", libraries: ["notes"] }],
triplet_count: 111,
commit_hash: null,
status: "active",
updated_at: 1784451880,
};
it("hits GET /map/:library and returns full detail", async () => {
const { server, tools } = makeServer();
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(calls[0].url.pathname).toBe("/map/kb");
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
expect(map.record_id).toBe("e_123");
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
});
it("Arcrun#87 三次收尾:單庫詳圖 triplet_count=0、entry_count>0 → 附「別當空庫」提示", async () => {
const emptyTripletDetail = {
...DETAIL,
library: "arcrun",
triplet_count: 0,
entry_count: 15,
top_entities: [],
relation_profile: [],
bridges: [],
};
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: emptyTripletDetail })));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "arcrun" });
const body = parseResult(res);
const map = (body.data as { map: { entry_count: number; triplet_count: number } }).map;
expect(map.triplet_count).toBe(0);
expect(map.entry_count).toBe(15);
const hints = (body.hints as string[]).join(" ");
expect(hints).toContain("arcrun");
expect(hints).toContain("kbdb_search");
});
it("triplet_count>0 的庫不附「別當空庫」提示(不需要時別洗版)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: DETAIL }))); // DETAIL.triplet_count=111
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
const body = parseResult(res);
const hints = (body.hints as string[]).join(" ");
expect(hints).not.toContain("entry_count");
});
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
const { server, tools } = makeServer();
const raw = {
...DETAIL,
top_entities: '[{"name":"00-INDEX","degree":29}]',
relation_profile: '[{"predicate":"連結至","count":48}]',
bridges: "not-json{{", // parse 失敗 → 當空陣列,不 crash
triplet_count: "111",
};
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBeUndefined();
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
expect(map.top_entities).toEqual([{ name: "00-INDEX", degree: 29 }]);
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
expect(map.bridges).toEqual([]);
expect(map.triplet_count).toBe(111);
});
it("404 → map_not_found with recompute backfill hint(誠實回報)", async () => {
const { server, tools } = makeServer();
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
);
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
// 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
// 不再是舊版那句「從未 recompute」的曖昧說法。
expect(String(body.human_message)).toContain("查無庫");
expect(String(body.human_message)).toContain("從沒出現過");
});
it("binding throws → internal_error, not an unhandled crash", async () => {
const { server, tools } = makeServer();
const env = {
KBDB: {
fetch: async () => {
throw new Error("binding down");
},
},
} as unknown as Env;
registerGetMap(server, env, SERVICE);
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("internal_error");
});
});
// ── instructions 注入(design §4:拉不到=靜默略過,絕不擋連線)────────────────────────
describe("buildLibraryMapInstructions", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("renders one compact line per library in the指定格式", async () => {
const { env } = makeEnv(
() =>
new Response(
JSON.stringify({
success: true,
libraries: [
KB_ROW,
{ library: "notes", narrative: "手機隨手筆記", top_entities: ["Gitea"], triplet_count: 108 },
],
count: 2,
}),
),
);
const text = await buildLibraryMapInstructions(env, SERVICE);
expect(text).not.toBeNull();
// design §4 格式:{library}{narrative}|核心:{top3}{triplet_count} triplets
expect(text!).toContain("kbleo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea111 triplets");
expect(text!).toContain("notes:手機隨手筆記|核心:Gitea108 triplets");
// 導引句:把地圖跟 get_map 工具接起來(design §6 retrieval 流程)
expect(text!).toContain("kbdb_get_map");
});
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("binding throws → null(靜默略過)", async () => {
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
const { env } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
);
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
});
it("caches within TTLsame isolate 第二次不再打 /map", async () => {
const { env, calls } = makeEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const first = await buildLibraryMapInstructions(env, SERVICE);
const second = await buildLibraryMapInstructions(env, SERVICE);
expect(second).toBe(first);
expect(calls).toHaveLength(1);
});
});
describe("renderLibraryMapLines", () => {
it("null narrative / string-form top_entities / string count 都容錯", () => {
const text = renderLibraryMapLines([
{
library: "kb",
narrative: null,
top_entities: '["00-INDEX"]',
triplet_count: "5",
},
]);
expect(text).toBe("- kb:(narrative 待補)|核心:00-INDEX5 triplets");
});
it("empty input → null", () => {
expect(renderLibraryMapLines([])).toBeNull();
});
it("Arcrun#87 三次收尾:triplet_count=0 但 entry_count>0 → 開場那行不再讀成「這庫沒有知識」", () => {
// 這是 leo21c 的實際現況(kb 以外 7 庫皆此況)——這段渲染出的文字是全新 session 連上 MCP
// 第一眼看到的地圖,過去只印「0 triplets」,讀起來像空庫,session 因此跳過不查。
const text = renderLibraryMapLines([
{ library: "arcrun", narrative: null, top_entities: [], triplet_count: 0, entry_count: 15 },
]);
expect(text).toBe("- arcrun:(narrative 待補)|核心:(尚無)|0 triplets/15 筆原始內容(尚未萃取關係,kbdb_search 查得到)");
});
it("triplet_count=0 且 entry_count=0(真的沒有任何內容)→ 誠實講兩個都是 0", () => {
const text = renderLibraryMapLines([
{ library: "empty-lib", narrative: null, top_entities: [], triplet_count: 0, entry_count: 0 },
]);
expect(text).toBe("- empty-lib:(narrative 待補)|核心:(尚無)|0 triplets0 內容");
});
it("entry_count 缺席(舊部署未帶欄位)→ 容錯當 0,維持既有 triplet-only 措辭不 crash", () => {
const text = renderLibraryMapLines([
{ library: "kb", narrative: "摘要", top_entities: ["A"], triplet_count: 5 },
]);
expect(text).toBe("- kb:摘要|核心:A5 triplets");
});
});
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
// 一個只有部分權限的帳號。
describe("藏書地圖:登入身分(portal 資料面)", () => {
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBeUndefined();
expect(calls).toHaveLength(1);
expect(calls[0].url.pathname).toBe("/portal/data/map");
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
});
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
registerGetMap(server, env, PORTAL);
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
});
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
expect(res.isError).toBe(true);
const body = parseResult(res);
expect(body.error_code).toBe("map_not_found");
expect(String(body.human_message)).toContain("不在你被授權");
});
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
const { server, tools } = makeServer();
const { env } = makePortalEnv(
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
);
registerGetMap(server, env, PORTAL);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("session_expired");
});
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed", async () => {
const { server, tools } = makeServer();
const { env, calls } = makePortalEnv(() => new Response("{}"));
registerGetMap(server, env, STALE);
const res = await tools.get("kbdb_get_map")!.handler({});
expect(res.isError).toBe(true);
expect(parseResult(res).error_code).toBe("identity_missing");
expect(calls).toHaveLength(0);
});
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const text = await buildLibraryMapInstructions(env, PORTAL);
expect(text).toContain("kb");
expect(calls[0].url.pathname).toBe("/portal/data/map");
});
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
const { env, calls } = makePortalEnv(
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
);
const other: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
};
await buildLibraryMapInstructions(env, PORTAL);
await buildLibraryMapInstructions(env, other);
expect(calls).toHaveLength(2); // 兩次真的各打一次
await buildLibraryMapInstructions(env, PORTAL);
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
});
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
const { env, calls } = makePortalEnv(() => new Response("{}"));
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
expect(calls).toHaveLength(0);
});
});