ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
402 lines
21 KiB
TypeScript
402 lines
21 KiB
TypeScript
// execution-log — KV 額度事故修復(總管交辦,2026-08-07)測試。
|
||
// 測試策略比照既有 library-map.test.ts:真 SQLite(node: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'));
|
||
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
|
||
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');
|
||
});
|
||
|
||
// 0007(樹狀 record 模型,v7 定稿 2026-08-15 confirm)後的正解表清單:entry_values 已拆
|
||
// ——它是「關係」的第二套實作(D92),關係列改住 entries 的指標欄。這條斷言在改版前
|
||
// 釘的是「三張核心表」,規格層變更(pending-changes.md「record 要有身分」)把答案改掉,
|
||
// 不是實作去配合測試。
|
||
it('template 存在(tpl-execution-log),核心表只剩 entries/templates(entry_values 已拆,0007)', 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', '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('target:page_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% → log;80%~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=2:degrade 門檻=2*0.8=1.6,skip 門檻=2。
|
||
// 第 1 次 count=1(1<=1.6)→ log;第 2 次 count=2(1.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 route(Hono 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 → 400;GET /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、GET/PUT /execution-log/retention route(Hono 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 → 400;retention_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);
|
||
});
|
||
});
|