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, ISecretRepository } 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', proxyMode: 'direct', 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, proxyMode: data.proxyMode, 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 () => {}), }; } function mockSecretRepo(): ISecretRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })), update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })), delete: vi.fn(async () => {}), }; } describe('ProjectService', () => { let projectRepo: ReturnType; let serverRepo: ReturnType; let secretRepo: ReturnType; let service: ProjectService; beforeEach(() => { projectRepo = mockProjectRepo(); serverRepo = mockServerRepo(); secretRepo = mockSecretRepo(); service = new ProjectService(projectRepo, serverRepo, secretRepo); }); 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('creates project with proxyMode and llmProvider', async () => { const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' }); vi.mocked(projectRepo.create).mockResolvedValue(created); vi.mocked(projectRepo.findById).mockResolvedValue(created); const result = await service.create({ name: 'filtered-proj', proxyMode: 'filtered', llmProvider: 'openai', }, 'user-1'); expect(result.proxyMode).toBe('filtered'); expect(result.llmProvider).toBe('openai'); }); it('rejects filtered project without llmProvider', async () => { await expect( service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'), ).rejects.toThrow(); }); 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 proxyMode', async () => { const existing = makeProject({ id: 'proj-1' }); vi.mocked(projectRepo.findById).mockResolvedValue(existing); await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' }); expect(projectRepo.update).toHaveBeenCalledWith('proj-1', { proxyMode: 'filtered', 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 direct mode config with STDIO servers', async () => { const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' }); const project = makeProject({ id: 'proj-1', name: 'my-proj', proxyMode: 'direct', servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], }); vi.mocked(projectRepo.findById).mockResolvedValue(project); vi.mocked(serverRepo.findById).mockResolvedValue(srv); const config = await service.generateMcpConfig('proj-1'); expect(config.mcpServers['github']).toBeDefined(); expect(config.mcpServers['github']?.command).toBe('npx'); expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']); }); it('generates direct mode config with SSE servers (URL-based)', async () => { const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' }); const project = makeProject({ id: 'proj-1', proxyMode: 'direct', servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }], }); vi.mocked(projectRepo.findById).mockResolvedValue(project); vi.mocked(serverRepo.findById).mockResolvedValue(srv); const config = await service.generateMcpConfig('proj-1'); expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server'); expect(config.mcpServers['sse-server']?.command).toBeUndefined(); }); it('generates filtered mode config (single mcplocal entry)', async () => { const project = makeProject({ id: 'proj-1', name: 'filtered-proj', proxyMode: 'filtered', llmProvider: 'openai', 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['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj'); }); it('resolves by name for mcp-config', async () => { const project = makeProject({ id: 'proj-1', name: 'my-proj', proxyMode: 'direct', servers: [], }); vi.mocked(projectRepo.findById).mockResolvedValue(null); vi.mocked(projectRepo.findByName).mockResolvedValue(project); const config = await service.generateMcpConfig('my-proj'); expect(config.mcpServers).toEqual({}); }); it('includes env for STDIO servers', async () => { const srv = makeServer({ id: 'srv-1', name: 'github', transport: 'STDIO', env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }], }); const project = makeProject({ id: 'proj-1', proxyMode: 'direct', servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], }); vi.mocked(projectRepo.findById).mockResolvedValue(project); vi.mocked(serverRepo.findById).mockResolvedValue(srv); const config = await service.generateMcpConfig('proj-1'); expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123'); }); }); });