From e90688c608f4371b706f0a0a1f799e5641233917 Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 8 Jul 2026 23:30:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E5=88=86=E6=B5=81=E5=8F=B0?= =?UTF-8?q?=E5=8B=BE=E6=8E=89/=E9=82=84=E5=8E=9F=EF=BC=88=E9=9B=99?= =?UTF-8?q?=E5=90=91=E9=8A=B7=E6=A1=88=EF=BC=9Aconsole=20=E5=8B=BE?= =?UTF-8?q?=E6=8E=89=EF=BC=9D=E7=B5=82=E5=B1=80=20checked=5Fvia:console?= =?UTF-8?q?=EF=BC=8C=E8=90=83=E5=8F=96=E7=AB=AF=E7=B5=95=E4=B8=8D=E5=BE=A9?= =?UTF-8?q?=E6=B4=BB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/console-triage-model.ts | 34 +++++ .../src/routes/console-dashboard.ts | 51 ++++++- cypher-executor/src/routes/console.ts | 52 ++++++- .../tests/console-triage-check-route.test.ts | 144 ++++++++++++++++++ .../tests/console-triage-model.test.ts | 80 +++++++++- cypher-executor/wrangler.test.toml | 8 + 6 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/tests/console-triage-check-route.test.ts diff --git a/cypher-executor/src/lib/console-triage-model.ts b/cypher-executor/src/lib/console-triage-model.ts index c70f890..786b583 100644 --- a/cypher-executor/src/lib/console-triage-model.ts +++ b/cypher-executor/src/lib/console-triage-model.ts @@ -112,6 +112,40 @@ export function inboxToTriageItem(e: KbdbEntry): TriageItem { }; } +export type TriageCheckAction = 'check' | 'restore'; + +/** + * console 勾掉/還原 → 新 content JSON 字串(純函式;route 先 GET 原 entry 再整串回寫, + * 防 PATCH 蓋掉別的欄位)。 + * + * ── 雙向銷案語意(leo 2026-07-08 拍板,死循環防呆——萃取端會配合這套規約)── + * 1. console 勾掉=**終局**:status:done + checked_via:"console" + checked_at。 + * 即使 Logseq 原文還是 TODO,萃取端(Arcrun#8 ingest 線)看到 checked_via:console + * 也**絕不復活它**(不改回 new、不重灌覆蓋)——否則「console 勾掉→下輪萃取又出現」 + * 的死循環。 + * 2. Logseq 那邊改 DONE 的,萃取端來 PATCH status:done + checked_via:"logseq"—— + * 同一個 status slot、兩個來源靠 checked_via 溯源,console 只需忠實顯示非 done 項。 + * 3. 還原(誤勾救濟)=status 回 new、**移除 checked_via/checked_at**(回到「未銷案」 + * 的乾淨狀態,讓兩邊規約重新適用;不留殘欄位讓萃取端誤讀成已銷案)。 + * + * 防禦:content 非 JSON(不合契約殘資料,與 todoToTriageItem 同一情境)→ 包成 + * {"text":原文} 再上狀態欄——原文不丟、從此畢業成契約 JSON(status 才有地方放)。 + */ +export function applyTriageCheck(content: string | null | undefined, action: TriageCheckAction, nowIso: string): string { + let obj: Record; + try { + const v = JSON.parse(content ?? ''); + obj = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : { text: content ?? '' }; + } catch { + obj = { text: content ?? '' }; + } + if (action === 'restore') { + const { checked_via: _via, checked_at: _at, ...rest } = obj; + return JSON.stringify({ ...rest, status: 'new' }); + } + return JSON.stringify({ ...obj, status: 'done', checked_via: 'console', checked_at: nowIso }); +} + /** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */ export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel { const items = [ diff --git a/cypher-executor/src/routes/console-dashboard.ts b/cypher-executor/src/routes/console-dashboard.ts index 79c29b4..4a5034a 100644 --- a/cypher-executor/src/routes/console-dashboard.ts +++ b/cypher-executor/src/routes/console-dashboard.ts @@ -68,7 +68,7 @@ import { sortWaitingItems, taipeiDayKey, } from '../lib/console-dashboard-model'; -import { buildTriageModel } from '../lib/console-triage-model'; +import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model'; import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>(); @@ -473,6 +473,55 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => { return c.json({ ...model, generated_at: new Date().toISOString() }); }); +// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。 +// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在 +// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用 +// triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。 +// +// PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)—— +// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。 +// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。 +// +// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)── +// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不 +// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。 +// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。 +consoleDashboardRouter.post('/console/triage-check', async (c) => { + const ok = await validateConsoleSession(c.env, c.req.header('authorization')); + if (!ok) return c.json({ error: '需要登入(console session)' }, 401); + + const body = await c.req.json().catch(() => null); + const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : ''; + if (!entryId) return c.json({ error: 'entry_id 必填' }, 400); + const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check'; + + const tenant = c.env.CONSOLE_TENANT || 'leo'; + const { base, headers } = kbdbBase(c.env); + + // 先 GET 原 entry(整串回寫的前提),順便守兩道邊界: + // 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料); + // 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。 + const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>( + `${base}/entries/${encodeURIComponent(entryId)}`, + headers, + ); + const entry = got?.entry; + if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); + if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性 + if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') { + return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400); + } + + const newContent = applyTriageCheck(entry.content, action, new Date().toISOString()); + const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, { + method: 'PATCH', + headers, + body: JSON.stringify({ content: newContent }), + }); + if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502); + return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' }); +}); + function renderDashboardHtml(): string { return ` diff --git a/cypher-executor/src/routes/console.ts b/cypher-executor/src/routes/console.ts index 257cdc0..3d36ecd 100644 --- a/cypher-executor/src/routes/console.ts +++ b/cypher-executor/src/routes/console.ts @@ -28,6 +28,9 @@ * 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+ * entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips * +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。 + * 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)—— + * 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改 + * DONE 由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。 * - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點; * 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開) * + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19) @@ -260,6 +263,17 @@ function renderConsoleHtml(registryBase: string): string { .tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); } .tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; } .tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); } + .tri-item .row { display: flex; gap: 12px; align-items: flex-start; } + .tri-item .row .tx { flex: 1; min-width: 0; } + /* 勾掉鈕:≥44px 圓形(手機好按),綠框空心=待勾;按下去由後端回寫才算數(誠實,不先假裝成功) */ + .tri-check { flex: none; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; font-size: 19px; line-height: 1; + border: 1.5px solid rgba(var(--ok-rgb),.55); background: rgba(var(--ok-rgb),.06); color: var(--ok); } + .tri-check:active { background: rgba(var(--ok-rgb),.22); } + .tri-check:disabled { opacity: .45; cursor: default; } + /* 還原鈕(誤勾救濟,只出現在「顯示已完成」清單):小顆但仍 44px 高好按 */ + .tri-restore { flex: none; min-height: 32px; padding: 5px 14px; border-radius: 999px; cursor: pointer; font-size: 13px; + border: 1px solid rgba(var(--amber-rgb),.5); background: none; color: var(--amber); } + .tri-restore:disabled { opacity: .45; cursor: default; } .tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); } .tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); } @@ -1267,13 +1281,21 @@ function renderConsoleHtml(registryBase: string): string { { key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' } ]; function triItem(m) { + var done = m.status === 'done'; var meta = ''; if (m.marker) meta += '' + esc(m.marker) + ''; meta += '' + esc(m.project || '未分項') + ''; if (m.source) meta += '' + esc(m.source) + ''; if (m.unclassified && m.origin === 'todo') meta += '未分流'; + // 誤勾救濟:done 清單裡給「還原」(status 回 new + 移除 checked_via/checked_at,端點同一個) + if (done) meta += ''; meta += '' + esc(fmtDateTime(m.at)) + ''; - return '
' + esc(m.text) + '
' + meta + '
'; + // 勾掉=雙向銷案的 console 端終局(checked_via:console,萃取端絕不復活); + // 後端回寫成功才改本地狀態移出三欄(done 預設隱藏),不樂觀假裝 + var body = done + ? '
' + esc(m.text) + '
' + : '
' + esc(m.text) + '
'; + return '
' + body + '
' + meta + '
'; } function triChip(label, val, on) { return ''; @@ -1315,6 +1337,34 @@ function renderConsoleHtml(registryBase: string): string { TG.project = t.getAttribute('data-triproj'); renderTriage(); }); + // 勾掉/還原:POST /console/triage-check(console session 保護的小端點——瀏覽器沒有 KBDB + // token,PATCH 由 server 端做,先 GET 原 entry 整串回寫防蓋掉別的欄位)。成功才動本地 + // 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。 + function triageAct(id, action, btn) { + btn.disabled = true; + fetch('/console/triage-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token }, + body: JSON.stringify({ entry_id: id, action: action }) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + if (!x.ok) { btn.disabled = false; toast(x.d.error || ('失敗(HTTP ' + x.status + ')')); return; } + var items = (TG.data && TG.data.items) || []; + for (var i = 0; i < items.length; i++) { + if (items[i].id === id) items[i].status = (action === 'restore' ? 'new' : 'done'); + } + renderTriage(); + toast(action === 'restore' ? '已還原' : '已勾掉'); + }) + .catch(function (e) { btn.disabled = false; toast('請求失敗:' + friendlyErr(e)); }); + } + $('tg-out').addEventListener('click', function (ev) { + var chk = ev.target.closest('[data-tricheck]'); + if (chk) { triageAct(chk.getAttribute('data-tricheck'), 'check', chk); return; } + var rst = ev.target.closest('[data-trirestore]'); + if (rst) triageAct(rst.getAttribute('data-trirestore'), 'restore', rst); + }); function loadTriage() { var out = $('tg-out'); out.innerHTML = '
載入中…
'; diff --git a/cypher-executor/tests/console-triage-check-route.test.ts b/cypher-executor/tests/console-triage-check-route.test.ts new file mode 100644 index 0000000..13e59f1 --- /dev/null +++ b/cypher-executor/tests/console-triage-check-route.test.ts @@ -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 帶完整 JSON(text/marker/owner_tier 等 + * 原樣保留 + status:done + checked_via:console + checked_at)——防 PATCH 蓋掉別的欄位 + * 3. 邊界:owner_id 非 console 租戶 → 404 不 PATCH;entry_type 非 todo/inbox → 400 不 PATCH + * 4. restore:status 回 new、checked_via/checked_at 移除 + * + * KBDB 打 fetchMock 假 host(wrangler.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 session(console-auth.ts 的 key 規約 console_sess:) + await env.SESSIONS_KV.put(`console_sess:${TOKEN}`, JSON.stringify({ created_at: Date.now() })); +}); + +function post(body: unknown, headers: Record = AUTH) { + return SELF.fetch('http://localhost/console/triage-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +/** mock GET /entries/:id(KBDB base 回應形狀 {success, entry}) */ +function mockGet(id: string, entry: Record) { + 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; + 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; + 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; + expect(res.status).toBe(200); + expect(data.status).toBe('new'); + + const content = JSON.parse((JSON.parse(patched) as { content: string }).content) as Record; + expect(content).toEqual({ text: '誤勾的', from: 'leo', status: 'new' }); + }); +}); diff --git a/cypher-executor/tests/console-triage-model.test.ts b/cypher-executor/tests/console-triage-model.test.ts index 6f8eeeb..c4807c2 100644 --- a/cypher-executor/tests/console-triage-model.test.ts +++ b/cypher-executor/tests/console-triage-model.test.ts @@ -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('check:status→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('check:inbox 契約 {"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('check:content 非 JSON(不合契約殘資料)→ 包成 {"text":原文},原文不丟', () => { + const after = JSON.parse(applyTriageCheck('裸字串待辦', 'check', NOW)); + expect(after).toEqual({ text: '裸字串待辦', status: 'done', checked_via: 'console', checked_at: NOW }); + }); + + it('check:已 done 的再勾=冪等(仍 done,checked_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('restore:status→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('restore:content 非 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'); + }); +}); diff --git a/cypher-executor/wrangler.test.toml b/cypher-executor/wrangler.test.toml index 0542d54..3fdaa34 100644 --- a/cypher-executor/wrangler.test.toml +++ b/cypher-executor/wrangler.test.toml @@ -24,6 +24,11 @@ id = "test-credentials-kv" binding = "RECIPES" id = "test-recipes" +# console session mock(分流台勾掉 route 測試;console-auth.ts 的 session 真身存這個 KV) +[[kv_namespaces]] +binding = "SESSIONS_KV" +id = "test-sessions-kv" + # credential-store-migration T8/T9 測試用 D1 mock(Miniflare 本地 SQLite,非真實 leo21c D1; # schema 由 tests/setup.ts 在測試啟動時建表,不用 migrations_dir——0002_credentials.sql 就一張表, # 直接 exec 比接 migrations 機制簡單) @@ -35,3 +40,6 @@ database_id = "test-credentials-db-id" [vars] ENVIRONMENT = "test" ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +# 分流台勾掉 route 測試:KBDB 指到假 host(fetchMock 攔截,絕不外連——尤其不打官方 uncle6 fallback) +KBDB_BASE_URL = "https://kbdb.test" +CONSOLE_TENANT = "leo"