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/bun": "^1.1.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitest/coverage-v8": "^4.0.15",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vitest": "^4.0.15"
|
"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:
|
ai:
|
||||||
specifier: ^5.0.108
|
specifier: ^5.0.108
|
||||||
version: 5.0.112(zod@4.1.13)
|
version: 5.0.112(zod@4.1.13)
|
||||||
chalk:
|
|
||||||
specifier: ^5.3.0
|
|
||||||
version: 5.6.2
|
|
||||||
js-yaml:
|
js-yaml:
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
@@ -216,6 +213,9 @@ importers:
|
|||||||
'@types/uuid':
|
'@types/uuid':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 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:
|
typescript:
|
||||||
specifier: ^5.6.0
|
specifier: ^5.6.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|||||||
Reference in New Issue
Block a user