/** * execution-logger 測試(KV 額度事故修復,2026-08-07) * * KBDB=API-as-Wall(leo 2026-06-14):cypher-executor 端不直連任何 D1,一律 fire-and-forget * fetch KBDB `/execution-log/record`。本檔驗的是「cypher 這一側」的職責,測試手法比照姊妹模組 * execution-evaluator.test.ts(recordComponentStats,同款「fire-and-forget POST 統計」): * `vi.stubGlobal('fetch', ...)` 直接攔截,不用 fetchMock。 * 1. 送出的 payload 形狀正確(workflow_id/owner_id/verdict/duration_ms/message/target) * 2. target 從 trigger context 的 page_name/path 擷取(少記:不整包送 input) * 3. 任何錯誤(fetch reject、KBDB 回非 2xx)都不影響呼叫端(永不 throw) * * 「少記截斷長度」「A2 自我降級」的實際邏輯與驗證在 KBDB 端(kbdb/tests/execution-log.test.ts), * 因為決策/儲存都搬到 KBDB 做了,cypher 只是薄殼呼叫端。 */ import { describe, it, expect, vi, afterEach } from 'vitest'; import { writeExecutionVerdict } from '../src/actions/execution-logger'; import type { Bindings } from '../src/types'; afterEach(() => vi.unstubAllGlobals()); function fakeEnv(): Bindings { return { KBDB_BASE_URL: 'https://kbdb.test', ENVIRONMENT: 'test', } as unknown as Bindings; } function stubFetchCapture(): { calls: Array<{ url: string; body: Record }> } { const calls: Array<{ url: string; body: Record }> = []; vi.stubGlobal('fetch', vi.fn(async (url: string, init: RequestInit) => { calls.push({ url: String(url), body: JSON.parse(String(init.body)) }); return new Response(JSON.stringify({ success: true, written: true, mode: 'log' }), { status: 200 }); })); return { calls }; } describe('writeExecutionVerdict — 送出正確 payload(少記,不整包 input)', () => { it('成功:POST 到 KBDB_BASE_URL/execution-log/record,帶 workflow_id/owner_id/verdict/duration_ms/message', async () => { const { calls } = stubFetchCapture(); await writeExecutionVerdict( fakeEnv(), 'wf-1', [], 'success', 123, '執行完成', { page_name: 'a.md' }, 'ak_test', ); expect(calls).toHaveLength(1); expect(calls[0].url).toBe('https://kbdb.test/execution-log/record'); expect(calls[0].body).toEqual({ workflow_id: 'wf-1', owner_id: 'ak_test', verdict: 'success', duration_ms: 123, message: '執行完成', target: 'a.md', }); }); it('target:page_name 優先,沒有時 fallback path;都沒有則為 null', async () => { const { calls: calls1 } = stubFetchCapture(); await writeExecutionVerdict(fakeEnv(), 'wf-2', [], 'failed', 1, 'err', { path: 'docs/x.md' }); expect(calls1[0].body.target).toBe('docs/x.md'); vi.unstubAllGlobals(); const { calls: calls2 } = stubFetchCapture(); await writeExecutionVerdict(fakeEnv(), 'wf-3', [], 'failed', 1, 'err', undefined); expect(calls2[0].body.target).toBeNull(); }); it('不整包送 input:巨大的無關欄位不會出現在送出的 payload 裡', async () => { const { calls } = stubFetchCapture(); await writeExecutionVerdict(fakeEnv(), 'wf-4', [], 'failed', 1, 'err', { page_name: 'a.md', unrelated_huge_field: 'z'.repeat(10000), }); expect(Object.keys(calls[0].body).sort()).toEqual( ['duration_ms', 'message', 'owner_id', 'target', 'verdict', 'workflow_id'], ); }); it('沒有 apiKey(/execute 舊路徑):owner_id 送 null,不炸', async () => { const { calls } = stubFetchCapture(); await writeExecutionVerdict(fakeEnv(), 'wf-5', [], 'success', 1, 'ok'); expect(calls[0].body.owner_id).toBeNull(); }); }); describe('writeExecutionVerdict — 記錄失敗不影響主流程(永不 throw)', () => { it('KBDB 端點連不上(fetch reject):函式仍正常 resolve', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('network down'); })); await expect( writeExecutionVerdict(fakeEnv(), 'wf-broken', [], 'failed', 1, '任何訊息'), ).resolves.toBeUndefined(); }); it('KBDB 回非 2xx(例如額度打滿的 5xx):函式仍正常 resolve', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ success: false, error: 'quota exceeded' }), { status: 500 }), )); await expect( writeExecutionVerdict(fakeEnv(), 'wf-broken2', [], 'failed', 1, '任何訊息'), ).resolves.toBeUndefined(); }); });