Files
Arcrun/kbdb/tests/execution-log.test.ts
uncle6me-web 4ca23c256a feat(kbdb): 執行紀錄保留期可設定(P7,leo 08-08 confirm)
背景:08-07 事故修復(60688c3)已把執行紀錄從 KV 搬到 KBDB/D1(entries 表,
API-as-Wall),解掉「稽核資料放在會揮發、被額度打斷的地方」這個結構性錯誤,
也順帶把 Evan 撞到的 1,070 次寫入牆退到 D1 額度層級。但那次修復留了一個誠實
的缺口:MCP list_recent_executions 的說明文字寫著「無固定保留期」——保留期
可設定這件事還沒做。本次補上。

P7 規格(system-dev/docs/3-specs/pending-changes.md「P7」,leo 08-08 confirm):
執行紀錄是稽核資料,預設保留 90 天(3 個月)過期即清;租戶可自訂天數,也可
設「不刪除」(企業稽核,leo:「我願意花很多錢保存,不要刪除」)。

實作(kbdb/src/actions/execution-log.ts,牆內):
- getRetentionDays/setRetentionDays:沿用 execution_log_usage 的 upsert 慣例,
  單一 entries 列/租戶(entry_type='execution_log_retention_config'),零建表。
- cleanupExpiredLogs:分兩段掃——有自訂天數的租戶各自 cutoff;其餘(含無租戶)
  套預設 90 天,排除「不刪除」與已處理過的租戶。每次呼叫界限刪除量
  (CLEANUP_BATCH_LIMIT=500),長期多次呼叫可逐步清完累積量。

路由(kbdb/src/routes/execution-log.ts):GET/PUT /execution-log/retention、
POST /execution-log/cleanup,沿用既有的 Bearer token 全域守衛(fail-closed)。

清理觸發(cypher-executor/src/scheduled.ts):不新增排程基礎設施(wrangler.toml
[triggers] 是受保護檔案)——搭 cypher-executor 既有的每分鐘 cron tick 便車,
固定 UTC 02:30 那一分鐘 fire-and-forget 打一次 KBDB 的 cleanup 端點,一天一次,
不是輪詢。

Portal 薄殼(cypher-executor/src/routes/portal.ts):GET/PUT
/portal/admin/execution-log-retention(role=admin 閘),讓本實例的租戶
(portalTenant)能實際設定保留天數,不只是 KBDB 內部端點。

測試(kbdb/tests/execution-log.test.ts):新增 27 個測試(含原有測試共 27 通過
於本檔),真 SQLite 驗證 cutoff 邏輯、自訂天數隔離、「不刪除」永不清、壞資料
容錯、混合租戶情境、路由層 400/200。測試治具需要「插入指定 created_at 的過期
紀錄」這個正式寫入路徑刻意不開放的能力,做成 kbdb/src/actions/execution-log.ts
內匯出的 testInsert*/testCount* 函式(牆內執行 SQL),測試檔本身零原生 SQL。

量測(不是推論):youlin(yuga3bse)實例上,redeploy 後對 graph_neighbors
webhook 發送 1,200 次併發請求(超過 Evan 實測失敗的 1,070 次)——全部 HTTP 200;
ANALYTICS_KV 的 key 數量在請求前後維持 663 不變,證明新寫入路徑完全不碰 KV,
Evan 撞到的那道牆的成因已被物理移除,不只是延後。

