#!/usr/bin/env node /** * test/unit-logger-db.mjs * * 单元测试:logger-db.ts 的 SQLite 接口功能验证 * 运行方式:node test/unit-logger-db.mjs * * 测试内容: * 1. initDb - 初始化创建表和索引 * 2. dbInsertRequest - 写入记录 * 3. dbGetPayload - 按需读取 payload * 4. dbGetSummaries - 游标分页查询 * 5. dbGetSummaryCount - 总数统计 * 6. dbGetSummariesSince - 按时间范围加载(启动恢复) * 7. dbClear - 清空 * 8. 分页边界:before 游标正确性 * 9. INSERT OR REPLACE 幂等性 */ import Database from 'better-sqlite3'; import { existsSync, unlinkSync, mkdirSync } from 'fs'; import { dirname } from 'path'; // ==================== 测试框架 ==================== let passed = 0, failed = 0; const errors = []; function assert(condition, msg) { if (condition) { passed++; console.log(` ✓ ${msg}`); } else { failed++; const err = ` ✗ ${msg}`; errors.push(err); console.error(err); } } function assertEq(actual, expected, msg) { const ok = JSON.stringify(actual) === JSON.stringify(expected); if (!ok) { console.error(` actual: ${JSON.stringify(actual)}`); console.error(` expected: ${JSON.stringify(expected)}`); } assert(ok, msg); } // ==================== 内联实现(与 src/logger-db.ts 保持同步)==================== // 使用相同逻辑直接操作 better-sqlite3,不依赖 dist/ const TEST_DB_PATH = '/tmp/cursor2api-test.db'; // 清理旧测试数据库 if (existsSync(TEST_DB_PATH)) unlinkSync(TEST_DB_PATH); let db; function initDb(dbPath) { const dir = dirname(dbPath); if (dir && dir !== '.' && !existsSync(dir)) mkdirSync(dir, { recursive: true }); db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); db.exec(` CREATE TABLE IF NOT EXISTS requests ( request_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, summary_json TEXT NOT NULL, payload_json TEXT ); CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp); `); } function dbInsertRequest(summary, payload) { db.prepare( 'INSERT OR REPLACE INTO requests (request_id, timestamp, summary_json, payload_json) VALUES (?, ?, ?, ?)' ).run(summary.requestId, summary.startTime, JSON.stringify(summary), JSON.stringify(payload)); } function dbGetPayload(requestId) { const row = db.prepare('SELECT payload_json FROM requests WHERE request_id = ?').get(requestId); if (!row?.payload_json) return undefined; try { return JSON.parse(row.payload_json); } catch { return undefined; } } function dbGetSummaries({ limit, before }) { let rows; if (before !== undefined) { rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp < ? ORDER BY timestamp DESC LIMIT ?').all(before, limit); } else { rows = db.prepare('SELECT summary_json FROM requests ORDER BY timestamp DESC LIMIT ?').all(limit); } return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean); } function dbGetSummaryCount() { return db.prepare('SELECT COUNT(*) as cnt FROM requests').get().cnt; } function dbGetSummariesSince(cutoff) { const rows = db.prepare('SELECT summary_json FROM requests WHERE timestamp >= ? ORDER BY timestamp ASC').all(cutoff); return rows.map(r => { try { return JSON.parse(r.summary_json); } catch { return null; } }).filter(Boolean); } function dbClear() { db.prepare('DELETE FROM requests').run(); } // ==================== 测试数据 ==================== function makeSummary(id, startTime, extra = {}) { return { requestId: id, startTime, endTime: startTime + 1000, method: 'POST', path: '/v1/messages', model: 'claude-sonnet-4-6', stream: true, apiFormat: 'anthropic', hasTools: false, toolCount: 0, messageCount: 3, status: 'success', responseChars: 500, retryCount: 0, continuationCount: 0, toolCallsDetected: 0, thinkingChars: 0, systemPromptLength: 100, phaseTimings: [], title: `测试请求 ${id}`, ...extra, }; } function makePayload(id) { return { question: `用户问题 ${id}`, answer: `模型回答 ${id}`, answerType: 'text', }; } // 时间基准(各记录间隔 1 秒) const BASE_TS = Date.now() - 10000; const records = [ { summary: makeSummary('req-001', BASE_TS + 1000), payload: makePayload('req-001') }, { summary: makeSummary('req-002', BASE_TS + 2000), payload: makePayload('req-002') }, { summary: makeSummary('req-003', BASE_TS + 3000), payload: makePayload('req-003') }, { summary: makeSummary('req-004', BASE_TS + 4000, { status: 'error', error: '超时' }), payload: makePayload('req-004') }, { summary: makeSummary('req-005', BASE_TS + 5000), payload: makePayload('req-005') }, ]; // ==================== 开始测试 ==================== console.log('=== unit-logger-db: SQLite 接口功能测试 ===\n'); // --- 1. initDb --- console.log('【1】initDb'); try { initDb(TEST_DB_PATH); assert(existsSync(TEST_DB_PATH), '数据库文件已创建'); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name); assert(tables.includes('requests'), '表 requests 已创建'); const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all().map(r => r.name); assert(indexes.includes('idx_timestamp'), '索引 idx_timestamp 已创建'); } catch (e) { assert(false, `initDb 抛出异常: ${e.message}`); } // --- 2. dbInsertRequest --- console.log('\n【2】dbInsertRequest'); for (const { summary, payload } of records) { dbInsertRequest(summary, payload); } assertEq(dbGetSummaryCount(), 5, '插入 5 条后总数为 5'); // --- 3. dbGetPayload --- console.log('\n【3】dbGetPayload'); const p2 = dbGetPayload('req-002'); assert(p2 !== undefined, 'req-002 payload 可读取'); assertEq(p2.question, '用户问题 req-002', 'payload.question 正确'); assertEq(p2.answer, '模型回答 req-002', 'payload.answer 正确'); assert(dbGetPayload('req-999') === undefined, '不存在的 requestId 返回 undefined'); // --- 4. dbGetSummaries 无游标(最新在前)--- console.log('\n【4】dbGetSummaries(无游标)'); const all = dbGetSummaries({ limit: 10 }); assertEq(all.length, 5, '返回全部 5 条'); assertEq(all[0].requestId, 'req-005', '第一条是最新的 req-005'); assertEq(all[4].requestId, 'req-001', '最后一条是最旧的 req-001'); // --- 5. dbGetSummaries limit --- console.log('\n【5】dbGetSummaries(limit=3)'); const top3 = dbGetSummaries({ limit: 3 }); assertEq(top3.length, 3, '返回 3 条'); assertEq(top3[0].requestId, 'req-005', '第一条是 req-005'); assertEq(top3[2].requestId, 'req-003', '第三条是 req-003'); // --- 6. dbGetSummaries before 游标翻页 --- console.log('\n【6】dbGetSummaries(游标分页)'); // 第一页:最新 3 条(req-005, req-004, req-003) const page1 = dbGetSummaries({ limit: 3 }); assertEq(page1.length, 3, '第一页 3 条'); assertEq(page1[0].requestId, 'req-005', '第一页第一条 req-005'); // 第二页:before = page1 最后一条的 timestamp const beforeTs = page1[page1.length - 1].startTime; const page2 = dbGetSummaries({ limit: 3, before: beforeTs }); assertEq(page2.length, 2, '第二页 2 条(剩余 req-002, req-001)'); assertEq(page2[0].requestId, 'req-002', '第二页第一条 req-002'); assertEq(page2[1].requestId, 'req-001', '第二页第二条 req-001'); // --- 7. dbGetSummaryCount --- console.log('\n【7】dbGetSummaryCount'); assertEq(dbGetSummaryCount(), 5, '总数为 5'); // --- 8. dbGetSummariesSince(启动时加载)--- console.log('\n【8】dbGetSummariesSince'); // 只取 timestamp >= BASE_TS + 3000 的记录(req-003, req-004, req-005) const since = dbGetSummariesSince(BASE_TS + 3000); assertEq(since.length, 3, 'since 返回 3 条'); assertEq(since[0].requestId, 'req-003', '第一条 req-003(ASC 顺序)'); assertEq(since[2].requestId, 'req-005', '最后一条 req-005'); // cutoff 比所有记录都新 → 返回空 const sinceEmpty = dbGetSummariesSince(Date.now() + 99999); assertEq(sinceEmpty.length, 0, '未来 cutoff 返回空数组'); // --- 9. INSERT OR REPLACE 幂等性 --- console.log('\n【9】INSERT OR REPLACE 幂等性'); const updatedSummary = { ...records[0].summary, status: 'error', title: '已更新' }; dbInsertRequest(updatedSummary, records[0].payload); assertEq(dbGetSummaryCount(), 5, '重复插入后总数不变(仍 5 条)'); const allAfter = dbGetSummaries({ limit: 10 }); const updated = allAfter.find(s => s.requestId === 'req-001'); assertEq(updated?.title, '已更新', 'REPLACE 更新了 summary 内容'); // --- 10. dbClear --- console.log('\n【10】dbClear'); dbClear(); assertEq(dbGetSummaryCount(), 0, '清空后总数为 0'); const afterClear = dbGetSummaries({ limit: 10 }); assertEq(afterClear.length, 0, '清空后查询返回空数组'); assert(dbGetPayload('req-001') === undefined, '清空后 payload 也不可读取'); // ==================== 结果 ==================== console.log(`\n${'='.repeat(40)}`); console.log(`测试结果: ${passed} 通过 / ${failed} 失败`); if (errors.length > 0) { console.error('\n失败项:'); for (const e of errors) console.error(e); } // 清理 db.close(); try { unlinkSync(TEST_DB_PATH); } catch { /* ignore */ } process.exit(failed > 0 ? 1 : 0);