test(server): 添加 server 模块单元测试

- 新增 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%
This commit is contained in:
2025-12-15 00:07:32 +08:00
parent 503e4c4ccd
commit 5835799b69
17 changed files with 3258 additions and 3 deletions
+1
View File
@@ -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"
}
+167
View File
@@ -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<ReturnType<typeof createMockProviderRegistry>> = {}) {
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<ReturnType<typeof createMockAgentRegistry>> = {}) {
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<ReturnType<typeof createMockPermissionManager>> = {}) {
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<ReturnType<typeof createMockToolRegistry>> = {}) {
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<ReturnType<typeof createMockAgent>>;
providerRegistry?: Partial<ReturnType<typeof createMockProviderRegistry>>;
agentRegistry?: Partial<ReturnType<typeof createMockAgentRegistry>>;
permissionManager?: Partial<ReturnType<typeof createMockPermissionManager>>;
toolRegistry?: Partial<ReturnType<typeof createMockToolRegistry>>;
loadConfig?: ReturnType<typeof vi.fn>;
saveConfig?: ReturnType<typeof vi.fn>;
} = {}) {
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;
});
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Hono 框架 Mock 工厂
*
* 提供 Hono Context 和 WebSocket Context 的 mock 实现
*/
import { vi } from 'vitest';
/**
* 创建 Mock Hono Context
*/
export function createMockHonoContext(options: {
params?: Record<string, string>;
body?: unknown;
query?: Record<string, string>;
headers?: Record<string, string>;
} = {}) {
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);
}
+152
View File
@@ -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<string, MockSession>();
const messages = new Map<string, MockMessage[]>();
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<MockSession>) => {
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> = {}): 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> = {}): MockMessage {
return {
id: 'test-msg-1',
role: 'user',
content: 'Test message',
timestamp: Date.now(),
...overrides,
};
}
+23
View File
@@ -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';
@@ -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');
});
});
});
@@ -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();
});
});
});
@@ -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();
});
});
});
@@ -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');
});
});
});
@@ -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');
});
});
});
@@ -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);
});
});
});
@@ -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');
});
});
});
@@ -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);
});
});
+141
View File
@@ -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');
});
});
});
+346
View File
@@ -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);
});
});
});
+18
View File
@@ -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,
},
});
+3 -3
View File
@@ -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