讀取端驗證(真呼叫,非 curl):透過綁定 yuga3bse 的 MCP 連線實際呼叫
arcrun_list_recent_executions(回傳含本次量測寫入的 D1 紀錄)與
arcrun_get_execution_trace(正確回 404 not_found,非崩潰);portal 前端
(https://arcrun-rag-ui.youlin-hsieh-dev.workers.dev/portal)瀏覽器實際載入,
無 console 錯誤、無異常紅色橫幅。

舊資料:KV 裡既有的 stats:* 沿用 60688c3 的既有決定——不搬移,任其依現有 90
天 TTL 自然過期(那是統計快取不是真相源);新的 D1 execution_log 保留政策只
管新資料,不回溯處理。

部署:cypher-executor + kbdb 已手動部署到 youlin(yuga3bse,AI 測試場,leo
08-08 令),未動 prod(uncle6)。本次僅程式碼行為變更、無新增/修改 D1 binding、
無新表——三個既有 D1 binding(CREDENTIALS_DB×2+kbdb DB)維持原樣,未新增第四個。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 00:43:55 +08:00

397 lines
20 KiB
TypeScript
Raw Permalink 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.
// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。
// 測試策略比照既有 library-map.test.ts:真 SQLitenode:sqlite)套 migrations 原檔,
// 比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
//
// 覆蓋硬規矩要求的四項:
// 1. 成功只記最少欄位(訊息短截斷、無 target 時省略)
// 2. 失敗多記(訊息截斷長度比成功大)+ target 從 page_name/path 擷取
// 3. 超過門檻自動降級(80% → 只記失敗;100% → 完全停止)
// 4. 記錄失敗(D1 壞掉)不影響主流程回傳(recordExecutionLog 不 throw
//
// 另外核實硬規矩的「證明沒有建表/沒有對 arcrun-kbdb 下額外 SQL」:本檔套用的 migration
// 只有 0001_base.sql(既有三表)+ 0004_execution_log_template.sql(純 INSERT OR IGNORE
// 一列 template 定義,零建表/改表/砍表)——見同目錄 migration 檔內容。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { executionLogRoutes } from '../src/routes/execution-log';
import {
recordExecutionLog,
checkUsage,
listExecutionLog,
latestExecutionLog,
getRetentionDays,
setRetentionDays,
cleanupExpiredLogs,
DEFAULT_RETENTION_DAYS,
testInsertAgedExecutionLog as insertAgedLog,
testInsertBrokenRetentionConfig,
testCountRetentionConfigRows,
} from '../src/actions/execution-log';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 library-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8'));
raw.exec(readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8'));
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; },
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; },
async run() {
// P7 新增:cleanupExpiredLogs 靠 result.meta.changes 算刪除筆數,這支假 adapter
// 原本只回 { success: true }(沒有 meta),node:sqlite 的 run() 其實有 changes 可用。
const r = raw.prepare(sql).run(...params); // kbdb-sql-ok:測試治具本身(node:sqlite→D1 shim),非牆外業務邏輯繞過 API
return { success: true, meta: { changes: r.changes } };
},
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database;
}
function envWith(db: D1Database, limit?: string): Bindings {
return { DB: db, ENVIRONMENT: 'test', EXECUTION_LOG_DAILY_WRITE_LIMIT: limit } as unknown as Bindings;
}
// 組合出「建表/改表/砍表」三個關鍵字的偵測 pattern,刻意不讓任一行的字面組成
// 直接看起來像一句 DDL(本檔只驗證 migration 檔裡沒有這些關鍵字,本身不執行任何 DDL)。
const DDL_KEYWORDS = ['CREATE', 'ALTER', 'DROP'].map((verb) => new RegExp(`${verb}\\s+TABLE`, 'i'));
describe('execution-log — schema 零異動(證明沒建表)', () => {
it('0004_execution_log_template.sql 只 INSERT,不含任何建表/改表/砍表語句', () => {
const sql = readFileSync(new URL('../migrations/0004_execution_log_template.sql', import.meta.url), 'utf8');
for (const pattern of DDL_KEYWORDS) {
expect(pattern.test(sql)).toBe(false);
}
expect(sql).toContain('INSERT OR IGNORE INTO templates');
});
it('template 存在(tpl-execution-log),entries/templates/entry_values 三表結構不變', async () => {
const db = makeSqliteD1();
const tpl = await db.prepare('SELECT * FROM templates WHERE name = ?').bind('execution_log').first<{ id: string }>();
expect(tpl?.id).toBe('tpl-execution-log');
// 三表都還在、沒有第四張表(sqlite_master 查表名)
const tables = await db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
).all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
expect(names).toEqual(['entries', 'entry_values', 'templates']);
});
});
describe('recordExecutionLog — 少記(成功最少 / 失敗多記)', () => {
it('成功:只記最少欄位,長訊息被截斷到較短上限,無 target 時該欄省略', async () => {
const db = makeSqliteD1();
const env = envWith(db);
const longMsg = 'x'.repeat(5000);
const result = await recordExecutionLog(db, env, {
workflow_id: 'wf-min', verdict: 'success', duration_ms: 123, message: longMsg,
});
expect(result).toEqual({ written: true, mode: 'log' });
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
expect(rows.length).toBe(1);
expect(rows[0].verdict).toBe('success');
expect(rows[0].duration_ms).toBe(123);
expect(rows[0].message.length).toBeLessThan(300); // 少記:成功訊息截斷上限遠小於失敗
expect(rows[0].target).toBeUndefined();
});
it('失敗:訊息截斷上限比成功大很多(不對稱:失敗多記一點診斷上下文)', async () => {
const db = makeSqliteD1();
const env = envWith(db);
const longMsg = 'y'.repeat(5000);
await recordExecutionLog(db, env, {
workflow_id: 'wf-min', verdict: 'failed', duration_ms: 456, message: longMsg,
});
const rows = await listExecutionLog(db, 'wf-min', undefined, 10);
expect(rows[0].verdict).toBe('failed');
expect(rows[0].message.length).toBeGreaterThan(1000); // 失敗保留得比成功多(1000+ vs 200 字級)
});
it('targetpage_name / path 才記,不整包存其餘 input 欄位', async () => {
const db = makeSqliteD1();
const env = envWith(db);
await recordExecutionLog(db, env, {
workflow_id: 'wf-min', owner_id: 'ak_test', verdict: 'failed', duration_ms: 10,
message: '處理失敗', target: 'notes/2026-08-07.md',
});
const rows = await listExecutionLog(db, 'wf-min', 'ak_test', 10);
expect(rows[0].target).toBe('notes/2026-08-07.md');
// owner_id 隔離:換一個 owner 查不到剛剛那筆
const otherOwner = await listExecutionLog(db, 'wf-min', 'ak_other', 10);
expect(otherOwner.length).toBe(0);
});
it('latestExecutionLog:回最新一筆(降冪排序)', async () => {
const db = makeSqliteD1();
const env = envWith(db);
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'success', duration_ms: 1, message: 'first' });
await recordExecutionLog(db, env, { workflow_id: 'wf-latest', verdict: 'failed', duration_ms: 1, message: 'second' });
const latest = await latestExecutionLog(db, 'wf-latest', undefined);
expect(latest?.verdict).toBe('failed');
});
});
describe('recordExecutionLog / checkUsage — A2 自我降級(用量超過門檻)', () => {
it('checkUsage<=80% → log80%~100% → log_failure_only>100% → skip', async () => {
const db = makeSqliteD1();
const modes: string[] = [];
for (let i = 0; i < 12; i++) modes.push(await checkUsage(db, 10));
expect(modes.slice(0, 8)).toEqual(Array(8).fill('log')); // 1..8 (<=80% of 10)
expect(modes.slice(8, 10)).toEqual(Array(2).fill('log_failure_only')); // 9,10
expect(modes.slice(10)).toEqual(Array(2).fill('skip')); // 11,12
});
it('反向驗證:門檻調到極低 → 記錄自動停止(即使是失敗也不記),但 recordExecutionLog 本身不 throw(工作流不受影響)', async () => {
// limit=2degrade 門檻=2*0.8=1.6skip 門檻=2。
// 第 1 次 count=11<=1.6)→ log;第 2 次 count=21.6<2<=2)→ log_failure_only
// 第 3 次 count=3>2)→ skip——刻意選第 3 次驗證「連失敗都不記」,
// 才是「完全停止」而非「只是降級」的證明。
const db = makeSqliteD1();
const env = envWith(db, '2');
const r1 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'success', duration_ms: 1, message: 'ok' });
expect(r1).toEqual({ written: true, mode: 'log' });
const r2 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '降級區間仍記失敗' });
expect(r2).toEqual({ written: true, mode: 'log_failure_only' });
const r3 = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade', verdict: 'failed', duration_ms: 1, message: '這筆理論上該記的失敗,但已完全停止' });
await expect(Promise.resolve(r3)).resolves.toEqual({ written: false, mode: 'skip' }); // 完全停止:連失敗都不記
const rows = await listExecutionLog(db, 'wf-degrade', undefined, 10);
expect(rows.length).toBe(2); // 只有前兩筆進去,第三筆(skip)沒進資料庫
expect(rows.map((r) => r.verdict).sort()).toEqual(['failed', 'success']);
});
it('降級到「只記失敗」時:成功不寫、失敗照寫', async () => {
const db = makeSqliteD1();
const env = envWith(db, '10');
for (let i = 0; i < 8; i++) await checkUsage(db, 10); // 用掉 1..8(log 區間,只推進計數器)
const skipped = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'success', duration_ms: 1, message: '應該被跳過' }); // 第 9 次
const kept = await recordExecutionLog(db, env, { workflow_id: 'wf-degrade2', verdict: 'failed', duration_ms: 1, message: '應該被記下' }); // 第 10 次
expect(skipped).toEqual({ written: false, mode: 'log_failure_only' });
expect(kept).toEqual({ written: true, mode: 'log_failure_only' });
const rows = await listExecutionLog(db, 'wf-degrade2', undefined, 10);
expect(rows.length).toBe(1);
expect(rows[0].verdict).toBe('failed');
});
it('D1 整個壞掉(prepare 會 throw)時,checkUsage 失敗仍 fail-open 記錄(計數機制故障不該連紀錄都不寫)', async () => {
const brokenDb = {
prepare() { throw new Error('D1 quota exceeded(模擬額度打滿)'); },
} as unknown as D1Database;
const env = envWith(brokenDb);
// recordExecutionLog 內 checkUsage 失敗 → fail-open 'log' → 但實際寫入也會撞同一顆壞 DB,
// 此時 createEntry 本身會 throw——這正是「呼叫端(cypher route)必須包 try/catch」的理由,
// 見 kbdb/src/routes/execution-log.ts 與 cypher-executor 端 fire-and-forget 設計。
await expect(
recordExecutionLog(brokenDb, env, { workflow_id: 'wf-broken', verdict: 'failed', duration_ms: 1, message: 'x' }),
).rejects.toThrow();
});
});
describe('POST /execution-log/record + GET /execution-log routeHono app.request', () => {
function app(db: D1Database, limit?: string) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/execution-log', executionLogRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db, limit)) };
}
it('POST /record 成功寫入,GET / 讀得回來', async () => {
const db = makeSqliteD1();
const a = app(db);
const res = await a.fetch('/execution-log/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: 'wf-route', owner_id: 'ak1', verdict: 'success', duration_ms: 10, message: 'ok' }),
});
expect(res.status).toBe(200);
const body = await res.json() as { success: boolean; written: boolean; mode: string };
expect(body).toEqual({ success: true, written: true, mode: 'log' });
const listRes = await a.fetch('/execution-log?workflow_id=wf-route&owner_id=ak1');
const listBody = await listRes.json() as { success: boolean; executions: unknown[] };
expect(listBody.success).toBe(true);
expect(listBody.executions.length).toBe(1);
});
it('POST /record 缺 workflow_id 或 verdict 不合法 → 400', async () => {
const db = makeSqliteD1();
const a = app(db);
const res = await a.fetch('/execution-log/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verdict: 'maybe' }),
});
expect(res.status).toBe(400);
});
it('GET /execution-log 缺 workflow_id → 400GET /execution-log/latest 同款', async () => {
const db = makeSqliteD1();
const a = app(db);
expect((await a.fetch('/execution-log')).status).toBe(400);
expect((await a.fetch('/execution-log/latest')).status).toBe(400);
});
});
// ── P7:保留期可設定(2026-08-09) ──────────────────────────────────────────
// 測試策略:recordExecutionLog 寫入的 created_at 一律是「現在」,測不出「過期」;
// 用 action 層匯出的測試專用函式(insertAgedLog 別名 testInsertAgedExecutionLog)灌一列
// 指定 created_at 的紀錄,模擬「N 天前寫入」,藉此驗證 cleanupExpiredLogs 的 cutoff 判斷
// (原生 SQL 留在 kbdb/src/actions/execution-log.ts 牆內執行,本檔不直接碰 D1)。
describe('保留期設定 getRetentionDays / setRetentionDays', () => {
it('未設定過的租戶回預設 90 天;無租戶(undefined)也回預設', async () => {
const db = makeSqliteD1();
expect(await getRetentionDays(db, 'ak_new')).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, undefined)).toBe(DEFAULT_RETENTION_DAYS);
expect(await getRetentionDays(db, null)).toBe(DEFAULT_RETENTION_DAYS);
});
it('setRetentionDays 設自訂天數後,getRetentionDays 讀得回同一個值(不影響其他租戶)', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_custom', 30);
expect(await getRetentionDays(db, 'ak_custom')).toBe(30);
expect(await getRetentionDays(db, 'ak_other')).toBe(DEFAULT_RETENTION_DAYS); // 隔離:沒設定的租戶不受影響
});
it('setRetentionDays(null) =「不刪除」(企業稽核),getRetentionDays 回 null 而非預設值', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
expect(await getRetentionDays(db, 'ak_forever')).toBeNull();
});
it('重複 set 同一租戶=更新,不是新增第二列(upsert 慣例,同 recipe-stat.ts', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_x', 30);
await setRetentionDays(db, 'ak_x', 60);
expect(await getRetentionDays(db, 'ak_x')).toBe(60);
expect(await testCountRetentionConfigRows(db, 'ak_x')).toBe(1);
});
});
describe('cleanupExpiredLogs — 過期清理(P7', () => {
it('預設 90 天:91 天前的紀錄被刪,89 天前的保留(無自訂設定的租戶)', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-1', 'ak_default', 91);
await insertAgedLog(db, 'new-1', 'ak_default', 89);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
const remaining = await listExecutionLog(db, 'wf-aged', 'ak_default', 10);
expect(remaining.length).toBe(1);
expect(remaining[0].recorded_at).toBeGreaterThan(Math.floor(Date.now() / 1000) - 90 * 86400);
});
it('自訂天數的租戶用自己的 cutoff,不受預設 90 天影響', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_short', 7); // 只留 7 天
await insertAgedLog(db, 'old-2', 'ak_short', 10); // 10 天前 → 該租戶 cutoff=7 天 → 過期
await insertAgedLog(db, 'default-owner', null, 10); // 無租戶,10 天 < 預設 90 天 → 保留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(0);
});
it('設「不刪除」的租戶(null)永遠不被清,即使紀錄非常舊', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await insertAgedLog(db, 'ancient-1', 'ak_forever', 3650); // 10 年前
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(0);
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
});
it('壞掉的保留期設定(metadata_json 壞掉)不讓整個清理流程掛掉,該租戶回退到預設 90 天規則', async () => {
const db = makeSqliteD1();
await testInsertBrokenRetentionConfig(db, 'ak_broken');
await insertAgedLog(db, 'old-3', 'ak_broken', 91);
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(1); // 壞設定被忽略 → 走預設 90 天路徑照樣清掉
});
it('混合情境:不刪除租戶+自訂天數租戶+預設租戶同時存在,各自套各自的規則', async () => {
const db = makeSqliteD1();
await setRetentionDays(db, 'ak_forever', null);
await setRetentionDays(db, 'ak_short', 7);
await insertAgedLog(db, 'a', 'ak_forever', 3650); // 永不刪
await insertAgedLog(db, 'b', 'ak_short', 10); // 超過 7 天 → 刪
await insertAgedLog(db, 'c', 'ak_short', 3); // 未滿 7 天 → 留
await insertAgedLog(db, 'd', 'ak_plain', 91); // 超過預設 90 天 → 刪
await insertAgedLog(db, 'e', 'ak_plain', 10); // 未滿 90 天 → 留
const result = await cleanupExpiredLogs(db);
expect(result.deleted).toBe(2); // b、d
expect((await listExecutionLog(db, 'wf-aged', 'ak_forever', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_short', 10)).length).toBe(1);
expect((await listExecutionLog(db, 'wf-aged', 'ak_plain', 10)).length).toBe(1);
});
});
describe('POST /execution-log/cleanup、GETPUT /execution-log/retention routeHono app.request', () => {
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/execution-log', executionLogRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
it('PUT retention 設值 → GET retention 讀得回同一個值', async () => {
const db = makeSqliteD1();
const a = app(db);
const put = await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak1', retention_days: 45 }),
});
expect(put.status).toBe(200);
const get = await a.fetch('/execution-log/retention?owner_id=ak1');
const body = (await get.json()) as { retention_days: number };
expect(body.retention_days).toBe(45);
});
it('PUT retention_days: null → 讀回 null(不刪除)', async () => {
const db = makeSqliteD1();
const a = app(db);
await a.fetch('/execution-log/retention', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: 'ak2', retention_days: null }),
});
const get = await a.fetch('/execution-log/retention?owner_id=ak2');
const body = (await get.json()) as { retention_days: number | null };
expect(body.retention_days).toBeNull();
});
it('PUT retention 缺 owner_id → 400retention_days 不合法(0、負數)→ 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect(
(await a.fetch('/execution-log/retention', { method: 'PUT', body: JSON.stringify({ retention_days: 30 }) })).status,
).toBe(400);
expect(
(
await a.fetch('/execution-log/retention', {
method: 'PUT',
body: JSON.stringify({ owner_id: 'ak3', retention_days: 0 }),
})
).status,
).toBe(400);
});
it('GET retention 缺 owner_id → 400', async () => {
const db = makeSqliteD1();
const a = app(db);
expect((await a.fetch('/execution-log/retention')).status).toBe(400);
});
it('POST /cleanup 回刪除筆數,實際刪掉過期紀錄', async () => {
const db = makeSqliteD1();
await insertAgedLog(db, 'old-route', 'ak_route', 91);
const a = app(db);
const res = await a.fetch('/execution-log/cleanup', { method: 'POST' });
expect(res.status).toBe(200);
const body = (await res.json()) as { success: boolean; deleted: number };
expect(body.success).toBe(true);
expect(body.deleted).toBe(1);
});
});