import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ProjectService } from '../src/services/project.service.js'; import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js'; import type { IMcpServerRepository } from '../src/repositories/interfaces.js'; import type { McpServer } from '@prisma/client'; function makeProject(overrides: Partial = {}): ProjectWithRelations { return { id: 'proj-1', name: 'test-project', description: '', ownerId: 'user-1', proxyModel: '', gated: true, llmProvider: null, llmModel: null, version: 1, createdAt: new Date(), updatedAt: new Date(), servers: [], ...overrides, }; } function makeServer(overrides: Partial = {}): McpServer { return { id: 'srv-1', name: 'test-server', description: '', packageName: '@mcp/test', dockerImage: null, transport: 'STDIO', repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date(), templateName: null, templateVersion: null, ...overrides, }; } function mockProjectRepo(): IProjectRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async (data) => makeProject({ name: data.name, description: data.description, ownerId: data.ownerId, llmProvider: data.llmProvider ?? null, llmModel: data.llmModel ?? null, })), update: vi.fn(async (_id, data) => makeProject({ ...data as Partial })), delete: vi.fn(async () => {}), setServers: vi.fn(async () => {}), addServer: vi.fn(async () => {}), removeServer: 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 () => makeServer()), update: vi.fn(async () => makeServer()), delete: vi.fn(async () => {}), }; } describe('ProjectService', () => { let projectRepo: ReturnType; let serverRepo: ReturnType; let service: ProjectService; beforeEach(() => { projectRepo = mockProjectRepo(); serverRepo = mockServerRepo(); service = new ProjectService(projectRepo, serverRepo); }); describe('create', () => { it('creates a basic project', async () => { // After create, getById is called to re-fetch with relations const created = makeProject({ name: 'my-project', ownerId: 'user-1' }); vi.mocked(projectRepo.findById).mockResolvedValue(created); const result = await service.create({ name: 'my-project' }, 'user-1'); expect(result.name).toBe('my-project'); expect(result.ownerId).toBe('user-1'); expect(projectRepo.create).toHaveBeenCalled(); }); it('throws ConflictError when name exists', async () => { vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject()); await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError); }); it('validates input', async () => { await expect(service.create({ name: '' }, 'u1')).rejects.toThrow(); }); it('creates project with servers (resolves names)', async () => { const srv1 = makeServer({ id: 'srv-1', name: 'github' }); const srv2 = makeServer({ id: 'srv-2', name: 'slack' }); vi.mocked(serverRepo.findByName).mockImplementation(async (name) => { if (name === 'github') return srv1; if (name === 'slack') return srv2; return null; }); const created = makeProject({ id: 'proj-new' }); vi.mocked(projectRepo.create).mockResolvedValue(created); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-new', servers: [ { id: 'ps-1', server: { id: 'srv-1', name: 'github' } }, { id: 'ps-2', server: { id: 'srv-2', name: 'slack' } }, ], })); const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1'); expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-2']); expect(result.servers).toHaveLength(2); }); it('throws NotFoundError when server name resolution fails', async () => { vi.mocked(serverRepo.findByName).mockResolvedValue(null); await expect( service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'), ).rejects.toThrow(NotFoundError); }); }); describe('getById', () => { it('throws NotFoundError when not found', async () => { await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); it('returns project when found', async () => { const proj = makeProject({ id: 'found' }); vi.mocked(projectRepo.findById).mockResolvedValue(proj); const result = await service.getById('found'); expect(result.id).toBe('found'); }); }); describe('resolveAndGet', () => { it('finds by ID first', async () => { const proj = makeProject({ id: 'proj-id' }); vi.mocked(projectRepo.findById).mockResolvedValue(proj); const result = await service.resolveAndGet('proj-id'); expect(result.id).toBe('proj-id'); }); it('falls back to name when ID not found', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(null); const proj = makeProject({ name: 'my-name' }); vi.mocked(projectRepo.findByName).mockResolvedValue(proj); const result = await service.resolveAndGet('my-name'); expect(result.name).toBe('my-name'); }); it('throws NotFoundError when neither ID nor name found', async () => { await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError); }); }); describe('update', () => { it('updates servers (full replacement)', async () => { const existing = makeProject({ id: 'proj-1' }); vi.mocked(projectRepo.findById).mockResolvedValue(existing); const srv = makeServer({ id: 'srv-new', name: 'new-srv' }); vi.mocked(serverRepo.findByName).mockResolvedValue(srv); await service.update('proj-1', { servers: ['new-srv'] }); expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']); }); it('updates llmProvider', async () => { const existing = makeProject({ id: 'proj-1' }); vi.mocked(projectRepo.findById).mockResolvedValue(existing); await service.update('proj-1', { llmProvider: 'anthropic' }); expect(projectRepo.update).toHaveBeenCalledWith('proj-1', { llmProvider: 'anthropic', }); }); }); describe('delete', () => { it('deletes project', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await service.delete('p1'); expect(projectRepo.delete).toHaveBeenCalledWith('p1'); }); it('throws NotFoundError when project does not exist', async () => { await expect(service.delete('missing')).rejects.toThrow(NotFoundError); }); }); describe('addServer', () => { it('attaches a server by name', async () => { const project = makeProject({ id: 'proj-1' }); const srv = makeServer({ id: 'srv-1', name: 'my-ha' }); vi.mocked(projectRepo.findById).mockResolvedValue(project); vi.mocked(serverRepo.findByName).mockResolvedValue(srv); await service.addServer('proj-1', 'my-ha'); expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1'); }); it('throws NotFoundError when project not found', async () => { await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError); }); it('throws NotFoundError when server not found', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' })); vi.mocked(serverRepo.findByName).mockResolvedValue(null); await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError); }); }); describe('removeServer', () => { it('detaches a server by name', async () => { const project = makeProject({ id: 'proj-1' }); const srv = makeServer({ id: 'srv-1', name: 'my-ha' }); vi.mocked(projectRepo.findById).mockResolvedValue(project); vi.mocked(serverRepo.findByName).mockResolvedValue(srv); await service.removeServer('proj-1', 'my-ha'); expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1'); }); it('throws NotFoundError when project not found', async () => { await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError); }); it('throws NotFoundError when server not found', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' })); vi.mocked(serverRepo.findByName).mockResolvedValue(null); await expect(service.removeServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError); }); }); describe('generateMcpConfig', () => { it('generates single mcplocal proxy entry', async () => { const project = makeProject({ id: 'proj-1', name: 'my-proj', servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], }); vi.mocked(projectRepo.findById).mockResolvedValue(project); const config = await service.generateMcpConfig('proj-1'); expect(Object.keys(config.mcpServers)).toHaveLength(1); expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj'); }); it('resolves by name for mcp-config', async () => { const project = makeProject({ id: 'proj-1', name: 'my-proj', servers: [], }); vi.mocked(projectRepo.findById).mockResolvedValue(null); vi.mocked(projectRepo.findByName).mockResolvedValue(project); const config = await service.generateMcpConfig('my-proj'); expect(Object.keys(config.mcpServers)).toHaveLength(1); expect(config.mcpServers['my-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/my-proj'); }); }); });