Files
Arcrun/cypher-executor/tests/portal-data.test.ts
uncle6me-web 93b1140bf1 feat(portal): 新增 GET /portal/daemon/diagnostics(t213 matrix/arcrun 半部)
InkStoneCo 總管交辦(arcrun-rag repo t213):leo 拿真診斷檔實測四個真實問題,只答得出一題
——其餘三題需要地端資料(daemon 本機 manifest 總量/失敗分類/自我更新狀態),而現有
GET /portal/data/diagnostics 只有 portal session 認證,daemon 背景行程沒有 session
(密碼只在連線精靈當下用過就丟,不落地),構不到這支端點。

本次只做 matrix/arcrun 半部(雲端這半):
- 把 /portal/data/diagnostics 的核心查詢邏輯抽成 buildDiagnostics(env, tenant)
  (portal.ts),薄殼原則:能力只實作一次
- 新增 GET /portal/daemon/diagnostics,認證比照既有 /portal/daemon/extract
  (X-Arcrun-API-Key,非 session);apiKey 當 owner_id 用,不與 portalTenant(env)
  比對(t189 教訓:daemon 的 api_key 不保證等於 worker 的 CONSOLE_TENANT)
- /portal/data/diagnostics 改呼叫共用函式,回應形狀完全不變
- 按 leo 指示刪掉「需在失敗當下由封測者截圖」那句 notes(本機那半資料到位後這句話
  失去意義)

地端那半(arcrun-app 讀 manifest 合併 + 匯出按鈕)在 products/arcrun-rag repo 進行,
待另一隻處理 t210 的 agent 落地 app.go/main.js 改動後再接線,本次不動 arcrun-app。

驗證:
- npx tsc --noEmit:與 stash 前錯誤數相同(4 個),零新增(pre-existing,與本次改動無關)
- npx vitest run:314→315 通過(+3 新測試涵蓋 401/apiKey 當 owner_id 不比對 tenant/
  回應形狀與 session 版一致),14 個既有失敗與改動前完全相同(HTML 殼 404,測試環境
  static asset 問題,與 portal.ts/portal-data.ts 邏輯無關)

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

