2026-02-21 04:26:18 +00:00
|
|
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
|
|
|
import Fastify from 'fastify';
|
|
|
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
|
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
|
|
|
|
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
2026-02-22 13:30:46 +00:00
|
|
|
import { InstanceService } from '../src/services/instance.service.js';
|
2026-02-21 04:26:18 +00:00
|
|
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
2026-02-22 13:30:46 +00:00
|
|
|
import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js';
|
|
|
|
|
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
2026-02-21 04:26:18 +00:00
|
|
|
|
|
|
|
|
let app: FastifyInstance;
|
|
|
|
|
|
|
|
|
|
function mockRepo(): IMcpServerRepository {
|
2026-02-22 13:30:46 +00:00
|
|
|
let lastCreated: Record<string, unknown> | null = null;
|
2026-02-21 04:26:18 +00:00
|
|
|
return {
|
|
|
|
|
findAll: vi.fn(async () => [
|
2026-02-22 13:30:46 +00:00
|
|
|
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 },
|
2026-02-21 04:26:18 +00:00
|
|
|
]),
|
2026-02-22 13:30:46 +00:00
|
|
|
findById: vi.fn(async (id: string) => {
|
|
|
|
|
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
|
|
|
|
|
return null;
|
|
|
|
|
}),
|
2026-02-21 04:26:18 +00:00
|
|
|
findByName: vi.fn(async () => null),
|
2026-02-22 13:30:46 +00:00
|
|
|
create: vi.fn(async (data) => {
|
|
|
|
|
const server = {
|
|
|
|
|
id: 'new-id',
|
|
|
|
|
name: data.name,
|
|
|
|
|
description: data.description ?? '',
|
|
|
|
|
packageName: data.packageName ?? null,
|
|
|
|
|
dockerImage: null,
|
|
|
|
|
transport: data.transport ?? 'STDIO',
|
|
|
|
|
repositoryUrl: null,
|
|
|
|
|
externalUrl: null,
|
|
|
|
|
command: null,
|
|
|
|
|
containerPort: null,
|
|
|
|
|
replicas: data.replicas ?? 1,
|
|
|
|
|
envTemplate: [],
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
lastCreated = server;
|
|
|
|
|
return server;
|
|
|
|
|
}),
|
|
|
|
|
update: vi.fn(async (id, data) => {
|
|
|
|
|
const server = {
|
|
|
|
|
id,
|
|
|
|
|
name: 'slack',
|
|
|
|
|
description: (data.description as string) ?? 'Slack server',
|
|
|
|
|
packageName: null,
|
|
|
|
|
dockerImage: null,
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
repositoryUrl: null,
|
|
|
|
|
externalUrl: null,
|
|
|
|
|
command: null,
|
|
|
|
|
containerPort: null,
|
|
|
|
|
replicas: 1,
|
|
|
|
|
envTemplate: [],
|
|
|
|
|
version: 2,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
};
|
|
|
|
|
lastCreated = server;
|
|
|
|
|
return server;
|
|
|
|
|
}),
|
|
|
|
|
delete: vi.fn(async () => {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
if (app) await app.close();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function stubInstanceRepo(): IMcpInstanceRepository {
|
|
|
|
|
return {
|
|
|
|
|
findAll: vi.fn(async () => []),
|
|
|
|
|
findById: vi.fn(async () => null),
|
|
|
|
|
findByContainerId: vi.fn(async () => null),
|
2026-02-21 04:26:18 +00:00
|
|
|
create: vi.fn(async (data) => ({
|
2026-02-22 13:30:46 +00:00
|
|
|
id: 'inst-stub',
|
|
|
|
|
serverId: data.serverId,
|
|
|
|
|
containerId: null,
|
|
|
|
|
status: data.status ?? 'STOPPED',
|
|
|
|
|
port: null,
|
|
|
|
|
metadata: {},
|
2026-02-21 04:26:18 +00:00
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})),
|
2026-02-22 13:30:46 +00:00
|
|
|
updateStatus: vi.fn(async (id, status) => ({
|
2026-02-21 04:26:18 +00:00
|
|
|
id,
|
2026-02-22 13:30:46 +00:00
|
|
|
serverId: 'srv-1',
|
|
|
|
|
containerId: null,
|
|
|
|
|
status,
|
|
|
|
|
port: null,
|
|
|
|
|
metadata: {},
|
2026-02-21 04:26:18 +00:00
|
|
|
version: 2,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
})),
|
|
|
|
|
delete: vi.fn(async () => {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:30:46 +00:00
|
|
|
function stubOrchestrator(): McpOrchestrator {
|
|
|
|
|
return {
|
|
|
|
|
ping: vi.fn(async () => true),
|
|
|
|
|
pullImage: vi.fn(async () => {}),
|
|
|
|
|
createContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })),
|
|
|
|
|
stopContainer: vi.fn(async () => {}),
|
|
|
|
|
removeContainer: vi.fn(async () => {}),
|
|
|
|
|
inspectContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, createdAt: new Date() })),
|
|
|
|
|
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-02-21 04:26:18 +00:00
|
|
|
|
|
|
|
|
function createApp(repo: IMcpServerRepository) {
|
|
|
|
|
app = Fastify({ logger: false });
|
|
|
|
|
app.setErrorHandler(errorHandler);
|
|
|
|
|
const service = new McpServerService(repo);
|
2026-02-22 13:30:46 +00:00
|
|
|
const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator());
|
|
|
|
|
service.setInstanceService(instanceService);
|
|
|
|
|
registerMcpServerRoutes(app, service, instanceService);
|
2026-02-21 04:26:18 +00:00
|
|
|
return app.ready();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('MCP Server Routes', () => {
|
|
|
|
|
describe('GET /api/v1/servers', () => {
|
|
|
|
|
it('returns server list', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/servers' });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const body = res.json<Array<{ name: string }>>();
|
|
|
|
|
expect(body).toHaveLength(1);
|
|
|
|
|
expect(body[0]?.name).toBe('slack');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('GET /api/v1/servers/:id', () => {
|
|
|
|
|
it('returns 404 when not found', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/missing' });
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns server when found', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/1' });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('POST /api/v1/servers', () => {
|
|
|
|
|
it('creates a server and returns 201', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/v1/servers',
|
|
|
|
|
payload: { name: 'new-server' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
|
|
|
expect(res.json<{ name: string }>().name).toBe('new-server');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 400 for invalid input', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/v1/servers',
|
|
|
|
|
payload: { name: '' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 409 when name already exists', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'POST',
|
|
|
|
|
url: '/api/v1/servers',
|
|
|
|
|
payload: { name: 'existing' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(409);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('PUT /api/v1/servers/:id', () => {
|
|
|
|
|
it('updates a server', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
url: '/api/v1/servers/1',
|
|
|
|
|
payload: { description: 'Updated' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 404 when not found', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
url: '/api/v1/servers/missing',
|
|
|
|
|
payload: { description: 'x' },
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('DELETE /api/v1/servers/:id', () => {
|
|
|
|
|
it('deletes a server and returns 204', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/1' });
|
|
|
|
|
expect(res.statusCode).toBe(204);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns 404 when not found', async () => {
|
|
|
|
|
const repo = mockRepo();
|
|
|
|
|
await createApp(repo);
|
|
|
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/missing' });
|
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|