83aa1f6bb2
leo 2026-08-07 直接指令:「一顆按鈕在設定裡,按鈕下載一個檔案,把檔案發給我,你看那個 檔」。本端點是那個檔的資料來源:聚合 embed 模組健康狀態(module_enabled/cards_embedded/ cards_pending/self_test)、知識庫規模(library_count/triplet_count)、bundle_version、 instance_url。 紅線落實: - 只轉發數字/布林/字串狀態,KBDB /map 回應裡的 narrative/top_entities(卡片內容)讀出 triplet_count 後即丟棄,測試 portal-data.test.ts 新增案專門斷言回應不含內容字樣。 - 認證沿用既有 requirePortalUser session 閘,不對外公開。 3 個新測試全綠(未登入 401/完整聚合含隱私斷言/embed 未開時誠實回 false 不假裝)。 既有 1 個失敗案(/portal HTML 殼 404)為 stash 驗證過的既有失敗,與本次改動無關。
792 lines
39 KiB
TypeScript
792 lines
39 KiB
TypeScript
/**
|
||
* portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6,Gitea #24/#25)
|
||
*
|
||
* 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明):
|
||
* 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
|
||
* 2. /portal/data/search enforce:server 注入 owner_id+library;caller 自帶
|
||
* 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 403;admin 唯讀 list+最近執行、回應無 webhook_url;
|
||
* workflowsVisible 單元(admin/all/off/壞值)
|
||
* 6. /portal/session 能力欄位:graph_allowed / workflows_visible
|
||
*
|
||
* 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 { 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 並回收實際轉發的 query(enforce 的機械證據)。 */
|
||
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 /portal(HTML 殼)', () => {
|
||
it('200;brand 出現;**前端零租戶字串、零 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_id+library;caller 自帶 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、不注 library(design §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('跨租戶 entry(owner_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('未標記 library(NULL 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/:name(D-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 interceptor(afterEach 驗)=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/workflows(D-8:admin 唯讀)', () => {
|
||
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_url/trigger 把手**', 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
|
||
// (KBDB=API-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/session(P3 能力欄位)', () => {
|
||
it('一般 user(finance,無 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=false(Mira 零影響預設)
|
||
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('filterDeprecatedEntries(Arcrun#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('包一層 data(http_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('normalizeCjkQuery(t95 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('findBestNodeMatch(t96 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/search(t95 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/:name(t96 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/:name(t116 kbdb_base 補傳)', () => {
|
||
it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => {
|
||
// 設定 session(["*"] 全庫,放行 graph 粗閘)
|
||
await seedSession('tok-t116', 'rec_t116');
|
||
mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' }));
|
||
|
||
// 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→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_passthrough,output = 整個 context(含 kbdb_base)
|
||
// mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應
|
||
expect(Array.isArray(data.neighbors)).toBe(true);
|
||
expect(Array.isArray(data.edges)).toBe(true);
|
||
// 確認不是 502(graph_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/:name(t128 template 補傳)', () => {
|
||
it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', 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('dedupeSourcesByPage(t129 出處去重)', () => {
|
||
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 健康狀態+規模統計+版本;只含數字/布林/字串狀態', async () => {
|
||
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: '搜不到自己' });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
|
||
.reply(200, {
|
||
success: true,
|
||
libraries: [
|
||
{ name: 'general', triplet_count: 67, narrative: '不該出現在診斷檔', top_entities: ['密卡', '內容'] },
|
||
],
|
||
count: 1,
|
||
});
|
||
|
||
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;
|
||
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(1);
|
||
expect(body.triplet_count).toBe(67);
|
||
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');
|
||
// 紅線斷言:整份回應不含知識卡內容本體(/map 回應裡的 narrative/top_entities 沒被轉發)
|
||
const raw = JSON.stringify(body);
|
||
expect(raw).not.toContain('不該出現在診斷檔');
|
||
expect(raw).not.toContain('密卡');
|
||
});
|
||
|
||
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 模組未開' });
|
||
fetchMock
|
||
.get(KBDB)
|
||
.intercept({ path: (p: string) => p.startsWith('/map'), method: 'GET' })
|
||
.reply(200, { success: true, libraries: [], count: 0 });
|
||
|
||
const res = await get('/portal/data/diagnostics', { Authorization: 'Bearer tok-diag2' });
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { embedding: { module_enabled: boolean; self_test: { ran: boolean; found_itself: boolean | null } } };
|
||
expect(body.embedding.module_enabled).toBe(false);
|
||
expect(body.embedding.self_test.ran).toBe(false);
|
||
expect(body.embedding.self_test.found_itself).toBeNull();
|
||
});
|
||
});
|