254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
|
import { InstanceService } 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<typeof mockInstanceRepo>;
|
||
|
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||
|
|
let orchestrator: ReturnType<typeof mockOrchestrator>;
|
||
|
|
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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
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');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|