import { describe, it, expect, vi, beforeEach } from 'vitest'; import { InstanceService, InvalidStateError } from '../src/services/instance.service.js'; import { NotFoundError } from '../src/services/mcp-server.service.js'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js'; import type { McpOrchestrator } from '../src/services/orchestrator.js'; function mockInstanceRepo(): IMcpInstanceRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByContainerId: vi.fn(async () => null), create: vi.fn(async (data) => ({ id: 'inst-1', serverId: data.serverId, containerId: data.containerId ?? null, status: data.status ?? 'STOPPED', port: data.port ?? null, metadata: data.metadata ?? {}, version: 1, createdAt: new Date(), updatedAt: new Date(), })), updateStatus: vi.fn(async (id, status, fields) => ({ id, serverId: 'srv-1', containerId: fields?.containerId ?? 'ctr-abc', status, port: fields?.port ?? null, metadata: fields?.metadata ?? {}, version: 2, createdAt: new Date(), updatedAt: new Date(), })), delete: vi.fn(async () => {}), }; } function mockServerRepo(): IMcpServerRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async () => ({} as never)), update: vi.fn(async () => ({} as never)), delete: vi.fn(async () => {}), }; } function mockOrchestrator(): McpOrchestrator { return { ping: vi.fn(async () => true), pullImage: vi.fn(async () => {}), createContainer: vi.fn(async (spec) => ({ containerId: 'ctr-abc123', name: spec.name, state: 'running' as const, port: 3000, createdAt: new Date(), })), stopContainer: vi.fn(async () => {}), removeContainer: vi.fn(async () => {}), inspectContainer: vi.fn(async () => ({ containerId: 'ctr-abc123', name: 'test', state: 'running' as const, createdAt: new Date(), })), getContainerLogs: vi.fn(async () => ({ stdout: 'log output', stderr: '' })), }; } describe('InstanceService', () => { let instanceRepo: ReturnType; let serverRepo: ReturnType; let orchestrator: ReturnType; let service: InstanceService; beforeEach(() => { instanceRepo = mockInstanceRepo(); serverRepo = mockServerRepo(); orchestrator = mockOrchestrator(); service = new InstanceService(instanceRepo, serverRepo, orchestrator); }); describe('list', () => { it('lists all instances', async () => { const result = await service.list(); expect(instanceRepo.findAll).toHaveBeenCalledWith(undefined); expect(result).toEqual([]); }); it('filters by serverId', async () => { await service.list('srv-1'); expect(instanceRepo.findAll).toHaveBeenCalledWith('srv-1'); }); }); describe('getById', () => { it('throws NotFoundError when not found', async () => { await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); it('returns instance when found', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never); const result = await service.getById('inst-1'); expect(result.id).toBe('inst-1'); }); }); describe('start', () => { it('throws NotFoundError for unknown server', async () => { await expect(service.start('missing')).rejects.toThrow(NotFoundError); }); it('creates instance and starts container', async () => { vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest', packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.start('srv-1'); expect(instanceRepo.create).toHaveBeenCalledWith({ serverId: 'srv-1', status: 'STARTING', }); expect(orchestrator.createContainer).toHaveBeenCalled(); expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', 'RUNNING', expect.objectContaining({ containerId: 'ctr-abc123' }), ); expect(result.status).toBe('RUNNING'); }); it('marks instance as ERROR on container failure', async () => { vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest', packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }); vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable')); const result = await service.start('srv-1'); expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', 'ERROR', expect.objectContaining({ metadata: { error: 'Docker unavailable' } }), ); expect(result.status).toBe('ERROR'); }); it('uses dockerImage for container spec', async () => { vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1', packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }); await service.start('srv-1'); const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0]; expect(spec?.image).toBe('myregistry.com/slack:v1'); expect(spec?.containerPort).toBe(3000); // SSE transport }); }); describe('stop', () => { it('throws NotFoundError for missing instance', async () => { await expect(service.stop('missing')).rejects.toThrow(NotFoundError); }); it('stops a running container', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', serverId: 'srv-1', port: 3000, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); await service.stop('inst-1'); expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED'); }); it('handles stop without containerId', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: null, status: 'ERROR', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); await service.stop('inst-1'); expect(orchestrator.stopContainer).not.toHaveBeenCalled(); expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED'); }); it('throws InvalidStateError when already stopped', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError); }); }); describe('restart', () => { it('stops, removes, and starts a new instance', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', serverId: 'srv-1', port: 3000, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'slack', dockerImage: 'slack:latest', packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.restart('inst-1'); expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); expect(instanceRepo.create).toHaveBeenCalled(); expect(result.status).toBe('RUNNING'); }); it('handles restart when container already stopped', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'slack', dockerImage: 'slack:latest', packageName: null, transport: 'STDIO', description: '', repositoryUrl: null, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.restart('inst-1'); // Should not try to stop an already-stopped container expect(orchestrator.stopContainer).not.toHaveBeenCalled(); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); expect(result.status).toBe('RUNNING'); }); }); describe('inspect', () => { it('returns container info', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', serverId: 'srv-1', port: 3000, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.inspect('inst-1'); expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc'); expect(result.containerId).toBe('ctr-abc123'); }); it('throws InvalidStateError when no container', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: null, status: 'ERROR', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError); }); }); describe('remove', () => { it('removes container and DB record', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); await service.remove('inst-1'); expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); }); it('removes DB record even if container is already gone', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container')); await service.remove('inst-1'); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); }); }); describe('getLogs', () => { it('returns empty logs for instance without container', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: null, status: 'ERROR', serverId: 'srv-1', port: null, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.getLogs('inst-1'); expect(result).toEqual({ stdout: '', stderr: '' }); }); it('returns container logs', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING', serverId: 'srv-1', port: 3000, metadata: {}, version: 1, createdAt: new Date(), updatedAt: new Date(), }); const result = await service.getLogs('inst-1', { tail: 50 }); expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 }); expect(result.stdout).toBe('log output'); }); }); });