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>
This commit is contained in:
2026-07-08 23:30:13 +08:00
parent 1bc83c7124
commit e90688c608
6 changed files with 366 additions and 3 deletions
@@ -0,0 +1,144 @@
/**
* 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' });
});
});
@@ -11,7 +11,7 @@
* Logseq marker 詞彙(TODO/DOING/LATER/NOW),非憑空編造。
*/
import { describe, it, expect } from 'vitest';
import { todoToTriageItem, inboxToTriageItem, buildTriageModel } from '../src/lib/console-triage-model';
import { todoToTriageItem, inboxToTriageItem, buildTriageModel, applyTriageCheck } from '../src/lib/console-triage-model';
import type { KbdbEntry } from '../src/lib/console-dashboard-model';
const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000);
@@ -132,3 +132,81 @@ describe('buildTriageModel — 二源合一 / 三欄計數 / per-project', () =>
expect(m.counts).toEqual({ ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: 0 });
});
});
describe('applyTriageCheck — console 勾掉/還原(雙向銷案語意,leo 2026-07-08 拍板)', () => {
const NOW = '2026-07-08T12:00:00.000Z';
it('checkstatus→done + checked_via:console + checked_at,其餘欄位原樣保留', () => {
const before = JSON.stringify({
text: '整理 sprint 板',
marker: 'TODO',
owner_tier: 'leo',
project: 'Arcrun',
source: 'notes',
status: 'new',
});
const after = JSON.parse(applyTriageCheck(before, 'check', NOW));
expect(after).toEqual({
text: '整理 sprint 板',
marker: 'TODO',
owner_tier: 'leo',
project: 'Arcrun',
source: 'notes',
status: 'done',
checked_via: 'console',
checked_at: NOW,
});
});
it('checkinbox 契約 {"text","from","status"} 同樣適用,不掉 from', () => {
const after = JSON.parse(applyTriageCheck(JSON.stringify({ text: '買咖啡豆', from: 'leo', status: 'new' }), 'check', NOW));
expect(after.from).toBe('leo');
expect(after.status).toBe('done');
expect(after.checked_via).toBe('console');
});
it('checkcontent 非 JSON(不合契約殘資料)→ 包成 {"text":原文},原文不丟', () => {
const after = JSON.parse(applyTriageCheck('裸字串待辦', 'check', NOW));
expect(after).toEqual({ text: '裸字串待辦', status: 'done', checked_via: 'console', checked_at: NOW });
});
it('check:已 done 的再勾=冪等(仍 donechecked_via 蓋成 console', () => {
const before = JSON.stringify({ text: 'x', status: 'done', checked_via: 'logseq' });
const after = JSON.parse(applyTriageCheck(before, 'check', NOW));
expect(after.status).toBe('done');
expect(after.checked_via).toBe('console');
});
it('restorestatus→new、移除 checked_via/checked_at,其餘欄位原樣保留', () => {
const before = JSON.stringify({
text: '誤勾的事',
marker: 'TODO',
owner_tier: 'ai',
project: 'mira',
source: 'notes',
status: 'done',
checked_via: 'console',
checked_at: '2026-07-08T11:00:00.000Z',
});
const after = JSON.parse(applyTriageCheck(before, 'restore', NOW));
expect(after).toEqual({
text: '誤勾的事',
marker: 'TODO',
owner_tier: 'ai',
project: 'mira',
source: 'notes',
status: 'new',
});
});
it('restorecontent 非 JSON → 包成 {"text":原文,"status":"new"}', () => {
const after = JSON.parse(applyTriageCheck('裸字串', 'restore', NOW));
expect(after).toEqual({ text: '裸字串', status: 'new' });
});
it('null/空 content 防禦:不炸,text 誠實為空字串', () => {
const after = JSON.parse(applyTriageCheck(null, 'check', NOW));
expect(after.text).toBe('');
expect(after.status).toBe('done');
});
});