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'; import type { McpInstance } from '@prisma/client'; 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: '' })), }; } function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) { return { id: overrides.id ?? 'srv-1', name: overrides.name ?? 'slack', dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest', packageName: null, transport: overrides.transport ?? 'STDIO', description: '', repositoryUrl: null, externalUrl: overrides.externalUrl ?? null, command: overrides.command ?? null, containerPort: overrides.containerPort ?? null, replicas: overrides.replicas ?? 1, env: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }; } function makeInstance(overrides: Partial = {}): McpInstance { return { id: 'inst-1', serverId: 'srv-1', containerId: overrides.containerId ?? 'ctr-abc', status: overrides.status ?? 'RUNNING', port: overrides.port ?? 3000, metadata: overrides.metadata ?? {}, version: 1, createdAt: overrides.createdAt ?? new Date(), updatedAt: new Date(), ...overrides, } as McpInstance; } 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(makeInstance({ id: 'inst-1' })); const result = await service.getById('inst-1'); expect(result.id).toBe('inst-1'); }); }); describe('reconcile', () => { it('starts instances when below desired replicas', async () => { vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 })); vi.mocked(instanceRepo.findAll).mockResolvedValue([]); await service.reconcile('srv-1'); // Should create 2 instances expect(instanceRepo.create).toHaveBeenCalledTimes(2); }); it('does nothing when at desired replicas', async () => { vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 })); vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]); await service.reconcile('srv-1'); expect(instanceRepo.create).not.toHaveBeenCalled(); expect(instanceRepo.delete).not.toHaveBeenCalled(); }); it('removes excess instances when above desired replicas', async () => { vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 })); vi.mocked(instanceRepo.findAll).mockResolvedValue([ makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }), makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }), ]); await service.reconcile('srv-1'); // Should remove the oldest one expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old'); }); it('creates external instances without Docker', async () => { vi.mocked(serverRepo.findById).mockResolvedValue( makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }), ); vi.mocked(instanceRepo.findAll).mockResolvedValue([]); await service.reconcile('srv-1'); expect(instanceRepo.create).toHaveBeenCalledWith( expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }), ); expect(orchestrator.createContainer).not.toHaveBeenCalled(); }); it('handles replicas: 0 by removing all instances', async () => { vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 })); vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]); await service.reconcile('srv-1'); expect(instanceRepo.delete).toHaveBeenCalledTimes(1); }); it('throws NotFoundError for unknown server', async () => { await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError); }); }); describe('remove', () => { it('stops container and deletes DB record', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); const result = await service.remove('inst-1'); expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc'); expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); expect(result.serverId).toBe('srv-1'); }); it('deletes DB record for external instance (no container)', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null })); await service.remove('inst-1'); expect(orchestrator.stopContainer).not.toHaveBeenCalled(); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); }); it('deletes DB record even if container is already gone', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container')); await service.remove('inst-1'); expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1'); }); }); describe('removeAllForServer', () => { it('stops all containers for a server', async () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([ makeInstance({ id: 'inst-1', containerId: 'ctr-1' }), makeInstance({ id: 'inst-2', containerId: 'ctr-2' }), ]); await service.removeAllForServer('srv-1'); expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2); expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2); }); it('skips external instances with no container', async () => { vi.mocked(instanceRepo.findAll).mockResolvedValue([ makeInstance({ id: 'inst-1', containerId: null }), ]); await service.removeAllForServer('srv-1'); expect(orchestrator.stopContainer).not.toHaveBeenCalled(); }); }); describe('inspect', () => { it('returns container info', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); 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(makeInstance({ containerId: null })); await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError); }); }); describe('getLogs', () => { it('returns empty logs for instance without container', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null })); const result = await service.getLogs('inst-1'); expect(result).toEqual({ stdout: '', stderr: '' }); }); it('returns container logs', async () => { vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' })); const result = await service.getLogs('inst-1', { tail: 50 }); expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 }); expect(result.stdout).toBe('log output'); }); }); });