945 lines
48 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.
/**
* portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6Gitea #24/#25
*
* 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明):
* 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
* 2. /portal/data/search enforceserver 注入 owner_idlibrarycaller 自帶
* owner_id/library 參數被靜默覆蓋(filter 繞不過的機械證明);["*"]=不注 library
* 空集合=誠實空結果不打 KBDB
* 3. /portal/data/entries/:id 逐筆驗庫:越庫 404、跨租戶 404、不存在 404(同一句,
* 不洩存在性)、NULL library→general fallback
* 4. graph D-4 粗閘:無來源庫權限 403(不打 plugin);["*"]/有權 → 轉發
* 5. workflows D-8:非 admin 403admin 唯讀 list+最近執行、回應無 webhook_url
* workflowsVisible 單元(admin/all/off/壞值)
* 6. /portal/session 能力欄位:graph_allowed / workflows_visible
*
* KBDBgraph-plugin 都打 fetchMock 假 hostwrangler.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 { workflowsVisible } from '../src/routes/portal';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
const GRAPH = 'https://graph.test';
const TENANT = 'leo'; // wrangler.test.toml CONSOLE_TENANT(只在 server 側;下面驗它不出現在前端)
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
function get(path: string, headers: Record<string, string> = {}) {
return SELF.fetch(`http://localhost${path}`, { headers });
}
async function seedSession(token: string, recordId: string) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockGetRecord(recordId: string, values: Record<string, string>) {
fetchMock
.get(KBDB)
.intercept({ path: `/records/${recordId}`, method: 'GET' })
.reply(200, { success: true, record: { record_id: recordId, template_id: 'tpl_pu', values } });
}
function mockLibraryList(records: { record_id: string; values: Record<string, string> }[]) {
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/portal_library'), method: 'GET' })
.reply(200, { success: true, records: records.map((r) => ({ ...r, template_id: 'tpl_pl' })), count: records.length });
}
function userValues(overrides: Record<string, string> = {}): Record<string, string> {
return {
email: 'user@example.com',
display_name: '測試同仁',
status: 'active',
role: 'user',
password_hash: 'pbkdf2-sha256$600000$AA$BB',
libraries: '["finance"]',
created_at: '2026-07-14T00:00:00.000Z',
updated_at: '2026-07-14T00:00:00.000Z',
...overrides,
};
}
/** 攔 KBDB /entries/search 並回收實際轉發的 queryenforce 的機械證據)。 */
function captureSearch(reply: unknown = { success: true, entries: [], count: 0, mode: 'keyword' }): { url: () => string } {
let captured = '';
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => {
if (!p.startsWith('/entries/search?')) return false;
captured = p;
return true;
},
method: 'GET',
})
.reply(200, reply as Record<string, unknown>);
return { url: () => captured };
}
// ═══════════════ 1. /portal HTML 殼 ═══════════════
describe('GET /portalHTML 殼)', () => {
it('200brand 出現;**前端零租戶字串、零 X-Arcrun-API-Key、零 Mira**', async () => {
const res = await get('/portal');
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('Arcrun Portal'); // CONSOLE_BRAND 未設 → Arcrun(引擎共用件不寫死產品名)
expect(html).toContain('/portal/data/search'); // 資料只走 enforce 面
// design §3.3 關鍵差異的機械斷言:前端不持租戶字串、不打 /kbdb/*
expect(html).not.toContain('X-Arcrun-API-Key');
expect(html).not.toMatch(/['"]leo['"]/); // 租戶字串值不得出現在頁面
expect(html).not.toContain('/kbdb/'); // 不直打 kbdb proxy(那要 API key=租戶字串)
expect(html).not.toContain('Mira'); // 零 Mira 字樣(tasks.md P3
expect(html).not.toContain('CONSOLE_TENANT');
});
});
// ═══════════════ 2. /portal/data/search enforce ═══════════════
describe('GET /portal/data/search', () => {
it('未登入 → 401,不碰 KBDB', async () => {
const res = await get('/portal/data/search?q=hello');
expect(res.status).toBe(401);
});
it('server 注入 owner_idlibrarycaller 自帶 owner_id/library 被靜默覆蓋(繞不過)', async () => {
await seedSession('tok-s1', 'rec_1');
mockGetRecord('rec_1', userValues()); // libraries=["finance"]
const cap = captureSearch();
// 攻擊嘗試:自帶 library=hr + owner_id=evil → 應完全被 server 值取代
const res = await get('/portal/data/search?q=報告&library=hr&owner_id=evil', {
Authorization: 'Bearer tok-s1',
});
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('owner_id')).toBe(TENANT); // server 注入的租戶
expect(sent.get('library')).toBe('finance'); // server 注入的用戶庫集合
expect(cap.url()).not.toContain('hr'); // caller 的越權參數完全沒被轉發
expect(cap.url()).not.toContain('evil');
});
it('多庫用戶 → library=逗號集合;mode=semantic 透傳', async () => {
await seedSession('tok-s2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["general","finance"]' }));
const cap = captureSearch({ success: true, entries: [], count: 0, mode: 'semantic' });
const res = await get('/portal/data/search?q=q1&mode=semantic', { Authorization: 'Bearer tok-s2' });
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('library')).toBe('general,finance');
expect(sent.get('mode')).toBe('semantic');
});
it('["*"](全庫)→ 只注 owner_id、不注 librarydesign §3.3', async () => {
await seedSession('tok-s3', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
const res = await get('/portal/data/search?q=q2', { Authorization: 'Bearer tok-s3' });
expect(res.status).toBe(200);
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('owner_id')).toBe(TENANT);
expect(sent.has('library')).toBe(false);
});
it('庫集合為空 → 誠實空結果,不打 KBDB search', async () => {
await seedSession('tok-s4', 'rec_4');
mockGetRecord('rec_4', userValues({ libraries: '[]' }));
const res = await get('/portal/data/search?q=q3', { Authorization: 'Bearer tok-s4' });
expect(res.status).toBe(200);
const data = (await res.json()) as { entries: unknown[]; note?: string };
expect(data.entries).toEqual([]);
expect(data.note).toContain('尚未被授權'); // 無 pending interceptor=真沒打 KBDB
});
});
// ═══════════════ 3. /portal/data/entries/:id 逐筆驗庫 ═══════════════
function mockGetEntry(id: string, entry: Record<string, unknown> | null) {
fetchMock
.get(KBDB)
.intercept({ path: `/entries/${id}`, method: 'GET' })
.reply(entry ? 200 : 404, entry ? { success: true, entry } : { success: false, error: 'not found' });
}
describe('GET /portal/data/entries/:id(逐筆驗庫)', () => {
it('越庫 id 直讀(hr entry、用戶只有 finance)→ 404', async () => {
await seedSession('tok-e1', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_hr', { id: 'e_hr', owner_id: TENANT, metadata_json: '{"library":"hr"}', content: '機密' });
const res = await get('/portal/data/entries/e_hr', { Authorization: 'Bearer tok-e1' });
expect(res.status).toBe(404);
const data = (await res.json()) as { error: string };
expect(data.error).toBe('找不到這筆資料'); // 與不存在同一句(不洩存在性)
expect(JSON.stringify(data)).not.toContain('hr'); // 不洩庫名
});
it('有權庫(finance)→ 200 回 entry', async () => {
await seedSession('tok-e2', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_fin', { id: 'e_fin', owner_id: TENANT, metadata_json: '{"library":"finance","source":"logseq://x.md"}', content: '財務' });
const res = await get('/portal/data/entries/e_fin', { Authorization: 'Bearer tok-e2' });
expect(res.status).toBe(200);
const data = (await res.json()) as { entry: { id: string } };
expect(data.entry.id).toBe('e_fin');
});
it('跨租戶 entryowner_id 不是本實例租戶)→ 404 同一句', async () => {
await seedSession('tok-e3', 'rec_1');
mockGetRecord('rec_1', userValues({ libraries: '["*"]' })); // 就算全庫也擋跨租戶
mockGetEntry('e_other', { id: 'e_other', owner_id: 'other-tenant', metadata_json: '{"library":"finance"}' });
const res = await get('/portal/data/entries/e_other', { Authorization: 'Bearer tok-e3' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('不存在的 id → 404 同一句', async () => {
await seedSession('tok-e4', 'rec_1');
mockGetRecord('rec_1', userValues());
mockGetEntry('e_ghost', null);
const res = await get('/portal/data/entries/e_ghost', { Authorization: 'Bearer tok-e4' });
expect(res.status).toBe(404);
expect(((await res.json()) as { error: string }).error).toBe('找不到這筆資料');
});
it('未標記 libraryNULL metadata)→ 歸 general:有 general 者 200、無者 404', async () => {
await seedSession('tok-e5', 'rec_5');
mockGetRecord('rec_5', userValues({ libraries: '["general"]' }));
mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
const ok = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e5' });
expect(ok.status).toBe(200);
await seedSession('tok-e6', 'rec_6');
mockGetRecord('rec_6', userValues({ libraries: '["finance"]' })); // 沒 general
mockGetEntry('e_old', { id: 'e_old', owner_id: TENANT, metadata_json: null, content: '舊資料' });
const no = await get('/portal/data/entries/e_old', { Authorization: 'Bearer tok-e6' });
expect(no.status).toBe(404);
});
it('entryLibrary 單元:壞 metadata/缺欄位 → general;有 library → 原值', () => {
expect(entryLibrary({ metadata_json: null })).toBe('general');
expect(entryLibrary({ metadata_json: 'not-json{{' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"source":"x"}' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"library":""}' })).toBe('general');
expect(entryLibrary({ metadata_json: '{"library":"hr"}' })).toBe('hr');
});
});
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe('GET /portal/data/graph/neighbors/:nameD-4 粗閘)', () => {
it('無 graph 來源庫權限(來源庫預設 general、用戶只有 finance)→ 403,不打 plugin', async () => {
await seedSession('tok-g1', 'rec_1');
mockGetRecord('rec_1', userValues()); // finance only
mockLibraryList([]); // 沒有任何庫標 graph_source → 來源預設 ['general']
const res = await get('/portal/data/graph/neighbors/某節點', { Authorization: 'Bearer tok-g1' });
expect(res.status).toBe(403);
// 無 pending interceptorafterEach 驗)=graph plugin 完全沒被打
});
it('["*"] 全庫 → 放行並轉發 plugin(不需查庫目錄)', async () => {
await seedSession('tok-g2', 'rec_2');
mockGetRecord('rec_2', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { node: 'n', edges: [], neighbors: [], edgeCount: 0, neighborCount: 0 });
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g2' });
expect(res.status).toBe(200);
});
it('庫目錄標 finance 為 graph_source → finance 用戶放行', async () => {
await seedSession('tok-g3', 'rec_1');
mockGetRecord('rec_1', userValues()); // finance
mockLibraryList([
{ record_id: 'lib_fin', values: { name: 'finance', status: 'active', graph_source: 'true' } },
]);
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { node: 'n', edges: [], neighbors: [] });
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g3' });
expect(res.status).toBe(200);
});
it('停用的 graph_source 庫不算來源(disabled 排除 → 回到預設 general → finance 用戶 403', async () => {
await seedSession('tok-g4', 'rec_1');
mockGetRecord('rec_1', userValues());
mockLibraryList([
{ record_id: 'lib_fin', values: { name: 'finance', status: 'disabled', graph_source: 'true' } },
]);
const res = await get('/portal/data/graph/neighbors/n', { Authorization: 'Bearer tok-g4' });
expect(res.status).toBe(403);
});
});
// ═══════════════ 5. workflows D-8 ═══════════════
describe('GET /portal/data/workflowsD-8admin 唯讀)', () => {
it('非 admin(預設 PORTAL_SHOW_WORKFLOWS=admin)→ 403', async () => {
await seedSession('tok-w1', 'rec_1');
mockGetRecord('rec_1', userValues({ role: 'user' }));
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w1' });
expect(res.status).toBe(403);
});
it('admin → 200 唯讀 list+最近執行;**回應無 webhook_urltrigger 把手**', async () => {
await seedSession('tok-w2', 'rec_a');
mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
await env.WEBHOOKS.put(
`${TENANT}:wf:daily_report`,
JSON.stringify({ description: '每日彙整', created_at: '2026-07-14T00:00:00Z', cron_expr: '0 9 * * *' }),
);
// KV 額度事故修復(2026-08-07):last_execution 資料源改打 KBDB GET /execution-log/latest
// KBDBAPI-as-Wall,本檔一律 fetchMock 攔截,不碰任何 D1)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/execution-log/latest?'), method: 'GET' })
.reply(200, { success: true, execution: { verdict: 'success', recorded_at: 1783500000 } });
const res = await get('/portal/data/workflows', { Authorization: 'Bearer tok-w2' });
expect(res.status).toBe(200);
const data = (await res.json()) as {
workflows: { name: string; description: string; last_execution: { verdict?: string; timestamp: string } | null }[];
read_only: boolean;
};
expect(data.read_only).toBe(true);
const wf = data.workflows.find((w) => w.name === 'daily_report');
expect(wf).toBeTruthy();
expect(wf!.description).toBe('每日彙整');
expect(wf!.last_execution?.verdict).toBe('success');
expect(JSON.stringify(data)).not.toContain('webhook_url');
expect(JSON.stringify(data)).not.toContain('/trigger');
// 清場(KV 是 suite 共用實例,避免污染其他測試)
await env.WEBHOOKS.delete(`${TENANT}:wf:daily_report`);
});
it('workflowsVisible 單元:admin(預設/壞值)/ all / off', () => {
const mk = (v?: string) => ({ PORTAL_SHOW_WORKFLOWS: v }) as unknown as Bindings;
expect(workflowsVisible(mk(undefined), 'admin')).toBe(true);
expect(workflowsVisible(mk(undefined), 'user')).toBe(false);
expect(workflowsVisible(mk('all'), 'user')).toBe(true);
expect(workflowsVisible(mk('off'), 'admin')).toBe(false);
expect(workflowsVisible(mk('typo!!'), 'user')).toBe(false); // 壞值退回 admin-only,不意外全開
expect(workflowsVisible(mk('typo!!'), 'admin')).toBe(true);
});
});
// ═══════════════ 6. /portal/session 能力欄位 ═══════════════
describe('GET /portal/sessionP3 能力欄位)', () => {
it('一般 userfinance,無 graph 來源權限)→ graph_allowed=false、workflows_visible=false;仍無租戶字串', async () => {
await seedSession('tok-p1', 'rec_1');
mockGetRecord('rec_1', userValues());
mockLibraryList([]);
const res = await get('/portal/session', { Authorization: 'Bearer tok-p1' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.graph_allowed).toBe(false);
expect(data.workflows_visible).toBe(false);
expect('tenant' in data).toBe(false);
expect(JSON.stringify(data)).not.toContain('"leo"');
});
it('admin ["*"] → graph_allowed=true(免查庫目錄)、workflows_visible=true', async () => {
await seedSession('tok-p2', 'rec_a');
mockGetRecord('rec_a', userValues({ role: 'admin', libraries: '["*"]' }));
const res = await get('/portal/session', { Authorization: 'Bearer tok-p2' });
expect(res.status).toBe(200);
const data = (await res.json()) as Record<string, unknown>;
expect(data.graph_allowed).toBe(true);
expect(data.workflows_visible).toBe(true);
// portal-demo-suite:測試環境未設 upload bindings → upload_enabled=falseMira 零影響預設)
expect(data.upload_enabled).toBe(false);
});
});
// ═══════════════ 7. portal-demo-suite 純函式 ═══════════════
describe('sanitizeUploadFilename(上傳檔名驗證)', () => {
it('去路徑分隔(擋穿越)、.txt 改 .md、無副檔名補 .md', () => {
expect(sanitizeUploadFilename('notes.md')).toBe('notes.md');
expect(sanitizeUploadFilename('memo.txt')).toBe('memo.md');
expect(sanitizeUploadFilename('README')).toBe('README.md');
expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd.md'); // 只取最後一段,穿越失效
expect(sanitizeUploadFilename('a\\b\\c.md')).toBe('c.md');
expect(sanitizeUploadFilename('中文筆記.txt')).toBe('中文筆記.md');
});
it('空名/純路徑/隱藏檔/超過 100 字/非字串 → null', () => {
expect(sanitizeUploadFilename('')).toBe(null);
expect(sanitizeUploadFilename(' ')).toBe(null);
expect(sanitizeUploadFilename('docs/')).toBe(null);
expect(sanitizeUploadFilename('.env')).toBe(null);
expect(sanitizeUploadFilename('a'.repeat(120) + '.md')).toBe(null);
expect(sanitizeUploadFilename(42)).toBe(null);
expect(sanitizeUploadFilename(undefined)).toBe(null);
});
});
describe('filterDeprecatedEntriesArcrun#46 搜尋殘影治標)', () => {
it('濾 status=deprecated 與「(舊管線產物」開頭;metadata parse 失敗保留', () => {
const keepNormal = { metadata_json: '{"library":"general"}', content: '正常內容' };
const keepBadMeta = { metadata_json: 'not-json{{', content: '壞 metadata 不誤殺' };
const keepNullMeta = { metadata_json: null, content: '無 metadata' };
const dropByStatus = { metadata_json: '{"status":"deprecated"}', content: '看起來正常但已標廢' };
const dropByContent = { metadata_json: '{}', content: '(舊管線產物)殘影條目' };
const out = filterDeprecatedEntries([keepNormal, dropByStatus, keepBadMeta, dropByContent, keepNullMeta]);
expect(out).toEqual([keepNormal, keepBadMeta, keepNullMeta]);
});
it('空陣列 → 空陣列', () => {
expect(filterDeprecatedEntries([])).toEqual([]);
});
it('濾內部型別 value/workflow(無標題雜項列;leo 2026-07-18 客戶測試回饋);block/wiki 保留', () => {
const keepBlock = { entry_type: 'block', metadata_json: '{}', content: '正常 block' };
const keepWiki = { entry_type: 'wiki', metadata_json: '{}', content: '精耕頁' };
const keepNoType = { metadata_json: '{}', content: '無 entry_type 不誤殺' };
const dropValue = { entry_type: 'value', metadata_json: '{}', content: '特休假' };
const dropWorkflow = { entry_type: 'workflow', metadata_json: '{}', content: '同步問答:…' };
const out = filterDeprecatedEntries([keepBlock, dropValue, keepWiki, dropWorkflow, keepNoType]);
expect(out).toEqual([keepBlock, keepWiki, keepNoType]);
});
});
describe('mapGraphWorkflowOutput#57 workflow 輸出 → plugin 形狀)', () => {
it('本體有 neighbors → 直取;count 重算不信自報', () => {
const out = mapGraphWorkflowOutput({ neighbors: ['a', 'b'], edges: [{ subject: 'a', predicate: 'rel', object: 'b' }], count: 99 });
expect(out.neighbors).toEqual(['a', 'b']);
expect(out.edges.length).toBe(1);
expect(out.count).toBe(2);
});
it('包一層 datahttp_request 慣例)→ 取內層;非物件/缺欄位 → 誠實空集合', () => {
expect(mapGraphWorkflowOutput({ data: { neighbors: ['x'], edges: [] } })).toEqual({ neighbors: ['x'], edges: [], count: 1 });
expect(mapGraphWorkflowOutput(null)).toEqual({ neighbors: [], edges: [], count: 0 });
expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 });
});
});
// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════
describe('normalizeCjkQueryt95 CJK/ASCII 邊界補空白)', () => {
it('純中文 → 不動', () => {
expect(normalizeCjkQuery('中文')).toBe('中文');
expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複
});
it('純 ASCII/數字 → 不動', () => {
expect(normalizeCjkQuery('ABC123')).toBe('ABC123');
expect(normalizeCjkQuery('')).toBe('');
});
it('CJK→ASCII 邊界插空白', () => {
expect(normalizeCjkQuery('協作AI')).toBe('協作 AI');
expect(normalizeCjkQuery('中文1234')).toBe('中文 1234');
});
it('ASCII→CJK 邊界插空白', () => {
expect(normalizeCjkQuery('AI協作')).toBe('AI 協作');
expect(normalizeCjkQuery('1234中文')).toBe('1234 中文');
});
it('已有空白不重複插', () => {
expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書');
});
it('全形符號(非 ASCII alnum)不觸發插空白', () => {
expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文');
});
});
// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════
describe('findBestNodeMatcht96 fuzzy 節點比對)', () => {
it('空清單 → null', () => {
expect(findBestNodeMatch('AI 協作', [])).toBeNull();
});
it('完全不包含 → null', () => {
expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull();
});
it('精確子字串命中 → 返回', () => {
expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
it('多命中 → 取最短(最精確優先)', () => {
const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']);
expect(result).toBe('AI'); // 最短
});
it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => {
// 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」
expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書');
});
it('大小寫不敏感', () => {
expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書');
});
});
// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════
describe('GET /portal/data/searcht95 CJK 正規化)', () => {
it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => {
await seedSession('tok-cn1', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 已補空白
});
it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => {
await seedSession('tok-cn2', 'rec_3');
mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' }));
const cap = captureSearch();
await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' });
const sent = new URLSearchParams(cap.url().split('?')[1]);
expect(sent.get('q')).toBe('AI 協作'); // 無重複空白
});
});
// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════
describe('GET /portal/data/graph/neighbors/:namet96 fuzzy fallback', () => {
it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => {
await seedSession('tok-gf1', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // 有鄰居直接回
});
it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => {
await seedSession('tok-gf2', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// 精確命中「AI 協作」→ 0 鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 含「AI 協作規範書」
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, {
records: [
{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } },
{ values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } },
],
});
// fallback 以「AI 協作規範書」重查 → 有鄰居
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居
});
it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => {
await seedSession('tok-gf3', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
// KBDB triplets → 完全沒有能比對的節點
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] });
const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造
expect(data.edges.length).toBe(0);
});
it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => {
await seedSession('tok-gf4', 'rec_a');
mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' }));
// plugin 收到的是正規化後的「AI 協作」(%20 分隔)
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' })
.reply(200, { neighbors: [], edges: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' })
.reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] });
fetchMock
.get(GRAPH)
.intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' })
.reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] });
// 前端傳「AI協作」(無空白,URL encoded
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[] };
expect(data.neighbors.length).toBe(1);
});
});
// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════
describe('GET /portal/data/graph/neighbors/:namet116 kbdb_base 補傳)', () => {
it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_baseworkflow 正常執行不崩', async () => {
// 設定 session["*"] 全庫,放行 graph 粗閘)
await seedSession('tok-t116', 'rec_t116');
mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
// 在 WEBHOOKS KV 放 graph_neighbors workflowInput→Output 直通)
// 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback
// 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error
// 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback
const wfKey = `${TENANT}:wf:graph_neighbors`;
await env.WEBHOOKS.put(wfKey, JSON.stringify({
graph: {
id: 'gn-t116',
name: 'graph_neighbors',
nodes: [
{ id: 'input', type: 'Input' },
// comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
{ id: 'output', type: 'Output' },
],
edges: [
{ from: 'input', to: 'pass', type: 'PIPE' },
{ from: 'pass', to: 'output', type: 'PIPE' },
],
},
description: 't116 test',
created_at: '2026-07-29T00:00:00.000Z',
}));
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' });
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string };
// workflow 走 comp_passthroughoutput = 整個 context(含 kbdb_base
// mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
expect(Array.isArray(data.neighbors)).toBe(true);
expect(Array.isArray(data.edges)).toBe(true);
// 確認不是 502graph_neighbors workflow 執行失敗)
expect(res.status).not.toBe(502);
await env.WEBHOOKS.delete(wfKey);
});
});
// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════
describe('GET /portal/data/graph/neighbors/:namet128 template 補傳)', () => {
it('tenant 有 graph_neighbors workflow → portal 傳入 template=tripletworkflow 不崩', async () => {
await seedSession('tok-t128', 'rec_t128');
mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' }));
const wfKey = `${TENANT}:wf:graph_neighbors`;
await env.WEBHOOKS.put(wfKey, JSON.stringify({
graph: {
id: 'gn-t128',
name: 'graph_neighbors',
nodes: [
{ id: 'input', type: 'Input' },
{ id: 'pass', type: 'Component', componentId: 'comp_passthrough' },
{ id: 'output', type: 'Output' },
],
edges: [
{ from: 'input', to: 'pass', type: 'PIPE' },
{ from: 'pass', to: 'output', type: 'PIPE' },
],
},
}));
const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' });
// template 有進 context → workflow 執行不崩(非 502
expect(res.status).toBe(200);
const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] };
expect(Array.isArray(data.neighbors)).toBe(true);
await env.WEBHOOKS.delete(wfKey);
});
});
// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════
describe('dedupeSourcesByPaget129 出處去重)', () => {
it('同 page_name 合併,hit_count 標計數', () => {
const srcs = [
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
{ page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' },
{ page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' },
];
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
expect(out.length).toBe(1); // 3 筆→1 筆
expect(out[0].page_name).toBe('企業版功能');
expect(out[0].hit_count).toBe(3);
});
it('不同 page_name 各保留一筆;單筆無 hit_count', () => {
const srcs = [
{ page_name: 'A 頁', mode: 'semantic' },
{ page_name: 'B 頁', mode: 'keyword' },
];
const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[];
expect(out.length).toBe(2);
expect(out.every(s => s.hit_count === undefined)).toBe(true);
});
it('page 欄(備用)也能去重', () => {
const srcs = [
{ page: '備用頁', mode: 'semantic' },
{ page: '備用頁', mode: 'keyword' },
];
const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[];
expect(out.length).toBe(1);
expect(out[0].hit_count).toBe(2);
});
it('空陣列 → 空陣列;非物件條目跳過', () => {
expect(dedupeSourcesByPage([])).toEqual([]);
const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]);
expect(out.length).toBe(1);
});
it('page_name 優先於 page', () => {
const srcs = [
{ page_name: '優先頁', page: '備用頁' },
{ page_name: '優先頁', page: '備用頁' },
];
const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[];
expect(out.length).toBe(1); // 同 page_name → 合為一筆
});
});
// ═══════════════ 7. GET /portal/data/diagnostics(檢修孔,2026-08-07) ═══════════════
describe('GET /portal/data/diagnostics', () => {
it('未登入 → 401,不碰 KBDB', async () => {
const res = await get('/portal/data/diagnostics');
expect(res.status).toBe(401);
});
it('登入 → 200,聚合 embed 健康狀態+規模統計(即時查,非 /map 快取)+版本;只含數字/布林/字串狀態', async () => {
// 2026-08-08 修復對應測試:library_count/triplet_count 改走 listRecordsByTemplate(portal_library)
// /entries/libraries /records/triplet-stats(與 GET /portal/admin/libraries 同一套即時查),
// 不再靠 /maplibrary_map 快取,recompute 從未被呼叫,恆回空——這正是 08-07 leo 實測抓到的病根)。
await seedSession('tok-diag1', 'rec_diag1');
mockGetRecord('rec_diag1', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: true, pending: 3, embedded: 80 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: true, tested: true, passed: false, note: '搜不到自己' });
// 已登記庫:1 筆(kb),values 帶不該外流的內容欄位(display_name/description)驗紅線。
mockLibraryList([
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '不該出現在診斷檔', description: '密卡內容' } },
]);
// 資料裡實際蓋章出現過的庫:kb(與登記簿重複,去重)+notes(未登記但蓋章過,t52「蓋章即現身」)+general(fallback 桶,排除不算庫)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: ['general', 'kb', 'notes'], count: 3 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 40 }, { library: 'notes', triplet_count: 27 }] });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag1' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean };
embedding: { module_enabled: boolean; cards_embedded: number; cards_pending: number; self_test: { ran: boolean; found_itself: boolean | null } };
instance_url: string;
bundle_version: string | null;
};
expect(body.library_count).toBe(2); // kb(登記簿+資料面重複,去重)+notes;general 不算
expect(body.triplet_count).toBe(67); // 40+27,實際聚合 SQL 算出,非快取
expect(body.library_scope_check.ran).toBe(false); // 數字不是 0,不需要自我探測
expect(body.embedding.module_enabled).toBe(true);
expect(body.embedding.cards_embedded).toBe(80);
expect(body.embedding.cards_pending).toBe(3);
expect(body.embedding.self_test.ran).toBe(true);
expect(body.embedding.self_test.found_itself).toBe(false);
expect(body.instance_url).toBe('http://localhost');
// 紅線斷言:整份回應不含知識卡內容本體(登記簿 values 裡的 display_name/description 沒被轉發,只取了 name 算數)
const raw = JSON.stringify(body);
expect(raw).not.toContain('不該出現在診斷檔');
expect(raw).not.toContain('密卡');
expect(raw).not.toContain('"kb"'); // 連庫名本身都不外流,只回數字
});
it('embed 模組未開(自架未開語義搜尋)→ 誠實回 module_enabled:false,不是假裝有 index;庫/三元組真的是空 → 自我探測也回空,不誤判為查詢錯誤', async () => {
await seedSession('tok-diag2', 'rec_diag2');
mockGetRecord('rec_diag2', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// library_count/triplet_count 都是 0 → 觸發自我探測;這裡探測也回真的空(total:0)。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [], count: 0, total: 0 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(false);
expect(body.embedding.module_enabled).toBe(false);
expect(body.embedding.self_test.ran).toBe(false);
expect(body.embedding.self_test.found_itself).toBeNull();
});
it('統計自我檢查抓到 t161 同型病:庫/三元組回 0,但這個租戶底下其實查得到其他資料 → 標「像是查詢方式或租戶對不上」而非誤判成真的沒有資料', async () => {
await seedSession('tok-diag3', 'rec_diag3');
mockGetRecord('rec_diag3', userValues());
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: 'embed 模組未開' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
// 自我探測:這個租戶底下其實有 12 筆 entries——庫/三元組統計卻回 0,兩者矛盾,該被標記。
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [{ id: 'e1' }], count: 1, total: 12 });
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag3' });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
library_scope_check: { ran: boolean; any_entries_found: boolean | null; note: string };
};
expect(body.library_count).toBe(0);
expect(body.triplet_count).toBe(0);
expect(body.library_scope_check.ran).toBe(true);
expect(body.library_scope_check.any_entries_found).toBe(true);
expect(body.library_scope_check.note).toContain('查詢方式或租戶對不上');
});
});
// ═══════════ 8. GET /portal/daemon/diagnosticst213daemon 免帳密版檢修孔,2026-08-08) ═══════════
//
// 與上面 /portal/data/diagnostics 共用同一個 buildDiagnostics()portal.ts)——這裡只驗證
// ①認證換了一套(X-Arcrun-API-Key,非 session)②apiKey 當 owner_id 打 KBDB,不與
// portalTenant(env)='leo',見上方 TENANT 常數)比對/不要求相等(t189 教訓)③回應形狀
// 與 session 版一致。核心查詢邏輯已在上面 7 組測試驗過,這裡不重複。
describe('GET /portal/daemon/diagnosticst213 daemon 版)', () => {
it('沒帶 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await get('/portal/daemon/diagnostics');
expect(res.status).toBe(401);
});
it('帶 key(刻意與 CONSOLE_TENANT="leo" 不同)→ 200,且 KBDB 查詢用的 owner_id 是這把 key 本身,不是 leot189:不假設 apiKey===portalTenant', async () => {
const daemonKey = 'yuga3bse'; // 刻意選一個跟 TENANT('leo') 不同的值,比照 t189 geek6688 案例
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, enabled: true, pending: 2, embedded: 9 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, enabled: true, tested: true, passed: true, note: '' });
mockLibraryList([{ record_id: 'rec_lib_kb2', values: { name: 'kb' } }]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, libraries: ['general', 'kb'], count: 2 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats') && p.includes(`owner_id=${daemonKey}`), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 9 }] });
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
expect(res.status).toBe(200);
const body = (await res.json()) as {
library_count: number;
triplet_count: number;
embedding: { module_enabled: boolean; cards_embedded: number };
instance_url: string;
bundle_version: string | null;
};
expect(body.library_count).toBe(1);
expect(body.triplet_count).toBe(9);
expect(body.embedding.module_enabled).toBe(true);
expect(body.embedding.cards_embedded).toBe(9);
expect(body.instance_url).toBe('http://localhost');
// 沒有任何 session 檢查——不打 SESSIONS_KV/portal_user record(本測試從未 seedSession/mockGetRecord
// 仍然 200,證明這條路徑真的不吃 session)。
});
it('回應形狀與 session 版一致(同一組欄位名)', async () => {
const daemonKey = 'shape-check-key';
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/backfill/status'), method: 'GET' })
.reply(200, { success: true, enabled: false, pending: 0, embedded: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/embed/selftest'), method: 'GET' })
.reply(200, { success: true, enabled: false, tested: false, passed: null, note: '' });
mockLibraryList([]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { success: true, libraries: [], count: 0 });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries?'), method: 'GET' })
.reply(200, { success: true, entries: [], count: 0, total: 0 });
const res = await get('/portal/daemon/diagnostics', { 'X-Arcrun-API-Key': daemonKey });
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(Object.keys(body).sort()).toEqual(
['generated_at', 'instance_url', 'bundle_version', 'library_count', 'triplet_count', 'library_scope_check', 'embedding', 'notes'].sort(),
);
// t213 leo 08-08 指令:舊的「需在失敗當下截圖」那句已刪,notes 不該再含這句話。
expect(JSON.stringify(body.notes)).not.toContain('截圖');
});
});