test(server): 添加 routes 模块单元测试
- agents.test.ts: Agent presets CRUD API (18 tests) - tools.test.ts: Tool registration & execution (10 tests) - files.test.ts: File browser API with path security (24 tests) - commands.test.ts: Slash commands CRUD & execution (20 tests) - hooks.test.ts: Hooks configuration management (20 tests) 覆盖率从 29.59% 提升到 60.35%,共 287 个测试
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Commands Route 测试
|
||||
*
|
||||
* 测试斜杠命令管理 REST API 端点
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// Create mock command module
|
||||
const mockCommandRegistry = vi.hoisted(() => ({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
list: vi.fn(),
|
||||
search: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCommandExecutor = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCommandManager = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getContent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCommandModule = vi.hoisted(() => ({
|
||||
getCommandRegistry: vi.fn(() => mockCommandRegistry),
|
||||
createCommandExecutor: vi.fn(() => mockCommandExecutor),
|
||||
createCommandManager: vi.fn(() => mockCommandManager),
|
||||
}));
|
||||
|
||||
// Track if module should be available
|
||||
let moduleAvailable = true;
|
||||
|
||||
vi.mock('@ai-assistant/core', () => {
|
||||
if (!moduleAvailable) {
|
||||
throw new Error('Module not found');
|
||||
}
|
||||
return mockCommandModule;
|
||||
});
|
||||
|
||||
vi.mock('../../../src/routes/config.js', () => ({
|
||||
getConfig: vi.fn(() => ({ workdir: '/test/workdir' })),
|
||||
}));
|
||||
|
||||
import { commandsRouter } from '../../../src/routes/commands.js';
|
||||
|
||||
// Create test app
|
||||
const app = new Hono();
|
||||
app.route('/commands', commandsRouter);
|
||||
|
||||
describe('Commands Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
moduleAvailable = true;
|
||||
mockCommandRegistry.list.mockReturnValue([]);
|
||||
mockCommandRegistry.getStats.mockReturnValue({ total: 0, bySource: {} });
|
||||
});
|
||||
|
||||
describe('GET /commands - 列出所有命令', () => {
|
||||
it('返回命令列表', async () => {
|
||||
mockCommandRegistry.list.mockReturnValue([
|
||||
{ name: 'test', description: 'Test command', source: 'builtin' },
|
||||
{ name: 'deploy', description: 'Deploy command', source: 'project' },
|
||||
]);
|
||||
mockCommandRegistry.getStats.mockReturnValue({
|
||||
total: 2,
|
||||
bySource: { builtin: 1, project: 1 },
|
||||
});
|
||||
|
||||
const res = await app.request('/commands');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.commands).toHaveLength(2);
|
||||
expect(json.data.stats.total).toBe(2);
|
||||
});
|
||||
|
||||
it('返回空列表', async () => {
|
||||
const res = await app.request('/commands');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.data.commands).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /commands/search - 搜索命令', () => {
|
||||
it('搜索返回结果', async () => {
|
||||
mockCommandRegistry.search.mockReturnValue([
|
||||
{
|
||||
command: { name: 'test', description: 'Test', source: 'builtin', template: 'echo test' },
|
||||
score: 0.9,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.request('/commands/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: 'test', limit: 10 }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data).toHaveLength(1);
|
||||
expect(json.data[0].score).toBe(0.9);
|
||||
});
|
||||
|
||||
it('缺少查询参数返回 400', async () => {
|
||||
const res = await app.request('/commands/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /commands/reload - 重新加载命令', () => {
|
||||
it('重新加载成功', async () => {
|
||||
mockCommandRegistry.getStats.mockReturnValue({
|
||||
total: 5,
|
||||
bySource: { builtin: 3, project: 2 },
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/reload', { method: 'POST' });
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.message).toBe('Commands reloaded');
|
||||
expect(mockCommandRegistry.reload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /commands/:name - 获取命令详情', () => {
|
||||
it('返回命令详情', async () => {
|
||||
mockCommandRegistry.get.mockReturnValue({
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
template: 'echo test',
|
||||
source: 'builtin',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.name).toBe('test');
|
||||
expect(json.data.hasTemplate).toBe(true);
|
||||
});
|
||||
|
||||
it('命令不存在返回 404', async () => {
|
||||
mockCommandRegistry.get.mockReturnValue(undefined);
|
||||
|
||||
const res = await app.request('/commands/non-existent');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('支持包含斜杠的命令名', async () => {
|
||||
mockCommandRegistry.get.mockReturnValue({
|
||||
name: 'deploy/staging',
|
||||
description: 'Deploy to staging',
|
||||
template: 'deploy staging',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/deploy/staging');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.data.name).toBe('deploy/staging');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /commands/:name/execute - 执行命令', () => {
|
||||
it('执行成功', async () => {
|
||||
mockCommandRegistry.get.mockReturnValue({
|
||||
name: 'test',
|
||||
template: 'echo {{args}}',
|
||||
source: 'builtin',
|
||||
});
|
||||
mockCommandExecutor.execute.mockResolvedValue({
|
||||
success: true,
|
||||
prompt: 'echo hello',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ arguments: 'hello' }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.prompt).toBe('echo hello');
|
||||
});
|
||||
|
||||
it('执行失败返回 400', async () => {
|
||||
mockCommandExecutor.execute.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Command not found',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
});
|
||||
|
||||
it('无请求体时默认空参数', async () => {
|
||||
mockCommandExecutor.execute.mockResolvedValue({
|
||||
success: true,
|
||||
prompt: 'test',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test/execute', {
|
||||
method: 'POST',
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockCommandExecutor.execute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /commands - 创建命令', () => {
|
||||
it('创建成功', async () => {
|
||||
mockCommandManager.create.mockResolvedValue({
|
||||
success: true,
|
||||
path: '/test/.claude/commands/my-command.md',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'my-command',
|
||||
description: 'My command',
|
||||
template: 'echo hello',
|
||||
scope: 'project',
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.name).toBe('my-command');
|
||||
});
|
||||
|
||||
it('缺少必需字段返回 400', async () => {
|
||||
const res = await app.request('/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'test' }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
});
|
||||
|
||||
it('创建失败返回错误', async () => {
|
||||
mockCommandManager.create.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Command already exists',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'existing',
|
||||
template: 'echo test',
|
||||
scope: 'project',
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.error).toContain('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /commands/:name/content - 获取命令完整内容', () => {
|
||||
// Note: Due to Hono route matching, /:name{.+} matches before /:name{.+}/content
|
||||
// So /test/content is interpreted as getting command "test/content" via the detail route
|
||||
// This is a known issue in the source code - the /content endpoint is shadowed
|
||||
|
||||
it('路由被 /:name{.+} 遮蔽,实际请求 test/content 作为命令名', async () => {
|
||||
// The request goes to GET /:name{.+} with name="test/content"
|
||||
// Not to GET /:name{.+}/content
|
||||
mockCommandRegistry.get.mockReturnValue({
|
||||
name: 'test/content',
|
||||
description: 'A command with /content in name',
|
||||
template: 'echo test',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test/content');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
// The detail route returns hasTemplate, not template
|
||||
expect(json.data.hasTemplate).toBe(true);
|
||||
expect(json.data.template).toBeUndefined();
|
||||
});
|
||||
|
||||
it('当命令不存在时返回 404', async () => {
|
||||
// This also goes through GET /:name{.+} with name="non-existent/content"
|
||||
mockCommandRegistry.get.mockReturnValue(undefined);
|
||||
|
||||
const res = await app.request('/commands/non-existent/content');
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toContain('non-existent/content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /commands/:name - 更新命令', () => {
|
||||
it('更新成功', async () => {
|
||||
mockCommandManager.update.mockResolvedValue({
|
||||
success: true,
|
||||
path: '/test/.claude/commands/test.md',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'echo updated' }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
});
|
||||
|
||||
it('更新失败返回 400', async () => {
|
||||
mockCommandManager.update.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Cannot update builtin command',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/builtin-cmd', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ template: 'new template' }),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /commands/:name - 删除命令', () => {
|
||||
it('删除成功', async () => {
|
||||
mockCommandManager.delete.mockResolvedValue({
|
||||
success: true,
|
||||
path: '/test/.claude/commands/test.md',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/test', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
});
|
||||
|
||||
it('删除失败返回 400', async () => {
|
||||
mockCommandManager.delete.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Cannot delete builtin command',
|
||||
});
|
||||
|
||||
const res = await app.request('/commands/builtin-cmd', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user