/** * Arcrun#100 — 「畫面上的 0,只准在真的是 0 的時候出現」 * * 病灶(leo 實遇):總圖頁寫「0 個實體・0 條關聯/知識庫還沒有任何關聯——上傳文件後 AI 會 * 自動織網」,而他庫裡有 1854 條三元組。那句話會叫他去做一件不需要做的事。 * * 本檔釘住三件事: * ① kbdb-graph-plugin 的 `/triplets` 前綴掛 Bearer 閘,cypher 打它**一定要帶 token** * (console-dashboard 兩支 stats 原本漏帶 → 永遠 401)。 * ② 三元組數量的真相源=KBDB `/records/triplet-stats`(真 SQL COUNT、依 owner 過濾), * **不是** plugin `/triplets/stats` 的 `total`——那是分頁長度(KBDB 端上限 100/500), * 1854 條的庫只會回 100。只修 401 不換來源=把「0」換成「100」,一樣是假的。 * ③ 讀不到一律 null / 502 / empty_confirmed=false,**絕不退化成 0**。 * * KBDB/graph-plugin 都打 fetchMock 假 host(wrangler.test.toml KBDB_BASE_URL=https://kbdb.test、 * KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。 */ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { graphHeaders, graphBase } from '../src/routes/kbdb-proxy'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; const GRAPH = 'https://graph.test'; const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT beforeAll(() => { fetchMock.activate(); fetchMock.disableNetConnect(); }); afterEach(() => fetchMock.assertNoPendingInterceptors()); /** KBDB `/records/triplet-stats` — 真 COUNT 的形狀:{ success, stats: [{library, triplet_count}] } */ function mockTripletStats(rows: { library: string; triplet_count: number }[] | null, status = 200) { fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(status, rows === null ? { success: false, error: 'boom' } : { success: true, stats: rows }); } // ═══════════════ 1. graphHeaders:打 plugin 的 header 只有一份 ═══════════════ describe('graphHeaders(#100 漂移的根:三處手拼 → 一支函式)', () => { it('有 KBDB_INTERNAL_TOKEN → 帶 Bearer(plugin 的 /triplets /graph /search /entities 全靠它)', () => { expect(graphHeaders({ KBDB_INTERNAL_TOKEN: 'tok-abc' } as unknown as Bindings)).toEqual({ Authorization: 'Bearer tok-abc', }); }); it('沒設 token → 空 headers(plugin 未設 secret 時本來就開放,不硬塞空 Bearer)', () => { expect(graphHeaders({} as unknown as Bindings)).toEqual({}); }); it('graphBase 仍照舊(KBDB_GRAPH_URL 優先、去尾斜線)', () => { expect(graphBase({ KBDB_GRAPH_URL: 'https://graph.test/' } as unknown as Bindings)).toBe('https://graph.test'); }); }); // ═══════════════ 2. /console/kb-scale-data:數字對得上庫裡真正的數量 ═══════════════ describe('GET /console/kb-scale-data — 三元組數=KBDB 真 COUNT', () => { it('庫裡 1854 條(跨三個庫)→ triplets_total 回 1854,不是 plugin 的分頁長度 100', async () => { mockTripletStats([ { library: 'general', triplet_count: 1200 }, { library: 'finance', triplet_count: 600 }, { library: 'ops', triplet_count: 54 }, ]); const res = await SELF.fetch('http://localhost/console/kb-scale-data'); expect(res.status).toBe(200); const d = (await res.json()) as { triplets_total: number | null }; expect(d.triplets_total).toBe(1854); }); it('反向:triplet-stats 讀不到(500)→ triplets_total = null,**不是 0**', async () => { mockTripletStats(null, 500); const res = await SELF.fetch('http://localhost/console/kb-scale-data'); expect(res.status).toBe(200); const d = (await res.json()) as { triplets_total: number | null }; expect(d.triplets_total).toBeNull(); expect(d.triplets_total).not.toBe(0); // 這一行就是 #100 的整個重點 }); it('反向:回應形狀不對(stats 不是陣列)→ null,不半信半疑當 0', async () => { fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) .reply(200, { success: true, stats: 'oops' }); const res = await SELF.fetch('http://localhost/console/kb-scale-data'); const d = (await res.json()) as { triplets_total: number | null }; expect(d.triplets_total).toBeNull(); }); it('真的是 0(庫存在但沒有任何三元組)→ 誠實回 0(0 只在這種時候出現)', async () => { mockTripletStats([]); const res = await SELF.fetch('http://localhost/console/kb-scale-data'); const d = (await res.json()) as { triplets_total: number | null }; expect(d.triplets_total).toBe(0); }); it('kb-scale-data 不再打 graph-plugin(沒有 plugin interceptor 也能拿到數字)', async () => { mockTripletStats([{ library: 'general', triplet_count: 7 }]); const res = await SELF.fetch('http://localhost/console/kb-scale-data'); const d = (await res.json()) as { triplets_total: number | null }; expect(d.triplets_total).toBe(7); // 打 GRAPH 的話 disableNetConnect 會讓它變 null }); }); // ═══════════════ 3. /console/dashboard-data:燈號問 plugin、數字問 KBDB ═══════════════ describe('GET /console/dashboard-data — 圖服務健康 vs 三元組數量是兩件事', () => { it('打 plugin /triplets/stats **有帶 Bearer** → graph.ok=true;數量仍取 KBDB 真 COUNT', async () => { // headers matcher:漏帶 Authorization 就配不到這個 interceptor → 請求失敗 → graph.ok=false fetchMock .get(GRAPH) .intercept({ path: (p: string) => p.startsWith('/triplets/stats'), method: 'GET', headers: { authorization: `Bearer ${env.KBDB_INTERNAL_TOKEN}` }, }) .reply(200, { total: 100 }); // plugin 的分頁長度,故意與真值不同 mockTripletStats([{ library: 'general', triplet_count: 1854 }]); const res = await SELF.fetch('http://localhost/console/dashboard-data'); expect(res.status).toBe(200); const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } }; kb: { triplets_total: number | null }; }; expect(d.system.graph.ok).toBe(true); // 帶了 token 才會是 true(#100 迴歸閘) expect(d.system.graph.triplets).toBe(1854); // 不是 plugin 的 100 expect(d.kb.triplets_total).toBe(1854); }); it('反向:plugin 打不通 → graph.ok=false,但三元組數照樣是真的(不被服務狀態吞掉)', async () => { mockTripletStats([{ library: 'general', triplet_count: 1854 }]); const res = await SELF.fetch('http://localhost/console/dashboard-data'); const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } }; expect(d.system.graph.ok).toBe(false); expect(d.system.graph.triplets).toBe(1854); }); it('反向:兩邊都讀不到 → ok=false + triplets=null(不是 0)', async () => { const res = await SELF.fetch('http://localhost/console/dashboard-data'); const d = (await res.json()) as { system: { graph: { ok: boolean; triplets: number | null } } }; expect(d.system.graph.ok).toBe(false); expect(d.system.graph.triplets).toBeNull(); }); });