Merge branch 'main' into fix/merge-main-into-batch-t173

# Conflicts:
#	console-ui/public/portal/index.html
#	cypher-executor/src/routes/health.ts
#	cypher-executor/src/routes/portal.ts
#	registry/components/kbdb_upsert_block/component.contract.yaml
#	registry/examples/km-wiki-ingest/workflow.yaml
This commit is contained in:
2026-08-02 23:43:16 +08:00
34 changed files with 2039 additions and 33 deletions
+58
View File
@@ -1,6 +1,8 @@
// Cypher Executor 端到端測試
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import { GraphExecutor } from '../src/graph-executor';
import type { ComponentRunner, ExecutionGraph } from '../src/types';
describe('GET /', () => {
it('回傳服務狀態', async () => {
@@ -191,4 +193,60 @@ describe('POST /execute', () => {
});
expect(res.status).toBe(400);
});
});
// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status codeGraphExecutor 單元測試)
describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => {
it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => {
// mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"}
const failLoader = async (_: string): Promise<ComponentRunner> =>
async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } });
const executor = new GraphExecutor(failLoader);
const graph: ExecutionGraph = {
id: 'foreach-fail-t117',
name: 'FOREACH 全失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// t117 核心驗證:全部失敗 → throw(不再靜默)
await expect(executor.execute(graph, {})).rejects.toThrow(/401/);
});
it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => {
let callCount = 0;
// 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑)
const mixedLoader = async (_: string): Promise<ComponentRunner> =>
async () => {
callCount++;
if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' };
return { success: true, data: { ok: true } };
};
const executor = new GraphExecutor(mixedLoader);
const graph: ExecutionGraph = {
id: 'foreach-mixed-t117',
name: 'FOREACH 部分失敗',
nodes: [
{ id: 'input', type: 'Input', data: { items: ['a', 'b'] } },
{ id: 'writer', type: 'Component', componentId: 'http_request' },
],
edges: [
{ from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' },
],
};
// 部分失敗 → 不拋出,正常回傳 results 陣列
const result = await executor.execute(graph, {});
expect(result).toBeDefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { SELF } from 'cloudflare:test';
import { healthRouter } from '../src/routes/health';
import type { Bindings, ExecutionContext } from '../src/types';
describe('GET /health — bundle_version 欄位', () => {
it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => {
// wrangler.test.toml 不設此 var → 走 ?? '' fallback
const res = await SELF.fetch('http://localhost/health');
const data = await res.json() as { ok: boolean; bundle_version: string };
expect(res.status).toBe(200);
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('');
});
it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => {
const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings;
const res = await healthRouter.fetch(
new Request('http://localhost/health'),
fakeEnv,
{} as ExecutionContext,
);
const data = await res.json() as { ok: boolean; bundle_version: string };
expect(data.ok).toBe(true);
expect(data.bundle_version).toBe('2026-07-28/6d06162');
});
});
+419 -3
View File
@@ -66,7 +66,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
for (const name of ['portal_user', 'portal_library']) {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -350,12 +350,262 @@ describe('/portal/admin/libraries', () => {
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
it('GET auto 庫列表過濾 generalgeneral 是系統桶,不在用戶目錄顯示)', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []);
// t142GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb', 'general', 'notes'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
const names = data.libraries.map((l) => l.name);
expect(names).toContain('kb');
expect(names).toContain('notes');
expect(names).not.toContain('general');
});
});
// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
describe('GET /portal/admin/libraries + statst142', () => {
it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
const kb = data.libraries.find((l) => l.name === 'kb');
expect(kb).toBeDefined();
expect(kb!.card_count).toBe(42);
expect(kb!.triplet_count).toBe(111);
});
it('auto 庫也帶 card_count + triplet_count', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['notes'] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
const notes = data.libraries.find((l) => l.name === 'notes');
expect(notes).toBeDefined();
expect(notes!.auto).toBe(true);
expect(notes!.card_count).toBe(7);
expect(notes!.triplet_count).toBe(108);
});
it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
]);
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
.reply(200, { success: true, stats: [] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
const empty = data.libraries.find((l) => l.name === 'empty');
expect(empty).toBeDefined();
expect(empty!.card_count).toBe(0);
expect(empty!.triplet_count).toBe(0);
});
});
// ═══════════════ t135 庫目錄移除 ═══════════════
describe('DELETE /portal/admin/librariest135', () => {
it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
// 成員驗證:list by template 回有該 record
mockListByTemplate('portal_library', [
{ record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } },
]);
let deleteCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/records/rec_lib1', method: 'DELETE' })
.reply(200, () => { deleteCalled = true; return { success: true }; });
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; name: string; message: string };
expect(data.success).toBe(true);
expect(data.name).toBe('finance');
expect(deleteCalled).toBe(true);
});
it('DELETE /:id — 庫不在目錄 → 404', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []); // 空目錄
const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(404);
});
it('DELETE /:id — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
let deprecateCalled = false;
fetchMock
.get(KBDB)
.intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' })
.reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; });
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; deprecated_count: number };
expect(data.success).toBe(true);
expect(data.deprecated_count).toBe(12);
expect(deprecateCalled).toBe(true);
});
it('DELETE /by-name/:name — 無 confirm → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — confirm 不符 → 400', async () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(400);
});
it('DELETE /by-name/:name — 非 admin → 403', async () => {
await seedAdminSession('tok-user', 'rec_u1');
mockGetRecord('rec_u1', userValues());
const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' });
expect(res.status).toBe(403);
});
});
// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════
describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122', () => {
const USER_EMAIL = 'daemon@example.com';
const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算)
const USER_RECORD = 'rec_daemon_user';
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo
/** mock email head lookupfindUserRecordId 走這個路徑)*/
function mockEmailLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock
.get(KBDB)
.intercept({
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
})
.reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}
it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => {
// 確保 KV 沒有 extractor config
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
expect(data.success).toBe(true);
expect(data.config.extractor).toBe('gemma');
expect('gemini_api_key' in data.config).toBe(false);
});
it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => {
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' }));
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash }));
const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; config: Record<string, string> };
expect(data.config.extractor).toBe('gemma');
expect(data.config.gemini_api_key).toBe('AIza-test-key-999');
// cleanup
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
});
it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => {
await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' }));
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean };
expect(data.engine).toBe('gemma');
expect(data.has_key).toBe(true);
// 回應主體不含金鑰明文
const raw = JSON.stringify(data);
expect(raw).not.toContain('AIza-secret-key');
expect(raw).not.toContain('gemini_api_key');
// cleanup
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
});
});
// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════
describe('GET /portalP4 admin 頁 HTML 殼)', () => {
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => {
it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => {
const res = await SELF.fetch('http://localhost/portal');
expect(res.status).toBe(200);
const html = await res.text();
@@ -367,5 +617,171 @@ describe('GET /portalP4 admin 頁 HTML 殼)', () => {
expect(html).not.toContain('/kbdb/');
expect(html).not.toContain('X-Arcrun-API-Key');
expect(html).not.toContain('Mira');
// t97abootstrap 後不再預埋 kb 庫
expect(html).not.toContain('"name": "kb"');
expect(html).not.toContain("name: 'kb'");
// t114:無「登記到目錄」按鈕
expect(html).not.toContain('lib-adopt');
expect(html).not.toContain('登記到目錄');
// t131:合併 AI 設定(舊兩區塊已移除)
expect(html).toContain('st-ai-panel');
expect(html).toContain('st-ai-key');
expect(html).toContain('st-ai-use-claude');
expect(html).not.toContain('st-extractor-panel');
expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除
});
});
// ═══════════════ 8. t131 合併 AI 設定 ═══════════════
describe('/portal/admin/ai + /portal/daemon/report-capabilitiest131', () => {
const USER_EMAIL = 'ai-test@example.com';
const USER_PW = 'unit-test-pw-1';
const USER_RECORD = 'rec_ai_user';
const AI_CONFIG_KEY = 'leo:portal:ai_config';
const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config';
const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps';
function aiAdminVals(): Record<string, string> {
return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash };
}
// 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock
async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) {
await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId }));
}
function mockAiRecord(recordId = USER_RECORD) {
fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, {
success: true,
record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() },
});
}
function mockEmailLookup(email: string, recordId: string | null) {
const needle = new URLSearchParams({ page_name: email }).toString();
fetchMock.get(KBDB).intercept({
path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)),
method: 'GET',
}).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 });
}
afterEach(async () => {
await env.WEBHOOKS.delete(AI_CONFIG_KEY);
await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY);
await env.WEBHOOKS.delete(DAEMON_CAPS_KEY);
});
it('POST /ai — 首次設定:同時寫 ai_configextractor_config+更新 rag_chat workflow', async () => {
const ragChatKey = 'leo:wf:rag_chat';
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} };
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean };
expect(data.success).toBe(true);
expect(data.has_key).toBe(true);
expect(data.use_claude_for_extract).toBe(false);
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
expect(stored.gemini_api_key).toBe('AIza-new-key-123');
expect(stored.use_claude_for_extract).toBe(false);
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
expect(exCfg.engine).toBe('gemma');
expect(exCfg.gemini_api_key).toBe('AIza-new-key-123');
const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow;
expect((updated.graph as { nodes: Array<{ config: Record<string, string> }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123');
await env.WEBHOOKS.delete(ragChatKey);
});
it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => {
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-no-workflow-key' },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean };
expect(data.success).toBe(true);
expect(data.has_key).toBe(true);
const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}');
expect(stored.gemini_api_key).toBe('AIza-no-workflow-key');
});
it('POST /ai — use_claude_for_extract=trueextractor engine=claude,不附 gemini_api_key', async () => {
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/ai',
{ gemini_api_key: 'AIza-key-888', use_claude_for_extract: true },
{ Authorization: 'Bearer tok-ai-admin' }
);
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean };
expect(data.use_claude_for_extract).toBe(true);
const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}');
expect(exCfg.engine).toBe('claude');
expect('gemini_api_key' in exCfg).toBe(false);
});
it('GET /ai — 不回明文金鑰;has_key=trueclaude_available 依 daemon_caps', async () => {
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false }));
await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true }));
await seedAiSession();
mockAiRecord();
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean };
expect(data.has_key).toBe(true);
expect(data.use_claude_for_extract).toBe(false);
expect(data.claude_available).toBe(true);
const raw = JSON.stringify(data);
expect(raw).not.toContain('AIza-secret-456');
expect(raw).not.toContain('gemini_api_key');
});
it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => {
await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' }));
await seedAiSession();
mockAiRecord();
const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { claude_available: boolean };
expect(data.claude_available).toBe(false);
});
it('POST /portal/daemon/report-capabilities — 有 claudedaemon_caps 寫入 has_claude=true', async () => {
mockEmailLookup(USER_EMAIL, USER_RECORD);
mockAiRecord();
const res = await json('POST', '/portal/daemon/report-capabilities', {
email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin',
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean };
expect(data.success).toBe(true);
const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}');
expect(caps.has_claude).toBe(true);
expect(caps.daemon_version).toBe('1.2.0');
});
it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => {
const ragChatKey = 'leo:wf:rag_chat';
const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} };
await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow));
await seedAiSession();
mockAiRecord();
const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; replaced: number };
expect(data.success).toBe(true);
expect(data.replaced).toBeGreaterThan(0);
await env.WEBHOOKS.delete(ragChatKey);
});
});
+51 -1
View File
@@ -19,6 +19,7 @@
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth';
import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds';
const KBDB = 'https://kbdb.test';
const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace
@@ -73,7 +74,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu
}
function mockTemplatesExist() {
for (const name of ['portal_user', 'portal_library']) {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
@@ -414,3 +415,52 @@ describe('admin 端點 role 閘', () => {
expect(res.status).toBe(404);
});
});
// ═══════════════ t130 — triplet template seed ═══════════════
describe('t130 — triplet template seedPORTAL_TEMPLATE_SEEDS 補 tripletensurePortalTemplates 冪等)', () => {
it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data', () => {
const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet');
expect(seed).toBeDefined();
for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) {
expect(seed!.slots).toContain(slot);
}
});
it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => {
for (const name of ['portal_user', 'portal_library', 'triplet']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
expect(res.status).toBe(200);
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
expect(data.portal_templates.existing).toContain('triplet');
expect(data.portal_templates.created).not.toContain('triplet');
});
it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => {
for (const name of ['portal_user', 'portal_library']) {
fetchMock
.get(KBDB)
.intercept({ path: `/templates/${name}`, method: 'GET' })
.reply(200, { success: true, template: { id: `tpl-${name}`, name } });
}
fetchMock
.get(KBDB)
.intercept({ path: '/templates/triplet', method: 'GET' })
.reply(404, { success: false, error: 'template not found: triplet' });
fetchMock
.get(KBDB)
.intercept({ path: '/templates', method: 'POST' })
.reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } });
const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' });
expect(res.status).toBe(200);
const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } };
expect(data.portal_templates.created).toContain('triplet');
expect(data.portal_templates.existing).not.toContain('triplet');
});
});
+293 -1
View File
@@ -19,7 +19,7 @@
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 } from '../src/routes/portal-data';
import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data';
import type { Bindings } from '../src/types';
const KBDB = 'https://kbdb.test';
@@ -417,3 +417,295 @@ describe('mapGraphWorkflowOutput#57 workflow 輸出 → plugin 形狀)', ()
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 → 合為一筆
});
});