import { describe, it, expect, beforeEach, vi } from 'vitest'; // Mock fs/promises vi.mock('fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue('{}'), readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), stat: vi.fn().mockResolvedValue({ isDirectory: () => false }), })); // Mock os vi.mock('os', () => ({ homedir: vi.fn(() => '/home/testuser'), })); import { SessionStorage } from '../../../src/session/storage.js'; import * as fs from 'fs/promises'; describe('SessionStorage - 会话存储', () => { let storage: SessionStorage; beforeEach(() => { vi.clearAllMocks(); storage = new SessionStorage('/test/storage'); }); describe('构造函数', () => { it('使用提供的存储目录', () => { const s = new SessionStorage('/custom/path'); expect(s.getStorageDir()).toBe('/custom/path'); }); it('默认使用 XDG 规范路径', () => { const originalEnv = process.env.XDG_DATA_HOME; process.env.XDG_DATA_HOME = '/xdg/data'; const s = new SessionStorage(); expect(s.getStorageDir()).toBe('/xdg/data/ai-assist'); process.env.XDG_DATA_HOME = originalEnv; }); it('无 XDG 环境变量使用 home 目录', () => { const originalEnv = process.env.XDG_DATA_HOME; delete process.env.XDG_DATA_HOME; const s = new SessionStorage(); expect(s.getStorageDir()).toContain('.local/share/ai-assist'); process.env.XDG_DATA_HOME = originalEnv; }); }); describe('generateSessionId - 生成会话 ID', () => { it('生成包含日期的会话 ID', () => { const id = storage.generateSessionId(); // 格式: YYYY-MM-DD_xxxxxx expect(id).toMatch(/^\d{4}-\d{2}-\d{2}_[a-z0-9]{6}$/); }); it('生成唯一的会话 ID', () => { const ids = new Set(); for (let i = 0; i < 100; i++) { ids.add(storage.generateSessionId()); } expect(ids.size).toBe(100); }); }); describe('ensureDir - 确保目录存在', () => { it('创建会话目录', async () => { await storage.ensureDir(); expect(fs.mkdir).toHaveBeenCalledWith( expect.stringContaining('sessions'), { recursive: true } ); }); }); describe('saveCurrentSession - 保存当前会话', () => { it('保存会话数据', async () => { const session = { id: 'test-session', workdir: '/test', messages: [{ role: 'user', content: 'hello' }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; await storage.saveCurrentSession(session as any); expect(fs.mkdir).toHaveBeenCalled(); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('current-session.json'), expect.any(String), 'utf-8' ); }); it('更新 updatedAt 时间戳', async () => { const session = { id: 'test-session', workdir: '/test', messages: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; await storage.saveCurrentSession(session as any); const writeCall = vi.mocked(fs.writeFile).mock.calls[0]; const savedData = JSON.parse(writeCall[1] as string); expect(new Date(savedData.updatedAt).getTime()).toBeGreaterThan( new Date('2024-01-01T00:00:00Z').getTime() ); }); }); describe('loadCurrentSession - 加载当前会话', () => { it('成功加载会话', async () => { const sessionData = { id: 'test-session', workdir: '/test', messages: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData)); const session = await storage.loadCurrentSession(); expect(session).toEqual(sessionData); }); it('文件不存在返回 null', async () => { vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); const session = await storage.loadCurrentSession(); expect(session).toBeNull(); }); }); describe('archiveCurrentSession - 归档当前会话', () => { it('归档有消息的会话', async () => { const sessionData = { id: 'test-session', workdir: '/test', messages: [{ role: 'user', content: 'test' }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData)); await storage.archiveCurrentSession(); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('test-session.json'), expect.any(String), 'utf-8' ); }); it('空会话不归档', async () => { const sessionData = { id: 'test-session', workdir: '/test', messages: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData)); await storage.archiveCurrentSession(); // writeFile 不应该被调用(只有 ensureDir 的 mkdir) expect(fs.writeFile).not.toHaveBeenCalled(); }); it('无当前会话不操作', async () => { vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); await storage.archiveCurrentSession(); expect(fs.writeFile).not.toHaveBeenCalled(); }); }); describe('clearCurrentSession - 清除当前会话', () => { it('删除当前会话文件', async () => { await storage.clearCurrentSession(); expect(fs.unlink).toHaveBeenCalledWith( expect.stringContaining('current-session.json') ); }); it('文件不存在不报错', async () => { vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT')); await expect(storage.clearCurrentSession()).resolves.not.toThrow(); }); }); describe('listSessions - 列出历史会话', () => { it('返回会话摘要列表', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json', 'session2.json'] as any); const session1 = { id: 'session1', workdir: '/test1', messages: [{ role: 'user', content: '第一条消息' }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }; const session2 = { id: 'session2', workdir: '/test2', messages: [], createdAt: '2024-01-03T00:00:00Z', updatedAt: '2024-01-04T00:00:00Z', }; vi.mocked(fs.readFile) .mockResolvedValueOnce(JSON.stringify(session1)) .mockResolvedValueOnce(JSON.stringify(session2)); const sessions = await storage.listSessions(); expect(sessions).toHaveLength(2); // 按更新时间降序 expect(sessions[0].id).toBe('session2'); expect(sessions[1].id).toBe('session1'); }); it('生成会话标题', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any); const session = { id: 'session1', workdir: '/test', messages: [{ role: 'user', content: '这是第一条用户消息' }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session)); const sessions = await storage.listSessions(); expect(sessions[0].title).toBe('这是第一条用户消息'); }); it('长标题截断', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any); const longContent = 'a'.repeat(100); const session = { id: 'session1', workdir: '/test', messages: [{ role: 'user', content: longContent }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session)); const sessions = await storage.listSessions(); expect(sessions[0].title.length).toBeLessThanOrEqual(53); // 50 + '...' }); it('跳过非 JSON 文件', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session.json', 'readme.txt'] as any); const session = { id: 'session', workdir: '/test', messages: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(session)); const sessions = await storage.listSessions(); expect(sessions).toHaveLength(1); }); it('跳过无法解析的文件', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session.json'] as any); vi.mocked(fs.readFile).mockResolvedValueOnce('invalid json'); const sessions = await storage.listSessions(); expect(sessions).toHaveLength(0); }); }); describe('loadSession - 加载指定会话', () => { it('成功加载会话', async () => { const sessionData = { id: 'session-123', workdir: '/test', messages: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify(sessionData)); const session = await storage.loadSession('session-123'); expect(session).toEqual(sessionData); expect(fs.readFile).toHaveBeenCalledWith( expect.stringContaining('session-123.json'), 'utf-8' ); }); it('会话不存在返回 null', async () => { vi.mocked(fs.readFile).mockRejectedValueOnce(new Error('ENOENT')); const session = await storage.loadSession('nonexistent'); expect(session).toBeNull(); }); }); describe('saveSession - 保存指定会话', () => { it('保存会话到文件', async () => { const session = { id: 'child-session', workdir: '/test', messages: [{ role: 'assistant', content: 'response' }], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; await storage.saveSession(session as any); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('child-session.json'), expect.any(String), 'utf-8' ); }); }); describe('deleteSession - 删除会话', () => { it('成功删除返回 true', async () => { const result = await storage.deleteSession('session-123'); expect(result).toBe(true); expect(fs.unlink).toHaveBeenCalledWith( expect.stringContaining('session-123.json') ); }); it('删除失败返回 false', async () => { vi.mocked(fs.unlink).mockRejectedValueOnce(new Error('ENOENT')); const result = await storage.deleteSession('nonexistent'); expect(result).toBe(false); }); }); describe('cleanupOldSessions - 清理旧会话', () => { it('删除超出保留数量的会话', async () => { // Mock listSessions 返回 3 个会话 vi.mocked(fs.readdir).mockResolvedValueOnce([ 'session1.json', 'session2.json', 'session3.json', ] as any); const sessions = [ { id: 'session3', updatedAt: '2024-01-03' }, { id: 'session2', updatedAt: '2024-01-02' }, { id: 'session1', updatedAt: '2024-01-01' }, ]; vi.mocked(fs.readFile) .mockResolvedValueOnce(JSON.stringify({ ...sessions[0], messages: [], workdir: '/', createdAt: '2024-01-01' })) .mockResolvedValueOnce(JSON.stringify({ ...sessions[1], messages: [], workdir: '/', createdAt: '2024-01-01' })) .mockResolvedValueOnce(JSON.stringify({ ...sessions[2], messages: [], workdir: '/', createdAt: '2024-01-01' })); const deletedCount = await storage.cleanupOldSessions(2); expect(deletedCount).toBe(1); expect(fs.unlink).toHaveBeenCalledWith( expect.stringContaining('session1.json') ); }); it('会话数量不超过保留数量不删除', async () => { vi.mocked(fs.readdir).mockResolvedValueOnce(['session1.json'] as any); vi.mocked(fs.readFile).mockResolvedValueOnce(JSON.stringify({ id: 'session1', messages: [], workdir: '/', createdAt: '2024-01-01', updatedAt: '2024-01-01', })); const deletedCount = await storage.cleanupOldSessions(5); expect(deletedCount).toBe(0); expect(fs.unlink).not.toHaveBeenCalled(); }); }); });