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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Generated
+3
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user