/** * v0.8.0 P2-1: 会话回收站(软删除 30 天保留)回归测试。 * * 钉住契约: * - delete → 软删除(deleted_at 时间戳),list/getSession 不再返回,listDeleted 返回; * - restore → 恢复到主列表; * - purge → 硬删除(不可恢复); * - purgeExpiredTrash(30d) → 仅清理超期条目; * - searchMessages 聚合剔除回收站会话。 * * 运行要求:better-sqlite3 为 Electron ABI 构建(test:electron),系统 Node 下自动跳过。 */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); let dbAvailable = true; let Database: typeof import('better-sqlite3'); try { // eslint-disable-next-line @typescript-eslint/no-require-imports Database = require('better-sqlite3'); const probe = new Database(':memory:'); probe.close(); } catch { dbAvailable = false; } describe.skipIf(!dbAvailable)('会话回收站(v0.8.0 P2-1)', () => { let sessionService: any; let db: InstanceType; beforeAll(async () => { const { SessionService } = await import('../session.service'); db = new Database(':memory:'); db.pragma('foreign_keys = ON'); // 最小 schema(与 DatabaseService.createTables 对齐的关键列:deleted_at + 级联删除) db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, deleted_at INTEGER, metadata TEXT DEFAULT '{}' ); CREATE TABLE messages ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT, reasoning_content TEXT, tool_calls TEXT, tool_result TEXT, attachments TEXT, iteration INTEGER, created_at INTEGER NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); CREATE TABLE session_summaries ( session_id TEXT PRIMARY KEY, summary TEXT NOT NULL, summarized_until_rowid INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE ); `); sessionService = new SessionService(() => db); }); afterAll(() => { try { db.close(); } catch { /* ignore */ } }); it('delete → 软删除:list 不返回、listDeleted 返回、getSession 排除', () => { const a = sessionService.create('A'); const b = sessionService.create('B'); expect(sessionService.delete(a.id)).toBe(true); const list = sessionService.list().map((s: { id: string }) => s.id); expect(list).toContain(b.id); expect(list).not.toContain(a.id); const trash = sessionService.listDeleted().map((s: { id: string }) => s.id); expect(trash).toEqual([a.id]); expect(sessionService.getSession(a.id)).toBeNull(); expect(sessionService.getSession(b.id)).not.toBeNull(); }); it('restore → 恢复到主列表', () => { const a = sessionService.listDeleted()[0]; expect(sessionService.restore(a.id)).toBe(true); expect(sessionService.list().map((s: { id: string }) => s.id)).toContain(a.id); expect(sessionService.listDeleted()).toHaveLength(0); }); it('purge → 硬删除(仅对回收站条目生效)', () => { const s = sessionService.create('temp'); sessionService.delete(s.id); sessionService.saveMessage({ sessionId: s.id, role: 'user', content: 'x' }); expect(sessionService.purge(s.id)).toBe(true); const count = db .prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id = ?') .get(s.id) as { c: number; }; expect(count.c).toBe(0); // 未删除的会话不可 purge(幂等保护) const live = sessionService.create('live'); expect(sessionService.purge(live.id)).toBe(false); }); it('purgeExpiredTrash(30d) → 仅清理超期条目', () => { const old = sessionService.create('old'); sessionService.delete(old.id); db.prepare('UPDATE sessions SET deleted_at = ? WHERE id = ?').run( Date.now() - 31 * 24 * 60 * 60 * 1000, old.id, ); const fresh = sessionService.create('fresh'); sessionService.delete(fresh.id); // deleted_at = now const purged = sessionService.purgeExpiredTrash(30); expect(purged).toBe(1); const remaining = db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id = ?').get(old.id) as { c: number; }; expect(remaining.c).toBe(0); expect(sessionService.listDeleted().map((s: { id: string }) => s.id)).toContain(fresh.id); }); it('searchMessages 聚合剔除回收站会话(≤2 字符 LIKE 回退路径)', () => { const s1 = sessionService.create('搜索目标会话'); sessionService.saveMessage({ sessionId: s1.id, role: 'user', content: '量子计算机的独特内容' }); // '量子' ≤2 字符走 LIKE 回退(最小 schema 无 FTS 表,与生产 trigram 路径 // 共用同一个 aggregateSearchResults 聚合出口,剔除逻辑同源) const r1 = sessionService.searchMessages('量子'); expect(r1.length).toBe(1); sessionService.delete(s1.id); const r2 = sessionService.searchMessages('量子'); expect(r2.length).toBe(0); }); });