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'; import { InstanceService } from '../src/services/instance.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js'; import type { McpOrchestrator } from '../src/services/orchestrator.js'; let app: FastifyInstance; function mockRepo(): IMcpServerRepository { let lastCreated: Record | null = null; return { findAll: vi.fn(async () => [ { id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 }, ]), findById: vi.fn(async (id: string) => { if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never; return null; }), findByName: vi.fn(async () => null), 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, env: [], 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, env: [], 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), create: vi.fn(async (data) => ({ id: 'inst-stub', serverId: data.serverId, containerId: null, status: data.status ?? 'STOPPED', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), })), updateStatus: vi.fn(async (id, status) => ({ id, serverId: 'srv-1', containerId: null, status, port: null, metadata: {}, version: 2, createdAt: new Date(), updatedAt: new Date(), })), delete: vi.fn(async () => {}), }; } 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: '' })), }; } function createApp(repo: IMcpServerRepository) { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); const service = new McpServerService(repo); const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator()); service.setInstanceService(instanceService); registerMcpServerRoutes(app, service, instanceService); 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>(); 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); }); }); });