Files
Arcrun/cypher-executor/tests/console-triage-check-route.test.ts
T
Leo e90688c608 feat(console): 分流台勾掉/還原(雙向銷案:console 勾掉=終局 checked_via:console,萃取端絕不復活)
leo 2026-07-08 拍板,PR#41 分流台的後續:

- lib/console-triage-model.ts:applyTriageCheck 純函式(check→status:done+
  checked_via:console+checked_at;restore→status:new+移除 checked_via/checked_at;
  content 非 JSON 殘資料包成 {"text":原文} 不丟字)。雙向銷案語意寫死在註解:
  console 勾掉=終局,即使 Logseq 原文還是 TODO 萃取端也絕不復活;Logseq 改 DONE
  由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
- console-dashboard.ts:POST /console/triage-check(沿 triage-data 同款 session 驗證;
  瀏覽器沒 KBDB token 故 server 端做 PATCH——先 GET 原 entry 整串回寫防蓋掉別的欄位;
  守 owner_id=CONSOLE_TENANT 與 entry_type∈{todo,inbox} 兩道邊界)。
- console.ts:每筆待辦 44px 圓形勾掉鈕(手機好按)+done 清單「還原」鈕(誤勾救濟);
  後端回寫成功才改本地狀態移出三欄(done 預設隱藏既有機制),失敗誠實 toast 可重試。
- 測試:applyTriageCheck 7 例(先紅後綠)+ route 層 8 例(fetchMock 假 host
  kbdb.test + disableNetConnect,絕不外連;驗 session 閘/整串回寫/邊界/restore)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 23:30:13 +08:00

145 lines
5.8 KiB
TypeScript
Raw 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.
/**
* POST /console/triage-check route 測試(分流台勾掉/還原,leo 2026-07-08 拍板)
*
* 驗證 IO 接線(改寫邏輯本身在 console-triage-model.test.ts 的 applyTriageCheck 純函式測試):
* 1. session 閘:無/壞 Bearer → 401(沿用 triage-data 同款 validateConsoleSession
* 2. 整串回寫:先 GET 原 entry → PATCH content 帶完整 JSONtext/marker/owner_tier 等
* 原樣保留 + status:done + checked_via:console + checked_at)——防 PATCH 蓋掉別的欄位
* 3. 邊界:owner_id 非 console 租戶 → 404 不 PATCHentry_type 非 todo/inbox → 400 不 PATCH
* 4. restorestatus 回 new、checked_via/checked_at 移除
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——測試絕不外連(更不會碰官方 uncle6 fallback)。
*/
import { SELF, env, fetchMock } from 'cloudflare:test';
import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest';
const TOKEN = 'test-session-token';
const AUTH = { Authorization: `Bearer ${TOKEN}` };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
beforeEach(async () => {
// isolatedStorage:每個測試自己 seed sessionconsole-auth.ts 的 key 規約 console_sess:<token>
await env.SESSIONS_KV.put(`console_sess:${TOKEN}`, JSON.stringify({ created_at: Date.now() }));
});
function post(body: unknown, headers: Record<string, string> = AUTH) {
return SELF.fetch('http://localhost/console/triage-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
/** mock GET /entries/:idKBDB base 回應形狀 {success, entry} */
function mockGet(id: string, entry: Record<string, unknown>) {
fetchMock
.get('https://kbdb.test')
.intercept({ path: `/entries/${id}`, method: 'GET' })
.reply(200, { success: true, entry });
}
describe('POST /console/triage-check — session 閘', () => {
it('無 Bearer → 401,不碰 KBDB', async () => {
const res = await post({ entry_id: 'e1' }, {});
expect(res.status).toBe(401);
});
it('壞 token → 401', async () => {
const res = await post({ entry_id: 'e1' }, { Authorization: 'Bearer wrong-token' });
expect(res.status).toBe(401);
});
it('entry_id 缺 → 400', async () => {
const res = await post({});
expect(res.status).toBe(400);
});
});
describe('POST /console/triage-check — check(勾掉=console 端終局銷案)', () => {
it('先 GET 原 entry 再整串回寫:其餘欄位原樣保留 + status/checked_via/checked_at', async () => {
mockGet('e1', {
id: 'e1',
owner_id: 'leo',
entry_type: 'todo',
content: JSON.stringify({ text: '把憑證輪替', marker: 'TODO', owner_tier: 'ai', project: 'Arcrun', source: 'notes', status: 'new' }),
});
let patched = '';
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/entries/e1', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return { success: true };
});
const res = await post({ entry_id: 'e1' });
const data = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(data.success).toBe(true);
expect(data.status).toBe('done');
const sent = JSON.parse(patched) as { content: string };
const content = JSON.parse(sent.content) as Record<string, unknown>;
expect(content.text).toBe('把憑證輪替');
expect(content.marker).toBe('TODO');
expect(content.owner_tier).toBe('ai');
expect(content.project).toBe('Arcrun');
expect(content.source).toBe('notes');
expect(content.status).toBe('done');
expect(content.checked_via).toBe('console'); // 終局標記:萃取端看到絕不復活
expect(typeof content.checked_at).toBe('string');
});
it('owner_id 非 console 租戶 → 404(不洩漏他租戶存在性),不發 PATCH', async () => {
mockGet('e2', { id: 'e2', owner_id: 'someone-else', entry_type: 'todo', content: '{}' });
const res = await post({ entry_id: 'e2' });
expect(res.status).toBe(404);
});
it('entry_type 非 todo/inbox → 400(不是泛用 entry 改寫器),不發 PATCH', async () => {
mockGet('e3', { id: 'e3', owner_id: 'leo', entry_type: 'wiki_card', content: '{}' });
const res = await post({ entry_id: 'e3' });
expect(res.status).toBe(400);
});
it('KBDB 找不到 entry → 404', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/entries/gone', method: 'GET' })
.reply(404, { success: false, error: 'not found' });
const res = await post({ entry_id: 'gone' });
expect(res.status).toBe(404);
});
});
describe('POST /console/triage-check — restore(誤勾救濟)', () => {
it('status 回 new、checked_via/checked_at 移除、其餘欄位保留(inbox 也適用)', async () => {
mockGet('e4', {
id: 'e4',
owner_id: 'leo',
entry_type: 'inbox',
content: JSON.stringify({ text: '誤勾的', from: 'leo', status: 'done', checked_via: 'console', checked_at: '2026-07-08T11:00:00.000Z' }),
});
let patched = '';
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/entries/e4', method: 'PATCH' })
.reply(200, (opts) => {
patched = String(opts.body);
return { success: true };
});
const res = await post({ entry_id: 'e4', action: 'restore' });
const data = (await res.json()) as Record<string, unknown>;
expect(res.status).toBe(200);
expect(data.status).toBe('new');
const content = JSON.parse((JSON.parse(patched) as { content: string }).content) as Record<string, unknown>;
expect(content).toEqual({ text: '誤勾的', from: 'leo', status: 'new' });
});
});