From 5835799b69b80450b87172939e134b81ace9d611 Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 15 Dec 2025 00:07:32 +0800 Subject: [PATCH] =?UTF-8?q?test(server):=20=E6=B7=BB=E5=8A=A0=20server=20?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 vitest 配置和测试基础设施 - 添加 adapter.test.ts: 测试 Core 模块初始化和 Agent 管理 (18 tests) - 添加 token.test.ts: 测试 Token 生成、验证和中间件 (25 tests) - 添加 handler.test.ts: 测试权限处理器 (18 tests) - 添加 ws.test.ts: 测试 WebSocket 连接和消息处理 (19 tests) - 添加 sse.test.ts: 测试 SSE 事件发送 (14 tests) - 添加 sessions.test.ts: 测试会话路由 (16 tests) - 添加 config.test.ts: 测试配置路由 (10 tests) - 添加 context.test.ts: 测试上下文压缩路由 (9 tests) - 添加 providers.test.ts: 测试 Provider 管理路由 (18 tests) - 添加 manager.test.ts: 测试 SessionManager (48 tests) 总计 195 个测试,覆盖率从 0% 提升至 29.59% --- packages/server/package.json | 1 + packages/server/tests/mocks/core.mock.ts | 167 +++++++ packages/server/tests/mocks/hono.mock.ts | 98 ++++ packages/server/tests/mocks/session.mock.ts | 152 ++++++ packages/server/tests/setup.ts | 23 + .../server/tests/unit/agent/adapter.test.ts | 341 ++++++++++++++ packages/server/tests/unit/auth/token.test.ts | 223 +++++++++ .../tests/unit/permission/handler.test.ts | 363 ++++++++++++++ .../server/tests/unit/routes/config.test.ts | 143 ++++++ .../server/tests/unit/routes/context.test.ts | 184 ++++++++ .../tests/unit/routes/providers.test.ts | 330 +++++++++++++ .../server/tests/unit/routes/sessions.test.ts | 280 +++++++++++ .../server/tests/unit/session/manager.test.ts | 445 ++++++++++++++++++ packages/server/tests/unit/sse.test.ts | 141 ++++++ packages/server/tests/unit/ws.test.ts | 346 ++++++++++++++ packages/server/vitest.config.ts | 18 + pnpm-lock.yaml | 6 +- 17 files changed, 3258 insertions(+), 3 deletions(-) create mode 100644 packages/server/tests/mocks/core.mock.ts create mode 100644 packages/server/tests/mocks/hono.mock.ts create mode 100644 packages/server/tests/mocks/session.mock.ts create mode 100644 packages/server/tests/setup.ts create mode 100644 packages/server/tests/unit/agent/adapter.test.ts create mode 100644 packages/server/tests/unit/auth/token.test.ts create mode 100644 packages/server/tests/unit/permission/handler.test.ts create mode 100644 packages/server/tests/unit/routes/config.test.ts create mode 100644 packages/server/tests/unit/routes/context.test.ts create mode 100644 packages/server/tests/unit/routes/providers.test.ts create mode 100644 packages/server/tests/unit/routes/sessions.test.ts create mode 100644 packages/server/tests/unit/session/manager.test.ts create mode 100644 packages/server/tests/unit/sse.test.ts create mode 100644 packages/server/tests/unit/ws.test.ts create mode 100644 packages/server/vitest.config.ts diff --git a/packages/server/package.json b/packages/server/package.json index b866dbd..a3a5ae6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -29,6 +29,7 @@ "@types/bun": "^1.1.0", "@types/node": "^22.0.0", "@types/uuid": "^10.0.0", + "@vitest/coverage-v8": "^4.0.15", "typescript": "^5.6.0", "vitest": "^4.0.15" } diff --git a/packages/server/tests/mocks/core.mock.ts b/packages/server/tests/mocks/core.mock.ts new file mode 100644 index 0000000..99fbbf4 --- /dev/null +++ b/packages/server/tests/mocks/core.mock.ts @@ -0,0 +1,167 @@ +/** + * Core 模块 Mock 工厂 + * + * 提供 @ai-assistant/core 模块的 mock 实现 + */ + +import { vi } from 'vitest'; + +/** + * 创建 Mock Agent 实例 + */ +export function createMockAgent() { + return { + setRegistry: vi.fn(), + chat: vi.fn().mockResolvedValue('mock response'), + getToolCount: vi.fn().mockReturnValue({ core: 5, discovered: 0, total: 5 }), + getContextUsageFormatted: vi.fn().mockReturnValue('10k / 200k'), + getContextUsage: vi.fn().mockReturnValue({ + input: 10000, + contextLimit: 200000, + available: 190000, + usagePercent: 5, + }), + compactHistory: vi.fn().mockResolvedValue({ freedTokens: 1000, type: 'prune' }), + getCompressionManager: vi.fn().mockReturnValue({ + shouldCompress: vi.fn().mockReturnValue(false), + }), + }; +} + +/** + * 创建 Mock ProviderRegistry + */ +export function createMockProviderRegistry(overrides: Partial> = {}) { + return { + init: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockReturnValue(false), + list: vi.fn().mockReturnValue([]), + listForApi: vi.fn().mockReturnValue([]), + get: vi.fn(), + getInfo: vi.fn(), + getDetail: vi.fn(), + has: vi.fn().mockReturnValue(false), + getConfig: vi.fn(), + setConfig: vi.fn(), + getAllConfigs: vi.fn().mockReturnValue({}), + getModels: vi.fn().mockReturnValue([]), + getModelInfo: vi.fn(), + addCustomModel: vi.fn(), + removeCustomModel: vi.fn().mockReturnValue(false), + testConnection: vi.fn().mockResolvedValue({ success: true }), + getModelFactory: vi.fn().mockReturnValue(() => ({})), + saveConfig: vi.fn().mockResolvedValue(undefined), + reloadConfig: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +/** + * 创建 Mock AgentRegistry + */ +export function createMockAgentRegistry(overrides: Partial> = {}) { + return { + init: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockReturnValue(false), + get: vi.fn(), + list: vi.fn().mockReturnValue([]), + listSubagents: vi.fn().mockReturnValue([]), + listPrimaryAgents: vi.fn().mockReturnValue([]), + getInternal: vi.fn(), + listInternalAgents: vi.fn().mockReturnValue([]), + register: vi.fn(), + remove: vi.fn().mockReturnValue(false), + has: vi.fn().mockReturnValue(false), + size: 0, + getNames: vi.fn().mockReturnValue([]), + getGlobalConfig: vi.fn().mockReturnValue(null), + generateSubagentDescription: vi.fn().mockReturnValue(''), + ...overrides, + }; +} + +/** + * 创建 Mock PermissionManager + */ +export function createMockPermissionManager(overrides: Partial> = {}) { + return { + setAskCallback: vi.fn(), + checkPermission: vi.fn().mockResolvedValue({ allowed: true }), + checkFilePermission: vi.fn().mockResolvedValue({ allowed: true }), + checkBashPermission: vi.fn().mockResolvedValue({ allowed: true }), + ...overrides, + }; +} + +/** + * 创建 Mock ToolRegistry + */ +export function createMockToolRegistry(overrides: Partial> = {}) { + return { + getCoreTools: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + get: vi.fn(), + has: vi.fn().mockReturnValue(false), + register: vi.fn(), + ...overrides, + }; +} + +/** + * 创建完整的 Core 模块 mock + */ +export function createMockCoreModule(overrides: { + agent?: Partial>; + providerRegistry?: Partial>; + agentRegistry?: Partial>; + permissionManager?: Partial>; + toolRegistry?: Partial>; + loadConfig?: ReturnType; + saveConfig?: ReturnType; +} = {}) { + const mockAgent = { ...createMockAgent(), ...overrides.agent }; + const mockProviderRegistry = createMockProviderRegistry(overrides.providerRegistry); + const mockAgentRegistry = createMockAgentRegistry(overrides.agentRegistry); + const mockPermissionManager = createMockPermissionManager(overrides.permissionManager); + const mockToolRegistry = createMockToolRegistry(overrides.toolRegistry); + + // 创建一个真正的类来模拟 Agent 构造函数 + const MockAgentClass = vi.fn().mockImplementation(function (this: any) { + Object.assign(this, mockAgent); + return this; + }); + + return { + Agent: MockAgentClass, + toolRegistry: mockToolRegistry, + loadConfig: overrides.loadConfig ?? vi.fn().mockReturnValue({ + provider: 'anthropic', + apiKey: 'test-api-key', + model: 'claude-sonnet-4-20250514', + maxTokens: 4096, + systemPrompt: 'test prompt', + }), + saveConfig: overrides.saveConfig ?? vi.fn(), + getPermissionManager: vi.fn().mockReturnValue(mockPermissionManager), + getProviderRegistry: vi.fn().mockReturnValue(mockProviderRegistry), + agentRegistry: mockAgentRegistry, + // 额外暴露内部 mock 以便测试验证 + _mockAgent: mockAgent, + _mockProviderRegistry: mockProviderRegistry, + _mockAgentRegistry: mockAgentRegistry, + _mockPermissionManager: mockPermissionManager, + _mockToolRegistry: mockToolRegistry, + }; +} + +/** + * 创建抛出 ConfigurationError 的 loadConfig mock + */ +export function createConfigurationErrorMock(message: string, provider: string = 'anthropic') { + return vi.fn().mockImplementation(() => { + const error = new Error(message) as Error & { provider: string }; + error.name = 'ConfigurationError'; + error.provider = provider; + throw error; + }); +} diff --git a/packages/server/tests/mocks/hono.mock.ts b/packages/server/tests/mocks/hono.mock.ts new file mode 100644 index 0000000..be4f633 --- /dev/null +++ b/packages/server/tests/mocks/hono.mock.ts @@ -0,0 +1,98 @@ +/** + * Hono 框架 Mock 工厂 + * + * 提供 Hono Context 和 WebSocket Context 的 mock 实现 + */ + +import { vi } from 'vitest'; + +/** + * 创建 Mock Hono Context + */ +export function createMockHonoContext(options: { + params?: Record; + body?: unknown; + query?: Record; + headers?: Record; +} = {}) { + const jsonMock = vi.fn((data: unknown, status?: number) => { + return new Response(JSON.stringify(data), { + status: status ?? 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const textMock = vi.fn((text: string, status?: number) => { + return new Response(text, { + status: status ?? 200, + headers: { 'Content-Type': 'text/plain' }, + }); + }); + + return { + req: { + param: vi.fn((key: string) => options.params?.[key]), + json: vi.fn().mockResolvedValue(options.body ?? {}), + query: vi.fn((key: string) => options.query?.[key]), + header: vi.fn((key: string) => options.headers?.[key.toLowerCase()]), + url: 'http://localhost:3000/api/test', + method: 'GET', + path: '/api/test', + }, + json: jsonMock, + text: textMock, + status: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + get: vi.fn((key: string) => { + if (key === 'sessionId') return options.params?.sessionId; + return undefined; + }), + set: vi.fn(), + // 用于测试验证 + _jsonMock: jsonMock, + _textMock: textMock, + }; +} + +/** + * 创建 Mock WebSocket Context + */ +export function createMockWSContext() { + const sendMock = vi.fn(); + const closeMock = vi.fn(); + + return { + send: sendMock, + close: closeMock, + readyState: 1, // WebSocket.OPEN + // 用于测试验证 + _sendMock: sendMock, + _closeMock: closeMock, + }; +} + +/** + * 创建 Mock SSE Stream + */ +export function createMockSSEStream() { + const writeMock = vi.fn(); + const closeMock = vi.fn(); + + return { + write: writeMock, + close: closeMock, + writeSSE: vi.fn((data: { event?: string; data: string; id?: string }) => { + writeMock(`event: ${data.event ?? 'message'}\ndata: ${data.data}\n\n`); + }), + // 用于测试验证 + _writeMock: writeMock, + _closeMock: closeMock, + }; +} + +/** + * 创建 Mock Next 函数 + */ +export function createMockNext() { + return vi.fn().mockResolvedValue(undefined); +} diff --git a/packages/server/tests/mocks/session.mock.ts b/packages/server/tests/mocks/session.mock.ts new file mode 100644 index 0000000..eec3448 --- /dev/null +++ b/packages/server/tests/mocks/session.mock.ts @@ -0,0 +1,152 @@ +/** + * SessionManager Mock 工厂 + * + * 提供会话管理器的 mock 实现 + */ + +import { vi } from 'vitest'; + +export interface MockSession { + id: string; + name?: string; + status: 'idle' | 'busy'; + createdAt: number; + updatedAt: number; + workdir?: string; +} + +export interface MockMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + timestamp: number; +} + +/** + * 创建 Mock SessionManager + */ +export function createMockSessionManager() { + const sessions = new Map(); + const messages = new Map(); + let messageIdCounter = 1; + + const manager = { + // 初始化 + init: vi.fn().mockResolvedValue(undefined), + + // Session CRUD + exists: vi.fn((id: string) => sessions.has(id)), + + get: vi.fn((id: string) => sessions.get(id)), + + create: vi.fn((data?: Partial) => { + const id = data?.id ?? `session-${Date.now()}`; + const session: MockSession = { + id, + name: data?.name, + status: 'idle', + createdAt: Date.now(), + updatedAt: Date.now(), + workdir: data?.workdir ?? process.cwd(), + }; + sessions.set(id, session); + messages.set(id, []); + return session; + }), + + delete: vi.fn(async (id: string) => { + const existed = sessions.has(id); + sessions.delete(id); + messages.delete(id); + return existed; + }), + + list: vi.fn(() => Array.from(sessions.values())), + + // 消息管理 + addMessage: vi.fn(async (sessionId: string, msg: { role: 'user' | 'assistant'; content: string }) => { + const msgList = messages.get(sessionId) || []; + const newMsg: MockMessage = { + id: `msg-${messageIdCounter++}`, + role: msg.role, + content: msg.content, + timestamp: Date.now(), + }; + msgList.push(newMsg); + messages.set(sessionId, msgList); + return newMsg; + }), + + getMessages: vi.fn((id: string) => messages.get(id) || []), + + // 状态更新 + updateStatus: vi.fn((id: string, status: 'idle' | 'busy') => { + const session = sessions.get(id); + if (session) { + session.status = status; + session.updatedAt = Date.now(); + } + }), + + updateSessionName: vi.fn(async (id: string, name: string) => { + const session = sessions.get(id); + if (session) { + session.name = name; + session.updatedAt = Date.now(); + return session; + } + return null; + }), + + // 测试辅助方法 + _addSession: (session: MockSession) => { + sessions.set(session.id, session); + messages.set(session.id, []); + }, + + _addMessage: (sessionId: string, message: MockMessage) => { + const msgList = messages.get(sessionId) || []; + msgList.push(message); + messages.set(sessionId, msgList); + }, + + _clear: () => { + sessions.clear(); + messages.clear(); + messageIdCounter = 1; + }, + + _getSessions: () => sessions, + _getMessages: () => messages, + }; + + return manager; +} + +/** + * 创建测试用的 Session 数据 + */ +export function createTestSession(overrides: Partial = {}): MockSession { + return { + id: 'test-session-1', + name: 'Test Session', + status: 'idle', + createdAt: Date.now(), + updatedAt: Date.now(), + workdir: '/test/workdir', + ...overrides, + }; +} + +/** + * 创建测试用的 Message 数据 + */ +export function createTestMessage(overrides: Partial = {}): MockMessage { + return { + id: 'test-msg-1', + role: 'user', + content: 'Test message', + timestamp: Date.now(), + ...overrides, + }; +} diff --git a/packages/server/tests/setup.ts b/packages/server/tests/setup.ts new file mode 100644 index 0000000..3efd439 --- /dev/null +++ b/packages/server/tests/setup.ts @@ -0,0 +1,23 @@ +/** + * Vitest 测试环境设置 + */ + +import { beforeAll, afterAll, beforeEach, vi } from 'vitest'; + +// Mock console 输出避免测试干扰 +beforeAll(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +// 每个测试前清理 mock +beforeEach(() => { + vi.clearAllMocks(); +}); + +// 设置测试环境变量 +process.env.NODE_ENV = 'test'; diff --git a/packages/server/tests/unit/agent/adapter.test.ts b/packages/server/tests/unit/agent/adapter.test.ts new file mode 100644 index 0000000..28f840b --- /dev/null +++ b/packages/server/tests/unit/agent/adapter.test.ts @@ -0,0 +1,341 @@ +/** + * Agent Adapter 测试 + * + * 测试 initCore、getOrCreateAgent、配置错误处理等关键功能 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createMockCoreModule, + createMockProviderRegistry, + createMockAgentRegistry, + createConfigurationErrorMock, +} from '../../mocks/core.mock.js'; + +// 由于 adapter.ts 使用动态 import,需要特殊处理 +// 我们需要在每个测试中重新导入模块以确保 mock 生效 + +describe('Agent Adapter', () => { + // 存储原始模块状态 + let originalCwd: typeof process.cwd; + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + originalCwd = process.cwd; + }); + + afterEach(() => { + process.cwd = originalCwd; + vi.restoreAllMocks(); + }); + + describe('initCore - Core 模块初始化', () => { + it('成功加载 Core 模块时返回 true', async () => { + const mockCore = createMockCoreModule(); + + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + const result = await initCore(); + + expect(result).toBe(true); + }); + + it('Core 模块缺少必要导出时返回 false', async () => { + const incompleteCore = { + Agent: undefined, + toolRegistry: undefined, + loadConfig: undefined, + }; + + vi.doMock('@ai-assistant/core', () => incompleteCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + const result = await initCore(); + + expect(result).toBe(false); + }); + + it('Core 模块加载失败时返回 false', async () => { + vi.doMock('@ai-assistant/core', () => { + throw new Error('Module not found'); + }); + + const { initCore } = await import('../../../src/agent/adapter.js'); + const result = await initCore(); + + expect(result).toBe(false); + }); + + it('调用 ProviderRegistry.init()', async () => { + const mockProviderRegistry = createMockProviderRegistry({ + isInitialized: vi.fn().mockReturnValue(false), + }); + const mockCore = createMockCoreModule({ + providerRegistry: mockProviderRegistry, + }); + + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(mockCore.getProviderRegistry().init).toHaveBeenCalled(); + }); + + it('ProviderRegistry 已初始化时不重复调用 init()', async () => { + const mockProviderRegistry = createMockProviderRegistry({ + isInitialized: vi.fn().mockReturnValue(true), + }); + const mockCore = createMockCoreModule({ + providerRegistry: mockProviderRegistry, + }); + + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(mockCore.getProviderRegistry().init).not.toHaveBeenCalled(); + }); + + it('调用 AgentRegistry.init() 并传入 process.cwd()', async () => { + const testCwd = '/test/workdir'; + process.cwd = vi.fn().mockReturnValue(testCwd); + + const mockAgentRegistry = createMockAgentRegistry({ + isInitialized: vi.fn().mockReturnValue(false), + }); + const mockCore = createMockCoreModule({ + agentRegistry: mockAgentRegistry, + }); + + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(mockCore.agentRegistry.init).toHaveBeenCalledWith(testCwd); + }); + + it('AgentRegistry 已初始化时不重复调用 init()', async () => { + const mockAgentRegistry = createMockAgentRegistry({ + isInitialized: vi.fn().mockReturnValue(true), + }); + const mockCore = createMockCoreModule({ + agentRegistry: mockAgentRegistry, + }); + + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(mockCore.agentRegistry.init).not.toHaveBeenCalled(); + }); + }); + + describe('isCoreAvailable - Core 模块可用性检查', () => { + it('Core 未初始化时返回 false', async () => { + vi.resetModules(); + const { isCoreAvailable } = await import('../../../src/agent/adapter.js'); + expect(isCoreAvailable()).toBe(false); + }); + + it('Core 初始化后返回 true', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore, isCoreAvailable } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(isCoreAvailable()).toBe(true); + }); + }); + + describe('getOrCreateAgent - Agent 创建与缓存', () => { + it('Core 未初始化时返回 null', async () => { + vi.resetModules(); + const { getOrCreateAgent } = await import('../../../src/agent/adapter.js'); + const agent = getOrCreateAgent('session-1'); + expect(agent).toBeNull(); + }); + + it('成功创建 Agent 实例', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + // Mock session manager + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + get: vi.fn().mockReturnValue({ id: 'session-1' }), + }), + })); + + // Mock permission handler + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent } = await import('../../../src/agent/adapter.js'); + await initCore(); + + const agent = getOrCreateAgent('session-1'); + + expect(agent).not.toBeNull(); + expect(mockCore.Agent).toHaveBeenCalled(); + }); + + it('重复获取同一 session 返回缓存的 Agent', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + }), + })); + + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent } = await import('../../../src/agent/adapter.js'); + await initCore(); + + const agent1 = getOrCreateAgent('session-1'); + const agent2 = getOrCreateAgent('session-1'); + + expect(agent1).toBe(agent2); + // Agent 构造函数只应被调用一次 + expect(mockCore.Agent).toHaveBeenCalledTimes(1); + }); + + it('ConfigurationError 时返回 null', async () => { + const mockCore = createMockCoreModule({ + loadConfig: createConfigurationErrorMock('未配置 API Key', 'deepseek'), + }); + vi.doMock('@ai-assistant/core', () => mockCore); + + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + }), + })); + + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent } = await import('../../../src/agent/adapter.js'); + await initCore(); + + const agent = getOrCreateAgent('session-1'); + + expect(agent).toBeNull(); + }); + + it('非 ConfigurationError 时重新抛出', async () => { + const mockCore = createMockCoreModule({ + loadConfig: vi.fn().mockImplementation(() => { + throw new Error('Other error'); + }), + }); + vi.doMock('@ai-assistant/core', () => mockCore); + + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + }), + })); + + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent } = await import('../../../src/agent/adapter.js'); + await initCore(); + + expect(() => getOrCreateAgent('session-1')).toThrow('Other error'); + }); + }); + + describe('destroyAgent - Agent 销毁', () => { + it('从缓存中移除 Agent', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + }), + })); + + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent, destroyAgent } = await import('../../../src/agent/adapter.js'); + await initCore(); + + // 创建 Agent + getOrCreateAgent('session-1'); + expect(mockCore.Agent).toHaveBeenCalledTimes(1); + + // 销毁 Agent + destroyAgent('session-1'); + + // 再次获取应创建新 Agent + getOrCreateAgent('session-1'); + expect(mockCore.Agent).toHaveBeenCalledTimes(2); + }); + }); + + describe('getAgentStats - Agent 统计信息', () => { + it('Core 未初始化时返回 available: false', async () => { + vi.resetModules(); + const { getAgentStats } = await import('../../../src/agent/adapter.js'); + const stats = getAgentStats('session-1'); + expect(stats).toEqual({ available: false }); + }); + + it('Agent 不存在时返回 available: true 但无其他信息', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + const { initCore, getAgentStats } = await import('../../../src/agent/adapter.js'); + await initCore(); + + const stats = getAgentStats('non-existent-session'); + expect(stats).toEqual({ available: true }); + }); + + it('Agent 存在时返回完整统计信息', async () => { + const mockCore = createMockCoreModule(); + vi.doMock('@ai-assistant/core', () => mockCore); + + vi.doMock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn().mockReturnValue({ + exists: vi.fn().mockReturnValue(true), + }), + })); + + vi.doMock('../../../src/permission/handler.js', () => ({ + createServerPermissionCallback: vi.fn().mockReturnValue(async () => ({ allow: true })), + })); + + const { initCore, getOrCreateAgent, getAgentStats } = await import('../../../src/agent/adapter.js'); + await initCore(); + + // 创建 Agent + getOrCreateAgent('session-1'); + + const stats = getAgentStats('session-1'); + expect(stats.available).toBe(true); + expect(stats.toolCount).toEqual({ core: 5, discovered: 0, total: 5 }); + expect(stats.contextUsage).toBe('10k / 200k'); + }); + }); +}); diff --git a/packages/server/tests/unit/auth/token.test.ts b/packages/server/tests/unit/auth/token.test.ts new file mode 100644 index 0000000..41e840a --- /dev/null +++ b/packages/server/tests/unit/auth/token.test.ts @@ -0,0 +1,223 @@ +/** + * Auth Token 测试 + * + * 测试 Token 生成、验证、脱敏显示等功能 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + generateToken, + maskToken, + initAuth, + getAuthConfig, + addToken, + removeToken, + setAuthEnabled, + validateToken, + extractToken, +} from '../../../src/auth/token.js'; +import { createMockHonoContext } from '../../mocks/hono.mock.js'; + +describe('Auth Token', () => { + beforeEach(() => { + // 每个测试前重置配置 + initAuth({ + enabled: false, + tokens: [], + skipPaths: ['/health', '/api/health'], + }); + }); + + describe('generateToken - Token 生成', () => { + it('生成默认长度(32)的 token', () => { + const token = generateToken(); + expect(token).toHaveLength(32); + }); + + it('生成指定长度的 token', () => { + const token = generateToken(16); + expect(token).toHaveLength(16); + }); + + it('token 只包含字母和数字', () => { + const token = generateToken(100); + expect(token).toMatch(/^[A-Za-z0-9]+$/); + }); + + it('每次生成的 token 不同', () => { + const token1 = generateToken(); + const token2 = generateToken(); + expect(token1).not.toBe(token2); + }); + }); + + describe('maskToken - Token 脱敏', () => { + it('长 token 显示前4位和后4位', () => { + const token = 'abcd1234567890efgh'; + const masked = maskToken(token); + expect(masked).toBe('abcd...efgh'); + }); + + it('短 token (<=8) 完全隐藏', () => { + expect(maskToken('12345678')).toBe('****'); + expect(maskToken('1234567')).toBe('****'); + expect(maskToken('abc')).toBe('****'); + }); + + it('正好9位的 token 正常脱敏', () => { + const token = '123456789'; + const masked = maskToken(token); + expect(masked).toBe('1234...6789'); + }); + }); + + describe('initAuth - 配置初始化', () => { + it('使用默认配置初始化', () => { + const config = initAuth(); + expect(config.enabled).toBe(false); + expect(config.tokens).toEqual([]); + expect(config.skipPaths).toContain('/health'); + }); + + it('合并自定义配置', () => { + const config = initAuth({ + enabled: true, + tokens: ['test-token'], + }); + expect(config.enabled).toBe(true); + expect(config.tokens).toEqual(['test-token']); + expect(config.skipPaths).toContain('/health'); // 保留默认值 + }); + + it('完全覆盖配置', () => { + const config = initAuth({ + enabled: true, + tokens: ['token1', 'token2'], + skipPaths: ['/custom-skip'], + }); + expect(config.skipPaths).toEqual(['/custom-skip']); + }); + }); + + describe('getAuthConfig - 获取配置', () => { + it('返回当前配置', () => { + initAuth({ enabled: true, tokens: ['test'] }); + const config1 = getAuthConfig(); + const config2 = getAuthConfig(); + + // 应该返回相同内容 + expect(config1).toEqual(config2); + expect(config1.enabled).toBe(true); + expect(config1.tokens).toContain('test'); + }); + + it('修改 enabled 不影响原配置', () => { + initAuth({ enabled: true }); + const config = getAuthConfig(); + config.enabled = false; + + const freshConfig = getAuthConfig(); + expect(freshConfig.enabled).toBe(true); + }); + }); + + describe('addToken / removeToken - Token 管理', () => { + it('添加新 token', () => { + addToken('new-token'); + expect(getAuthConfig().tokens).toContain('new-token'); + }); + + it('不重复添加相同 token', () => { + addToken('same-token'); + addToken('same-token'); + const tokens = getAuthConfig().tokens; + expect(tokens.filter((t) => t === 'same-token')).toHaveLength(1); + }); + + it('移除存在的 token', () => { + addToken('to-remove'); + removeToken('to-remove'); + expect(getAuthConfig().tokens).not.toContain('to-remove'); + }); + + it('移除不存在的 token 无副作用', () => { + const before = getAuthConfig().tokens.length; + removeToken('non-existent'); + expect(getAuthConfig().tokens.length).toBe(before); + }); + }); + + describe('setAuthEnabled - 启用/禁用认证', () => { + it('启用认证', () => { + setAuthEnabled(true); + expect(getAuthConfig().enabled).toBe(true); + }); + + it('禁用认证', () => { + setAuthEnabled(true); + setAuthEnabled(false); + expect(getAuthConfig().enabled).toBe(false); + }); + }); + + describe('validateToken - Token 验证', () => { + beforeEach(() => { + initAuth({ tokens: ['valid-token-1', 'valid-token-2'] }); + }); + + it('有效 token 返回 true', () => { + expect(validateToken('valid-token-1')).toBe(true); + expect(validateToken('valid-token-2')).toBe(true); + }); + + it('无效 token 返回 false', () => { + expect(validateToken('invalid-token')).toBe(false); + expect(validateToken('')).toBe(false); + }); + }); + + describe('extractToken - 从请求提取 Token', () => { + it('从 Authorization header 提取 Bearer token', () => { + const c = createMockHonoContext({ + headers: { authorization: 'Bearer my-secret-token' }, + }); + + const token = extractToken(c as any); + expect(token).toBe('my-secret-token'); + }); + + it('从 query parameter 提取 token', () => { + const c = createMockHonoContext({ + query: { token: 'query-token' }, + }); + + const token = extractToken(c as any); + expect(token).toBe('query-token'); + }); + + it('Authorization header 优先于 query', () => { + const c = createMockHonoContext({ + headers: { authorization: 'Bearer header-token' }, + query: { token: 'query-token' }, + }); + + const token = extractToken(c as any); + expect(token).toBe('header-token'); + }); + + it('无 token 时返回 null', () => { + const c = createMockHonoContext(); + const token = extractToken(c as any); + expect(token).toBeNull(); + }); + + it('非 Bearer 格式的 Authorization 返回 null', () => { + const c = createMockHonoContext({ + headers: { authorization: 'Basic abc123' }, + }); + + const token = extractToken(c as any); + expect(token).toBeNull(); + }); + }); +}); diff --git a/packages/server/tests/unit/permission/handler.test.ts b/packages/server/tests/unit/permission/handler.test.ts new file mode 100644 index 0000000..418f68c --- /dev/null +++ b/packages/server/tests/unit/permission/handler.test.ts @@ -0,0 +1,363 @@ +/** + * Permission Handler 测试 + * + * 测试权限类型检测、请求构建、响应处理、超时处理等功能 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock broadcastToSession before importing handler +vi.mock('../../../src/ws.js', () => ({ + broadcastToSession: vi.fn(), +})); + +import { + createServerPermissionCallback, + handlePermissionResponse, + cancelPendingRequests, + getPendingRequestCount, +} from '../../../src/permission/handler.js'; +import { broadcastToSession } from '../../../src/ws.js'; + +describe('Permission Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('detectPermissionType - 权限类型检测', () => { + it('检测 git 命令', async () => { + const callback = createServerPermissionCallback('session-1'); + // 触发一个 git 命令的权限请求 + const promise = callback({ command: 'git push origin main', workdir: '/test' }); + + // 检查发送的消息类型 + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + type: 'permission_request', + payload: expect.objectContaining({ + permissionType: 'git', + context: expect.objectContaining({ + gitOperation: 'push', + }), + }), + }) + ); + + // 模拟响应 + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('检测文件操作命令', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'read /etc/passwd', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + permissionType: 'file', + context: expect.objectContaining({ + operation: 'read', + path: '/etc/passwd', + }), + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('检测 write 文件操作', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'write /tmp/test.txt', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + permissionType: 'file', + context: expect.objectContaining({ + operation: 'write', + }), + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('检测 web 操作', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'fetch https://api.example.com', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + permissionType: 'web', + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('默认为 bash 类型', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'npm install', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + permissionType: 'bash', + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + }); + + describe('buildRequestContext - 请求上下文构建', () => { + it('git 命令包含 gitOperation', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'git commit -m "test"', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + context: expect.objectContaining({ + command: 'git commit -m "test"', + gitOperation: 'commit', + }), + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('文件操作包含 patterns 和 externalPaths', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ + command: 'edit /test/file.ts', + workdir: '/test', + patterns: ['*.ts'], + externalPaths: ['/external'], + }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + context: expect.objectContaining({ + operation: 'edit', + patterns: ['*.ts'], + externalPaths: ['/external'], + }), + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('bash 命令包含 command', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'rm -rf /tmp/test', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + payload: expect.objectContaining({ + context: expect.objectContaining({ + command: 'rm -rf /tmp/test', + }), + }), + }) + ); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + }); + + describe('createServerPermissionCallback - 权限回调', () => { + it('发送权限请求消息', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'ls -la', workdir: '/test' }); + + expect(broadcastToSession).toHaveBeenCalledTimes(1); + expect(broadcastToSession).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + type: 'permission_request', + sessionId: 'session-1', + payload: expect.objectContaining({ + requestId: expect.any(String), + permissionType: 'bash', + }), + }) + ); + + // 响应以完成 promise + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true); + await promise; + }); + + it('返回 allow: true 当响应允许时', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'ls', workdir: '/test' }); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, true, true); + + const result = await promise; + expect(result).toEqual({ allow: true, remember: true }); + }); + + it('返回 allow: false 当响应拒绝时', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'rm -rf /', workdir: '/test' }); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + handlePermissionResponse(requestId, false, false); + + const result = await promise; + expect(result).toEqual({ allow: false, remember: false }); + }); + }); + + describe('handlePermissionResponse - 权限响应处理', () => { + it('处理存在的请求返回 true', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'ls', workdir: '/test' }); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + + const result = handlePermissionResponse(requestId, true); + expect(result).toBe(true); + + await promise; + }); + + it('处理不存在的请求返回 false', () => { + const result = handlePermissionResponse('non-existent-request-id', true); + expect(result).toBe(false); + }); + + it('清除超时定时器', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'ls', workdir: '/test' }); + + const call = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId = (call[1] as any).payload.requestId; + + // 响应请求 + handlePermissionResponse(requestId, true); + await promise; + + // 快进超过超时时间,不应有任何错误 + vi.advanceTimersByTime(70000); + }); + }); + + describe('超时处理', () => { + it('超时后返回 allow: false', async () => { + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'dangerous-command', workdir: '/test' }); + + // 快进 60 秒(超时时间) + vi.advanceTimersByTime(60000); + + const result = await promise; + expect(result).toEqual({ allow: false, remember: false }); + }); + + it('超时后从 pending 中移除', async () => { + const initialCount = getPendingRequestCount(); + + const callback = createServerPermissionCallback('session-1'); + const promise = callback({ command: 'test', workdir: '/test' }); + + expect(getPendingRequestCount()).toBe(initialCount + 1); + + // 超时 + vi.advanceTimersByTime(60000); + await promise; + + expect(getPendingRequestCount()).toBe(initialCount); + }); + }); + + describe('getPendingRequestCount - 待处理请求计数', () => { + it('返回正确的待处理请求数', async () => { + const initialCount = getPendingRequestCount(); + + const callback = createServerPermissionCallback('session-1'); + const promise1 = callback({ command: 'cmd1', workdir: '/test' }); + const promise2 = callback({ command: 'cmd2', workdir: '/test' }); + + expect(getPendingRequestCount()).toBe(initialCount + 2); + + // 响应第一个 + const call1 = vi.mocked(broadcastToSession).mock.calls[0]; + const requestId1 = (call1[1] as any).payload.requestId; + handlePermissionResponse(requestId1, true); + await promise1; + + expect(getPendingRequestCount()).toBe(initialCount + 1); + + // 响应第二个 + const call2 = vi.mocked(broadcastToSession).mock.calls[1]; + const requestId2 = (call2[1] as any).payload.requestId; + handlePermissionResponse(requestId2, true); + await promise2; + + expect(getPendingRequestCount()).toBe(initialCount); + }); + }); + + describe('cancelPendingRequests - 取消待处理请求', () => { + it('调用不抛出错误', () => { + // 目前这个函数是占位符,只是确保不抛出错误 + expect(() => cancelPendingRequests('session-1')).not.toThrow(); + }); + }); +}); diff --git a/packages/server/tests/unit/routes/config.test.ts b/packages/server/tests/unit/routes/config.test.ts new file mode 100644 index 0000000..3623e83 --- /dev/null +++ b/packages/server/tests/unit/routes/config.test.ts @@ -0,0 +1,143 @@ +/** + * Config Route 测试 + * + * 测试配置管理 REST API 端点 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import { configRouter, getConfig, setConfig } from '../../../src/routes/config.js'; + +// Create test app +const app = new Hono(); +app.route('/config', configRouter); + +describe('Config Route', () => { + beforeEach(() => { + // Reset config before each test + setConfig({ workdir: '/default/workdir' }); + }); + + describe('GET /config - 获取配置', () => { + it('返回当前配置', async () => { + setConfig({ workdir: '/test/workdir' }); + + const res = await app.request('/config'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.workdir).toBe('/test/workdir'); + }); + }); + + describe('PUT /config - 更新配置', () => { + it('更新配置', async () => { + const res = await app.request('/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workdir: '/new/workdir' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.workdir).toBe('/new/workdir'); + }); + + it('合并配置而不是完全替换', async () => { + setConfig({ workdir: '/original' }); + + const res = await app.request('/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workdir: '/updated' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data.workdir).toBe('/updated'); + }); + + it('无效 JSON 返回 400', async () => { + const res = await app.request('/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json', + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + }); + + describe('PATCH /config - 部分更新配置', () => { + it('部分更新配置', async () => { + setConfig({ workdir: '/original' }); + + const res = await app.request('/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workdir: '/patched' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.workdir).toBe('/patched'); + }); + + it('忽略不存在的字段', async () => { + setConfig({ workdir: '/original' }); + + const res = await app.request('/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ unknownField: 'value' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data.workdir).toBe('/original'); + expect(json.data.unknownField).toBeUndefined(); + }); + + it('无效 JSON 返回 400', async () => { + const res = await app.request('/config', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json', + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + }); + + describe('getConfig / setConfig - 内部函数', () => { + it('getConfig 返回当前配置', () => { + setConfig({ workdir: '/test' }); + const config = getConfig(); + expect(config.workdir).toBe('/test'); + }); + + it('getConfig 返回副本,不影响原配置', () => { + setConfig({ workdir: '/original' }); + const config = getConfig(); + config.workdir = '/modified'; + + const freshConfig = getConfig(); + expect(freshConfig.workdir).toBe('/original'); + }); + + it('setConfig 合并配置', () => { + setConfig({ workdir: '/first' }); + setConfig({ workdir: '/second' }); + + const config = getConfig(); + expect(config.workdir).toBe('/second'); + }); + }); +}); diff --git a/packages/server/tests/unit/routes/context.test.ts b/packages/server/tests/unit/routes/context.test.ts new file mode 100644 index 0000000..7feede0 --- /dev/null +++ b/packages/server/tests/unit/routes/context.test.ts @@ -0,0 +1,184 @@ +/** + * Context Route 测试 + * + * 测试上下文压缩 REST API 端点 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; + +// Use vi.hoisted to create mocks before vi.mock is hoisted +const { mockExists, mockGetContextUsage, mockCompressContext } = vi.hoisted(() => ({ + mockExists: vi.fn(), + mockGetContextUsage: vi.fn(), + mockCompressContext: vi.fn(), +})); + +vi.mock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn(() => ({ + exists: mockExists, + })), +})); + +vi.mock('../../../src/agent/adapter.js', () => ({ + getContextUsage: mockGetContextUsage, + compressContext: mockCompressContext, +})); + +import { contextRouter } from '../../../src/routes/context.js'; + +// Create test app +const app = new Hono(); +app.route('', contextRouter); + +describe('Context Route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /sessions/:id/context - 获取上下文使用情况', () => { + it('返回上下文使用信息', async () => { + mockExists.mockReturnValue(true); + mockGetContextUsage.mockReturnValue({ + input: 50000, + contextLimit: 200000, + available: 150000, + usagePercent: 25, + formatted: '50K/150K (25%)', + shouldCompress: false, + }); + + const res = await app.request('/sessions/session-1/context'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.input).toBe(50000); + expect(json.data.usagePercent).toBe(25); + }); + + it('会话不存在返回 404', async () => { + mockExists.mockReturnValue(false); + + const res = await app.request('/sessions/non-existent/context'); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toBe('Session not found'); + }); + + it('Agent 未初始化时返回默认值', async () => { + mockExists.mockReturnValue(true); + mockGetContextUsage.mockReturnValue(null); + + const res = await app.request('/sessions/session-1/context'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.input).toBe(0); + expect(json.data.usagePercent).toBe(0); + expect(json.data.shouldCompress).toBe(false); + }); + }); + + describe('POST /sessions/:id/compress - 触发手动压缩', () => { + it('成功压缩上下文', async () => { + mockExists.mockReturnValue(true); + mockCompressContext.mockResolvedValue({ + type: 'summarize', + freedTokens: 10000, + newTokenCount: 40000, + }); + + const res = await app.request('/sessions/session-1/compress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: false }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.type).toBe('summarize'); + expect(json.data.freedTokens).toBe(10000); + }); + + it('强制压缩', async () => { + mockExists.mockReturnValue(true); + mockCompressContext.mockResolvedValue({ + type: 'summarize', + freedTokens: 20000, + newTokenCount: 30000, + }); + + const res = await app.request('/sessions/session-1/compress', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force: true }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockCompressContext).toHaveBeenCalledWith('session-1', true); + }); + + it('无请求体时默认不强制压缩', async () => { + mockExists.mockReturnValue(true); + mockCompressContext.mockResolvedValue({ + type: 'prune', + freedTokens: 5000, + }); + + const res = await app.request('/sessions/session-1/compress', { + method: 'POST', + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(mockCompressContext).toHaveBeenCalledWith('session-1', false); + }); + + it('会话不存在返回 404', async () => { + mockExists.mockReturnValue(false); + + const res = await app.request('/sessions/non-existent/compress', { + method: 'POST', + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toBe('Session not found'); + }); + + it('Agent 未初始化返回 400', async () => { + mockExists.mockReturnValue(true); + mockCompressContext.mockResolvedValue(null); + + const res = await app.request('/sessions/session-1/compress', { + method: 'POST', + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + expect(json.error).toBe('Agent not initialized for this session'); + }); + + it('压缩失败返回 500', async () => { + mockExists.mockReturnValue(true); + mockCompressContext.mockRejectedValue(new Error('Compression failed')); + + const res = await app.request('/sessions/session-1/compress', { + method: 'POST', + }); + const json = await res.json(); + + expect(res.status).toBe(500); + expect(json.success).toBe(false); + expect(json.error).toBe('Compression failed'); + }); + }); +}); diff --git a/packages/server/tests/unit/routes/providers.test.ts b/packages/server/tests/unit/routes/providers.test.ts new file mode 100644 index 0000000..a051ae6 --- /dev/null +++ b/packages/server/tests/unit/routes/providers.test.ts @@ -0,0 +1,330 @@ +/** + * Providers Route 测试 + * + * 测试模型提供商管理 REST API 端点 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; + +// Use vi.hoisted to create mocks +const mockRegistry = vi.hoisted(() => ({ + listForApi: vi.fn(), + getDetail: vi.fn(), + getModels: vi.fn(), + has: vi.fn(), + testConnection: vi.fn(), + registerCustom: vi.fn(), + setConfig: vi.fn(), + saveConfig: vi.fn(), + removeCustom: vi.fn(), + addCustomModel: vi.fn(), + removeCustomModel: vi.fn(), +})); + +const mockCoreModule = vi.hoisted(() => ({ + getProviderRegistry: vi.fn(() => mockRegistry), +})); + +// Track if core module should be available +let coreModuleAvailable = true; + +vi.mock('@ai-assistant/core', () => { + if (!coreModuleAvailable) { + throw new Error('Module not found'); + } + return mockCoreModule; +}); + +import { providersRouter } from '../../../src/routes/providers.js'; + +// Create test app +const app = new Hono(); +app.route('/providers', providersRouter); + +describe('Providers Route', () => { + beforeEach(() => { + vi.clearAllMocks(); + coreModuleAvailable = true; + }); + + describe('GET /providers - 列出所有提供商', () => { + it('返回提供商列表', async () => { + const providers = [ + { id: 'anthropic', name: 'Anthropic', builtin: true, enabled: true, hasApiKey: true, modelCount: 5 }, + { id: 'openai', name: 'OpenAI', builtin: true, enabled: false, hasApiKey: false, modelCount: 10 }, + ]; + mockRegistry.listForApi.mockReturnValue(providers); + + const res = await app.request('/providers'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(providers); + }); + + it('返回空列表', async () => { + mockRegistry.listForApi.mockReturnValue([]); + + const res = await app.request('/providers'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data).toEqual([]); + }); + }); + + describe('GET /providers/:id - 获取提供商详情', () => { + it('返回提供商详情', async () => { + const detail = { + id: 'anthropic', + name: 'Anthropic', + builtin: true, + models: [{ id: 'claude-3', name: 'Claude 3' }], + config: { enabled: true, hasApiKey: true }, + }; + mockRegistry.getDetail.mockReturnValue(detail); + + const res = await app.request('/providers/anthropic'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(detail); + }); + + it('提供商不存在返回 404', async () => { + mockRegistry.getDetail.mockReturnValue(undefined); + + const res = await app.request('/providers/non-existent'); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toContain('not found'); + }); + }); + + describe('GET /providers/:id/models - 获取模型列表', () => { + it('返回模型列表', async () => { + const models = [ + { id: 'claude-3-opus', name: 'Claude 3 Opus', contextWindow: 200000 }, + { id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', contextWindow: 200000 }, + ]; + mockRegistry.has.mockReturnValue(true); + mockRegistry.getModels.mockReturnValue(models); + + const res = await app.request('/providers/anthropic/models'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(models); + }); + + it('提供商不存在返回 404', async () => { + mockRegistry.has.mockReturnValue(false); + + const res = await app.request('/providers/non-existent/models'); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + }); + }); + + describe('POST /providers/:id/test - 测试连接', () => { + it('测试连接成功', async () => { + mockRegistry.testConnection.mockResolvedValue({ + success: true, + latency: 150, + }); + + const res = await app.request('/providers/anthropic/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey: 'test-key' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.success).toBe(true); + expect(json.data.latency).toBe(150); + }); + + it('测试连接失败', async () => { + mockRegistry.testConnection.mockResolvedValue({ + success: false, + error: 'Invalid API key', + }); + + const res = await app.request('/providers/anthropic/test', { + method: 'POST', + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data.success).toBe(false); + expect(json.data.error).toBe('Invalid API key'); + }); + }); + + describe('POST /providers - 注册自定义提供商', () => { + it('注册成功', async () => { + mockRegistry.registerCustom.mockReturnValue(undefined); + mockRegistry.saveConfig.mockResolvedValue(undefined); + + const res = await app.request('/providers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'custom-provider', + name: 'Custom Provider', + baseUrl: 'https://api.custom.com', + }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(mockRegistry.registerCustom).toHaveBeenCalled(); + expect(mockRegistry.saveConfig).toHaveBeenCalled(); + }); + + it('缺少必需字段返回 400', async () => { + const res = await app.request('/providers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'custom' }), + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + expect(json.error).toContain('Missing required fields'); + }); + }); + + describe('PUT /providers/:id - 更新配置', () => { + it('更新成功', async () => { + mockRegistry.has.mockReturnValue(true); + mockRegistry.setConfig.mockReturnValue(undefined); + mockRegistry.saveConfig.mockResolvedValue(undefined); + + const res = await app.request('/providers/anthropic', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true, apiKey: 'new-key' }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(mockRegistry.setConfig).toHaveBeenCalled(); + }); + + it('提供商不存在返回 404', async () => { + mockRegistry.has.mockReturnValue(false); + + const res = await app.request('/providers/non-existent', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: true }), + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + }); + }); + + describe('DELETE /providers/:id - 删除自定义提供商', () => { + it('删除成功', async () => { + mockRegistry.removeCustom.mockReturnValue(true); + mockRegistry.saveConfig.mockResolvedValue(undefined); + + const res = await app.request('/providers/custom-provider', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + }); + + it('提供商不存在返回 404', async () => { + mockRegistry.removeCustom.mockReturnValue(false); + + const res = await app.request('/providers/non-existent', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + }); + }); + + describe('POST /providers/:id/models - 添加自定义模型', () => { + it('添加成功', async () => { + mockRegistry.addCustomModel.mockReturnValue(undefined); + mockRegistry.saveConfig.mockResolvedValue(undefined); + + const res = await app.request('/providers/anthropic/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: 'custom-model', + name: 'Custom Model', + contextWindow: 100000, + }), + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(mockRegistry.addCustomModel).toHaveBeenCalledWith('anthropic', expect.any(Object)); + }); + + it('缺少必需字段返回 400', async () => { + const res = await app.request('/providers/anthropic/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: 'model' }), + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toContain('Missing required fields'); + }); + }); + + describe('DELETE /providers/:id/models/:modelId - 删除自定义模型', () => { + it('删除成功', async () => { + mockRegistry.removeCustomModel.mockReturnValue(true); + mockRegistry.saveConfig.mockResolvedValue(undefined); + + const res = await app.request('/providers/anthropic/models/custom-model', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + }); + + it('模型不存在返回 404', async () => { + mockRegistry.removeCustomModel.mockReturnValue(false); + + const res = await app.request('/providers/anthropic/models/non-existent', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + }); + }); +}); diff --git a/packages/server/tests/unit/routes/sessions.test.ts b/packages/server/tests/unit/routes/sessions.test.ts new file mode 100644 index 0000000..1c88a79 --- /dev/null +++ b/packages/server/tests/unit/routes/sessions.test.ts @@ -0,0 +1,280 @@ +/** + * Sessions Route 测试 + * + * 测试会话管理 REST API 端点 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Hono } from 'hono'; + +// Use vi.hoisted to create mocks before vi.mock is hoisted +const { mockList, mockCreate, mockGet, mockExists, mockDelete, mockGetMessages, mockAddMessage } = vi.hoisted(() => ({ + mockList: vi.fn(), + mockCreate: vi.fn(), + mockGet: vi.fn(), + mockExists: vi.fn(), + mockDelete: vi.fn(), + mockGetMessages: vi.fn(), + mockAddMessage: vi.fn(), +})); + +vi.mock('../../../src/session/manager.js', () => ({ + getSessionManager: vi.fn(() => ({ + list: mockList, + create: mockCreate, + get: mockGet, + exists: mockExists, + delete: mockDelete, + getMessages: mockGetMessages, + addMessage: mockAddMessage, + })), +})); + +import { sessionsRouter } from '../../../src/routes/sessions.js'; + +// Create test app +const app = new Hono(); +app.route('/sessions', sessionsRouter); + +describe('Sessions Route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /sessions - 列出会话', () => { + it('返回会话列表', async () => { + const sessions = [ + { id: 'session-1', name: 'Test 1', status: 'idle', createdAt: 1000, updatedAt: 1000 }, + { id: 'session-2', name: 'Test 2', status: 'active', createdAt: 2000, updatedAt: 2000 }, + ]; + mockList.mockReturnValue(sessions); + + const res = await app.request('/sessions'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(sessions); + }); + + it('空列表返回空数组', async () => { + mockList.mockReturnValue([]); + + const res = await app.request('/sessions'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual([]); + }); + }); + + describe('POST /sessions - 创建会话', () => { + it('创建新会话', async () => { + const newSession = { + id: 'new-session', + name: 'My Session', + status: 'idle', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + mockCreate.mockResolvedValue(newSession); + + const res = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'My Session' }), + }); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toEqual(newSession); + }); + + it('无效输入返回 400', async () => { + const res = await app.request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'invalid json', + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + }); + + describe('GET /sessions/:id - 获取单个会话', () => { + it('返回存在的会话', async () => { + const session = { id: 'session-1', name: 'Test', status: 'idle' }; + mockGet.mockReturnValue(session); + + const res = await app.request('/sessions/session-1'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(session); + }); + + it('不存在的会话返回 404', async () => { + mockGet.mockReturnValue(null); + + const res = await app.request('/sessions/non-existent'); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toBe('Session not found'); + }); + }); + + describe('DELETE /sessions/:id - 删除会话', () => { + it('删除存在的会话', async () => { + mockExists.mockReturnValue(true); + mockDelete.mockResolvedValue(true); + + const res = await app.request('/sessions/session-1', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(mockDelete).toHaveBeenCalledWith('session-1'); + }); + + it('不存在的会话返回 404', async () => { + mockExists.mockReturnValue(false); + + const res = await app.request('/sessions/non-existent', { + method: 'DELETE', + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toBe('Session not found'); + }); + }); + + describe('GET /sessions/:id/messages - 获取消息', () => { + it('返回会话消息', async () => { + const messages = [ + { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: 2000 }, + ]; + mockExists.mockReturnValue(true); + mockGetMessages.mockReturnValue(messages); + + const res = await app.request('/sessions/session-1/messages'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(messages); + }); + + it('不存在的会话返回 404', async () => { + mockExists.mockReturnValue(false); + + const res = await app.request('/sessions/non-existent/messages'); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + expect(json.error).toBe('Session not found'); + }); + + it('空消息返回空数组', async () => { + mockExists.mockReturnValue(true); + mockGetMessages.mockReturnValue([]); + + const res = await app.request('/sessions/session-1/messages'); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data).toEqual([]); + }); + }); + + describe('POST /sessions/:id/messages - 发送消息', () => { + it('添加用户消息', async () => { + const message = { id: 'msg-1', role: 'user', content: 'Hello', timestamp: Date.now() }; + mockExists.mockReturnValue(true); + mockAddMessage.mockResolvedValue(message); + + const res = await app.request('/sessions/session-1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'user', content: 'Hello' }), + }); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toEqual(message); + }); + + it('添加助手消息', async () => { + const message = { id: 'msg-2', role: 'assistant', content: 'Hi!', timestamp: Date.now() }; + mockExists.mockReturnValue(true); + mockAddMessage.mockResolvedValue(message); + + const res = await app.request('/sessions/session-1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'assistant', content: 'Hi!' }), + }); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.success).toBe(true); + }); + + it('不存在的会话返回 404', async () => { + mockExists.mockReturnValue(false); + + const res = await app.request('/sessions/non-existent/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'user', content: 'Hello' }), + }); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.success).toBe(false); + }); + + it('无效输入返回 400', async () => { + mockExists.mockReturnValue(true); + + const res = await app.request('/sessions/session-1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'invalid', content: 'Hello' }), + }); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.success).toBe(false); + }); + + it('添加消息失败返回 500', async () => { + mockExists.mockReturnValue(true); + mockAddMessage.mockResolvedValue(null); + + const res = await app.request('/sessions/session-1/messages', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'user', content: 'Hello' }), + }); + const json = await res.json(); + + expect(res.status).toBe(500); + expect(json.success).toBe(false); + expect(json.error).toBe('Failed to add message'); + }); + }); +}); diff --git a/packages/server/tests/unit/session/manager.test.ts b/packages/server/tests/unit/session/manager.test.ts new file mode 100644 index 0000000..91ac2ee --- /dev/null +++ b/packages/server/tests/unit/session/manager.test.ts @@ -0,0 +1,445 @@ +/** + * Session Manager 测试 + * + * 测试会话管理器的核心功能 + * + * 注意:SessionManager 使用动态 import 加载 @ai-assistant/core。 + * 测试中会创建新实例并记录初始会话数量,以确保测试独立性。 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { SessionManager, getSessionManager } from '../../../src/session/manager.js'; + +describe('SessionManager', () => { + let manager: SessionManager; + let initialCount: number; + + beforeEach(async () => { + vi.clearAllMocks(); + // Create a fresh instance for each test + manager = new SessionManager(); + await manager.init(); + // Record initial session count (may include sessions from storage) + initialCount = manager.count(); + }); + + describe('init - 初始化', () => { + it('初始化成功', async () => { + // Manager is already initialized in beforeEach + // Verify it works by creating a session + const session = await manager.create({ name: 'Test' }); + expect(session).toBeDefined(); + }); + + it('重复初始化只执行一次', async () => { + await manager.init(); + await manager.init(); + + // 不会抛错,可以正常工作 + const session = await manager.create({ name: 'Test' }); + expect(session).toBeDefined(); + }); + }); + + describe('create - 创建会话', () => { + it('创建新会话', async () => { + const session = await manager.create({ name: 'New Session' }); + + expect(session).toBeDefined(); + expect(session.name).toBe('New Session'); + expect(session.status).toBe('idle'); + expect(session.messageCount).toBe(0); + }); + + it('生成唯一 ID', async () => { + const session1 = await manager.create({ name: 'Session 1' }); + const session2 = await manager.create({ name: 'Session 2' }); + + expect(session1.id).not.toBe(session2.id); + }); + + it('使用自定义工作目录', async () => { + const session = await manager.create({ + name: 'Custom Workdir', + workdir: '/custom/path', + }); + + expect(session.workdir).toBe('/custom/path'); + }); + + it('无名称时创建匿名会话', async () => { + const session = await manager.create({}); + + expect(session).toBeDefined(); + expect(session.name).toBeUndefined(); + }); + + it('设置正确的时间戳', async () => { + const before = new Date().toISOString(); + const session = await manager.create({ name: 'Test' }); + const after = new Date().toISOString(); + + expect(session.createdAt).toBeDefined(); + expect(session.createdAt >= before).toBe(true); + expect(session.createdAt <= after).toBe(true); + expect(session.createdAt).toBe(session.updatedAt); + }); + + it('创建后 count 增加', async () => { + const before = manager.count(); + await manager.create({ name: 'Test' }); + expect(manager.count()).toBe(before + 1); + }); + }); + + describe('list - 列出会话', () => { + it('返回所有会话(包含新创建的)', async () => { + const countBefore = manager.list().length; + await manager.create({ name: 'Session 1' }); + await manager.create({ name: 'Session 2' }); + + const sessions = manager.list(); + expect(sessions.length).toBe(countBefore + 2); + }); + + it('按更新时间排序(最新在前)', async () => { + const session1 = await manager.create({ name: 'Older Session' }); + const session2 = await manager.create({ name: 'Newer Session' }); + + // Wait a bit and update session1 + await new Promise((resolve) => setTimeout(resolve, 10)); + manager.updateStatus(session1.id, 'active'); + + const sessions = manager.list(); + // session1 should be first as it was updated last + expect(sessions[0].id).toBe(session1.id); + }); + + it('返回数组类型', () => { + const sessions = manager.list(); + expect(Array.isArray(sessions)).toBe(true); + }); + }); + + describe('get - 获取会话', () => { + it('返回存在的会话', async () => { + const created = await manager.create({ name: 'Test' }); + const session = manager.get(created.id); + + expect(session).toBeDefined(); + expect(session?.id).toBe(created.id); + }); + + it('不存在返回 undefined', () => { + const session = manager.get('non-existent-id-12345'); + expect(session).toBeUndefined(); + }); + }); + + describe('delete - 删除会话', () => { + it('删除存在的会话', async () => { + const session = await manager.create({ name: 'To Delete' }); + const result = await manager.delete(session.id); + + expect(result).toBe(true); + expect(manager.exists(session.id)).toBe(false); + }); + + it('删除不存在的会话返回 false', async () => { + const result = await manager.delete('non-existent-id-12345'); + expect(result).toBe(false); + }); + + it('删除会话时同时删除消息', async () => { + const session = await manager.create({ name: 'With Messages' }); + await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + + await manager.delete(session.id); + + expect(manager.getMessages(session.id)).toEqual([]); + }); + + it('删除后 count 减少', async () => { + const session = await manager.create({ name: 'Test' }); + const countAfterCreate = manager.count(); + + await manager.delete(session.id); + expect(manager.count()).toBe(countAfterCreate - 1); + }); + }); + + describe('updateStatus - 更新状态', () => { + it('更新会话状态', async () => { + const session = await manager.create({ name: 'Test' }); + const updated = manager.updateStatus(session.id, 'active'); + + expect(updated?.status).toBe('active'); + }); + + it('更新为 busy 状态', async () => { + const session = await manager.create({ name: 'Test' }); + const updated = manager.updateStatus(session.id, 'busy'); + + expect(updated?.status).toBe('busy'); + }); + + it('更新为 idle 状态', async () => { + const session = await manager.create({ name: 'Test' }); + manager.updateStatus(session.id, 'active'); + const updated = manager.updateStatus(session.id, 'idle'); + + expect(updated?.status).toBe('idle'); + }); + + it('更新时更新 updatedAt', async () => { + const session = await manager.create({ name: 'Test' }); + const originalUpdatedAt = session.updatedAt; + + // Wait a bit to ensure time difference + await new Promise((resolve) => setTimeout(resolve, 10)); + + const updated = manager.updateStatus(session.id, 'busy'); + expect(updated?.updatedAt).not.toBe(originalUpdatedAt); + }); + + it('不存在的会话返回 undefined', () => { + const result = manager.updateStatus('non-existent-id-12345', 'active'); + expect(result).toBeUndefined(); + }); + }); + + describe('getMessages - 获取消息', () => { + it('返回会话消息', async () => { + const session = await manager.create({ name: 'Test' }); + await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + await manager.addMessage(session.id, { role: 'assistant', content: 'Hi!' }); + + const messages = manager.getMessages(session.id); + + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Hello'); + expect(messages[1].content).toBe('Hi!'); + }); + + it('新会话消息为空', async () => { + const session = await manager.create({ name: 'Test' }); + const messages = manager.getMessages(session.id); + expect(messages).toEqual([]); + }); + + it('不存在的会话返回空数组', () => { + const messages = manager.getMessages('non-existent-id-12345'); + expect(messages).toEqual([]); + }); + }); + + describe('addMessage - 添加消息', () => { + it('添加用户消息', async () => { + const session = await manager.create({ name: 'Test' }); + const message = await manager.addMessage(session.id, { + role: 'user', + content: 'Hello', + }); + + expect(message).toBeDefined(); + expect(message?.role).toBe('user'); + expect(message?.content).toBe('Hello'); + expect(message?.id).toBeDefined(); + expect(message?.sessionId).toBe(session.id); + }); + + it('添加助手消息', async () => { + const session = await manager.create({ name: 'Test' }); + const message = await manager.addMessage(session.id, { + role: 'assistant', + content: 'Hello!', + }); + + expect(message?.role).toBe('assistant'); + }); + + it('添加系统消息', async () => { + const session = await manager.create({ name: 'Test' }); + const message = await manager.addMessage(session.id, { + role: 'system', + content: 'System prompt', + }); + + expect(message?.role).toBe('system'); + }); + + it('消息有唯一 ID', async () => { + const session = await manager.create({ name: 'Test' }); + const msg1 = await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + const msg2 = await manager.addMessage(session.id, { role: 'assistant', content: 'Hi!' }); + + expect(msg1?.id).not.toBe(msg2?.id); + }); + + it('消息有正确的时间戳', async () => { + const session = await manager.create({ name: 'Test' }); + const before = new Date().toISOString(); + const message = await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + const after = new Date().toISOString(); + + expect(message?.createdAt).toBeDefined(); + expect(message?.createdAt! >= before).toBe(true); + expect(message?.createdAt! <= after).toBe(true); + }); + + it('更新会话的 messageCount', async () => { + const session = await manager.create({ name: 'Test' }); + expect(session.messageCount).toBe(0); + + await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + + const updated = manager.get(session.id); + expect(updated?.messageCount).toBe(1); + }); + + it('更新会话的 updatedAt', async () => { + const session = await manager.create({ name: 'Test' }); + const originalUpdatedAt = session.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + await manager.addMessage(session.id, { role: 'user', content: 'Hello' }); + + const updated = manager.get(session.id); + expect(updated?.updatedAt).not.toBe(originalUpdatedAt); + }); + + it('不存在的会话返回 undefined', async () => { + const result = await manager.addMessage('non-existent-id-12345', { + role: 'user', + content: 'Hello', + }); + + expect(result).toBeUndefined(); + }); + }); + + describe('updateSessionName - 更新名称', () => { + it('更新会话名称', async () => { + const session = await manager.create({ name: 'Old Name' }); + const updated = await manager.updateSessionName(session.id, 'New Name'); + + expect(updated?.name).toBe('New Name'); + }); + + it('更新名称更新 updatedAt', async () => { + const session = await manager.create({ name: 'Test' }); + const originalUpdatedAt = session.updatedAt; + + await new Promise((resolve) => setTimeout(resolve, 10)); + await manager.updateSessionName(session.id, 'Updated'); + + expect(manager.get(session.id)?.updatedAt).not.toBe(originalUpdatedAt); + }); + + it('不存在的会话返回 undefined', async () => { + const result = await manager.updateSessionName('non-existent-id-12345', 'Name'); + expect(result).toBeUndefined(); + }); + + it('更新为空名称', async () => { + const session = await manager.create({ name: 'Has Name' }); + const updated = await manager.updateSessionName(session.id, ''); + + expect(updated?.name).toBe(''); + }); + }); + + describe('count - 会话数量', () => { + it('创建后数量增加', async () => { + const before = manager.count(); + await manager.create({ name: 'Session 1' }); + expect(manager.count()).toBe(before + 1); + + await manager.create({ name: 'Session 2' }); + expect(manager.count()).toBe(before + 2); + + await manager.create({ name: 'Session 3' }); + expect(manager.count()).toBe(before + 3); + }); + + it('返回非负整数', () => { + expect(manager.count()).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(manager.count())).toBe(true); + }); + }); + + describe('exists - 检查存在', () => { + it('存在的会话返回 true', async () => { + const session = await manager.create({ name: 'Test' }); + expect(manager.exists(session.id)).toBe(true); + }); + + it('不存在的会话返回 false', () => { + expect(manager.exists('non-existent-id-12345')).toBe(false); + }); + + it('删除后返回 false', async () => { + const session = await manager.create({ name: 'Test' }); + await manager.delete(session.id); + expect(manager.exists(session.id)).toBe(false); + }); + }); + + describe('边界情况', () => { + it('处理特殊字符的会话名称', async () => { + const session = await manager.create({ name: '测试会话 <>&"\'`' }); + expect(session.name).toBe('测试会话 <>&"\'`'); + }); + + it('处理长消息内容', async () => { + const session = await manager.create({ name: 'Test' }); + const longContent = 'x'.repeat(10000); + const message = await manager.addMessage(session.id, { + role: 'user', + content: longContent, + }); + + expect(message?.content).toBe(longContent); + }); + + it('处理空字符串消息', async () => { + const session = await manager.create({ name: 'Test' }); + const message = await manager.addMessage(session.id, { + role: 'user', + content: '', + }); + + expect(message?.content).toBe(''); + }); + + it('多个会话独立的消息', async () => { + const session1 = await manager.create({ name: 'Session 1' }); + const session2 = await manager.create({ name: 'Session 2' }); + + await manager.addMessage(session1.id, { role: 'user', content: 'Message for session 1' }); + await manager.addMessage(session2.id, { role: 'user', content: 'Message for session 2' }); + + const messages1 = manager.getMessages(session1.id); + const messages2 = manager.getMessages(session2.id); + + expect(messages1.length).toBe(1); + expect(messages2.length).toBe(1); + expect(messages1[0].content).toBe('Message for session 1'); + expect(messages2[0].content).toBe('Message for session 2'); + }); + }); +}); + +describe('getSessionManager - 单例', () => { + it('返回同一实例', () => { + const manager1 = getSessionManager(); + const manager2 = getSessionManager(); + + expect(manager1).toBe(manager2); + }); + + it('返回 SessionManager 实例', () => { + const manager = getSessionManager(); + expect(manager).toBeInstanceOf(SessionManager); + }); +}); diff --git a/packages/server/tests/unit/sse.test.ts b/packages/server/tests/unit/sse.test.ts new file mode 100644 index 0000000..d05d380 --- /dev/null +++ b/packages/server/tests/unit/sse.test.ts @@ -0,0 +1,141 @@ +/** + * SSE (Server-Sent Events) Handler 测试 + * + * 测试 SSE 事件格式、订阅者管理、各类事件发送等功能 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock dependencies before imports +const mockExists = vi.fn(); + +vi.mock('../../src/session/manager.js', () => ({ + getSessionManager: vi.fn(() => ({ + exists: mockExists, + })), +})); + +// Import after mocking - note: some functions are internal and not testable directly +import { + emitEvent, + broadcastEvent, + emitStatusEvent, + emitLogEvent, + emitProgressEvent, + emitFileChangeEvent, + getSSEStats, +} from '../../src/sse.js'; + +describe('SSE Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExists.mockReturnValue(true); + }); + + describe('emitEvent - 发送事件', () => { + it('无订阅者时不抛出错误', () => { + expect(() => { + emitEvent('non-existent-session', { + event: 'test', + data: { timestamp: Date.now(), payload: null }, + }); + }).not.toThrow(); + }); + }); + + describe('broadcastEvent - 广播事件', () => { + it('无订阅者时不抛出错误', () => { + expect(() => { + broadcastEvent({ + event: 'broadcast-test', + data: { timestamp: Date.now(), payload: { message: 'hello' } }, + }); + }).not.toThrow(); + }); + }); + + describe('emitStatusEvent - 发送状态事件', () => { + it('调用时不抛出错误', () => { + expect(() => { + emitStatusEvent('session-1', 'processing', { step: 1, total: 5 }); + }).not.toThrow(); + }); + + it('只传状态不传详情时不抛出错误', () => { + expect(() => { + emitStatusEvent('session-1', 'idle'); + }).not.toThrow(); + }); + }); + + describe('emitLogEvent - 发送日志事件', () => { + it('发送 info 级别日志', () => { + expect(() => { + emitLogEvent('session-1', 'info', 'Processing started'); + }).not.toThrow(); + }); + + it('发送 warn 级别日志', () => { + expect(() => { + emitLogEvent('session-1', 'warn', 'Rate limit approaching'); + }).not.toThrow(); + }); + + it('发送 error 级别日志', () => { + expect(() => { + emitLogEvent('session-1', 'error', 'Connection failed'); + }).not.toThrow(); + }); + }); + + describe('emitProgressEvent - 发送进度事件', () => { + it('发送进度(带消息)', () => { + expect(() => { + emitProgressEvent('session-1', 50, 'Half way done'); + }).not.toThrow(); + }); + + it('发送进度(不带消息)', () => { + expect(() => { + emitProgressEvent('session-1', 100); + }).not.toThrow(); + }); + + it('发送 0% 进度', () => { + expect(() => { + emitProgressEvent('session-1', 0, 'Starting...'); + }).not.toThrow(); + }); + }); + + describe('emitFileChangeEvent - 发送文件变更事件', () => { + it('发送文件创建事件', () => { + expect(() => { + emitFileChangeEvent('session-1', 'created', '/path/to/new/file.ts'); + }).not.toThrow(); + }); + + it('发送文件修改事件', () => { + expect(() => { + emitFileChangeEvent('session-1', 'modified', '/path/to/existing/file.ts'); + }).not.toThrow(); + }); + + it('发送文件删除事件', () => { + expect(() => { + emitFileChangeEvent('session-1', 'deleted', '/path/to/removed/file.ts'); + }).not.toThrow(); + }); + }); + + describe('getSSEStats - 获取统计信息', () => { + it('无订阅者时返回 0', () => { + const stats = getSSEStats(); + // 由于没有实际连接,应该至少有这些属性 + expect(stats).toHaveProperty('sessions'); + expect(stats).toHaveProperty('subscribers'); + expect(typeof stats.sessions).toBe('number'); + expect(typeof stats.subscribers).toBe('number'); + }); + }); +}); diff --git a/packages/server/tests/unit/ws.test.ts b/packages/server/tests/unit/ws.test.ts new file mode 100644 index 0000000..5d7d10c --- /dev/null +++ b/packages/server/tests/unit/ws.test.ts @@ -0,0 +1,346 @@ +/** + * WebSocket Handler 测试 + * + * 测试 WebSocket 连接处理、消息路由、广播功能等 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Create mock functions +const mockExists = vi.fn(); +const mockUpdateStatus = vi.fn(); +const mockAddMessage = vi.fn(); +const mockGet = vi.fn(); + +// Mock dependencies before imports +vi.mock('../../src/session/manager.js', () => ({ + getSessionManager: vi.fn(() => ({ + exists: mockExists, + updateStatus: mockUpdateStatus, + addMessage: mockAddMessage, + get: mockGet, + })), +})); + +vi.mock('../../src/agent/index.js', () => ({ + processMessage: vi.fn().mockResolvedValue(undefined), + cancelProcessing: vi.fn(), +})); + +vi.mock('../../src/permission/handler.js', () => ({ + handlePermissionResponse: vi.fn().mockReturnValue(true), +})); + +import { + handleWebSocket, + handleWebSocketMessage, + handleWebSocketClose, + broadcastToSession, + getSessionConnections, + getConnectionStats, +} from '../../src/ws.js'; +import { processMessage, cancelProcessing } from '../../src/agent/index.js'; +import { handlePermissionResponse } from '../../src/permission/handler.js'; + +// Mock WSContext +function createMockWSContext() { + return { + send: vi.fn(), + close: vi.fn(), + }; +} + +// Counter to generate unique session IDs for test isolation +let testCounter = 0; +function getUniqueSessionId(prefix = 'session') { + return `${prefix}-${Date.now()}-${testCounter++}`; +} + +describe('WebSocket Handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExists.mockReturnValue(false); + mockAddMessage.mockReturnValue({ id: 'msg-1', role: 'user', content: '', timestamp: Date.now() }); + }); + + describe('handleWebSocket - 连接处理', () => { + it('无效 session 发送错误并关闭连接', () => { + const ws = createMockWSContext(); + mockExists.mockReturnValue(false); + + handleWebSocket(ws as any, 'invalid-session'); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"error"') + ); + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('Session not found') + ); + expect(ws.close).toHaveBeenCalledWith(4004, 'Session not found'); + }); + + it('有效 session 注册连接并发送 connected 消息', () => { + const ws = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws as any, 'session-1'); + + expect(ws.close).not.toHaveBeenCalled(); + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"connected"') + ); + expect(mockUpdateStatus).toHaveBeenCalledWith('session-1', 'active'); + }); + + it('连接被添加到 connections map', () => { + const ws = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws as any, 'session-1'); + + const connections = getSessionConnections('session-1'); + expect(connections.has(ws as any)).toBe(true); + }); + }); + + describe('handleWebSocketMessage - 消息处理', () => { + beforeEach(() => { + mockExists.mockReturnValue(true); + }); + + it('处理 message 类型消息', async () => { + const ws = createMockWSContext(); + const message = JSON.stringify({ + type: 'message', + payload: { content: 'Hello AI' }, + }); + + await handleWebSocketMessage(ws as any, 'session-1', message); + + expect(mockAddMessage).toHaveBeenCalledWith('session-1', { + role: 'user', + content: 'Hello AI', + }); + expect(processMessage).toHaveBeenCalledWith('session-1', 'Hello AI'); + }); + + it('处理 cancel 类型消息', async () => { + const ws = createMockWSContext(); + handleWebSocket(ws as any, 'session-1'); + + const message = JSON.stringify({ type: 'cancel' }); + await handleWebSocketMessage(ws as any, 'session-1', message); + + expect(cancelProcessing).toHaveBeenCalledWith('session-1'); + // 应该广播 cancelled 消息 + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"cancelled"') + ); + }); + + it('处理 permission_response 类型消息', async () => { + const ws = createMockWSContext(); + const message = JSON.stringify({ + type: 'permission_response', + payload: { requestId: 'req-123', allow: true, remember: false }, + }); + + await handleWebSocketMessage(ws as any, 'session-1', message); + + expect(handlePermissionResponse).toHaveBeenCalledWith('req-123', true, false); + }); + + it('未知消息类型返回错误', async () => { + const ws = createMockWSContext(); + const message = JSON.stringify({ type: 'unknown_type' }); + + await handleWebSocketMessage(ws as any, 'session-1', message); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"error"') + ); + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('Unknown message type') + ); + }); + + it('无效 JSON 返回错误', async () => { + const ws = createMockWSContext(); + await handleWebSocketMessage(ws as any, 'session-1', 'invalid json'); + + expect(ws.send).toHaveBeenCalledWith( + expect.stringContaining('"type":"error"') + ); + }); + + it('处理 ArrayBuffer 数据', async () => { + const ws = createMockWSContext(); + + const message = { type: 'message', payload: { content: 'ArrayBuffer test' } }; + const encoder = new TextEncoder(); + const buffer = encoder.encode(JSON.stringify(message)).buffer; + + await handleWebSocketMessage(ws as any, 'session-1', buffer); + + expect(mockAddMessage).toHaveBeenCalledWith('session-1', { + role: 'user', + content: 'ArrayBuffer test', + }); + }); + + it('空 content 处理正确', async () => { + const ws = createMockWSContext(); + const message = JSON.stringify({ + type: 'message', + payload: {}, + }); + + await handleWebSocketMessage(ws as any, 'session-1', message); + + expect(mockAddMessage).toHaveBeenCalledWith('session-1', { + role: 'user', + content: '', + }); + }); + }); + + describe('handleWebSocketClose - 关闭处理', () => { + it('从 connections 中移除连接', () => { + const sessionId = getUniqueSessionId('close'); + const ws = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws as any, sessionId); + expect(getSessionConnections(sessionId).size).toBe(1); + + handleWebSocketClose(ws as any, sessionId); + expect(getSessionConnections(sessionId).size).toBe(0); + }); + + it('最后一个连接关闭时更新 session 状态为 idle', () => { + const sessionId = getUniqueSessionId('idle'); + const ws = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws as any, sessionId); + handleWebSocketClose(ws as any, sessionId); + + expect(mockUpdateStatus).toHaveBeenLastCalledWith(sessionId, 'idle'); + }); + + it('多个连接时关闭一个不影响其他', () => { + const sessionId = getUniqueSessionId('multi'); + const ws1 = createMockWSContext(); + const ws2 = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws1 as any, sessionId); + handleWebSocket(ws2 as any, sessionId); + expect(getSessionConnections(sessionId).size).toBe(2); + + handleWebSocketClose(ws1 as any, sessionId); + expect(getSessionConnections(sessionId).size).toBe(1); + expect(getSessionConnections(sessionId).has(ws2 as any)).toBe(true); + }); + }); + + describe('broadcastToSession - 会话广播', () => { + it('向所有连接发送消息', () => { + const ws1 = createMockWSContext(); + const ws2 = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws1 as any, 'session-1'); + handleWebSocket(ws2 as any, 'session-1'); + + vi.clearAllMocks(); + + broadcastToSession('session-1', { + type: 'chunk', + sessionId: 'session-1', + payload: { content: 'test' }, + }); + + expect(ws1.send).toHaveBeenCalledTimes(1); + expect(ws2.send).toHaveBeenCalledTimes(1); + expect(ws1.send).toHaveBeenCalledWith(expect.stringContaining('"type":"chunk"')); + }); + + it('无连接时不抛出错误', () => { + expect(() => { + broadcastToSession('non-existent', { + type: 'chunk', + sessionId: 'non-existent', + payload: {}, + }); + }).not.toThrow(); + }); + + it('发送失败时继续发送给其他连接', () => { + mockExists.mockReturnValue(true); + + const ws1 = createMockWSContext(); + const ws2 = createMockWSContext(); + + handleWebSocket(ws1 as any, 'session-1'); + handleWebSocket(ws2 as any, 'session-1'); + + // Now set up ws1.send to throw + ws1.send.mockImplementation(() => { + throw new Error('Connection closed'); + }); + + expect(() => { + broadcastToSession('session-1', { + type: 'chunk', + sessionId: 'session-1', + payload: {}, + }); + }).not.toThrow(); + + expect(ws2.send).toHaveBeenCalled(); + }); + }); + + describe('getSessionConnections - 获取会话连接', () => { + it('返回会话的所有连接', () => { + const sessionId = getUniqueSessionId('get-conns'); + const ws = createMockWSContext(); + mockExists.mockReturnValue(true); + + handleWebSocket(ws as any, sessionId); + + const connections = getSessionConnections(sessionId); + expect(connections.size).toBe(1); + }); + + it('不存在的会话返回空 Set', () => { + const connections = getSessionConnections('non-existent-unique-12345'); + expect(connections.size).toBe(0); + }); + }); + + describe('getConnectionStats - 连接统计', () => { + it('返回正确的统计信息', () => { + // Get initial stats to account for connections from previous tests + const initialStats = getConnectionStats(); + + const sessionId1 = getUniqueSessionId('stats-1'); + const sessionId2 = getUniqueSessionId('stats-2'); + const ws1 = createMockWSContext(); + const ws2 = createMockWSContext(); + const ws3 = createMockWSContext(); + + mockExists.mockReturnValue(true); + + handleWebSocket(ws1 as any, sessionId1); + handleWebSocket(ws2 as any, sessionId1); + handleWebSocket(ws3 as any, sessionId2); + + const stats = getConnectionStats(); + // We added 2 sessions and 3 connections + expect(stats.sessions).toBe(initialStats.sessions + 2); + expect(stats.connections).toBe(initialStats.connections + 3); + }); + }); +}); diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts new file mode 100644 index 0000000..bae6131 --- /dev/null +++ b/packages/server/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/bin/**'], + }, + setupFiles: ['tests/setup.ts'], + testTimeout: 10000, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77bf83d..62eb28a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: ai: specifier: ^5.0.108 version: 5.0.112(zod@4.1.13) - chalk: - specifier: ^5.3.0 - version: 5.6.2 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -216,6 +213,9 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 + '@vitest/coverage-v8': + specifier: ^4.0.15 + version: 4.0.15(vitest@4.0.15(@opentelemetry/api@1.9.0)(@types/node@22.19.2)(jiti@1.21.7)(terser@5.44.1)(yaml@2.8.2)) typescript: specifier: ^5.6.0 version: 5.9.3