import { describe, it, expect, vi, afterEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerProjectRoutes } from '../src/routes/projects.js'; import { ProjectService } from '../src/services/project.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js'; import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js'; let app: FastifyInstance; 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 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, })), 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 () => ({} as never)), update: vi.fn(async () => ({} as never)), 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 () => ({} as never)), update: vi.fn(async () => ({} as never)), delete: vi.fn(async () => {}), }; } afterEach(async () => { if (app) await app.close(); }); function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo()); registerProjectRoutes(app, service); return app.ready(); } describe('Project Routes', () => { describe('GET /api/v1/projects', () => { it('returns project list', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findAll).mockResolvedValue([ makeProject({ id: 'p1', name: 'alpha', ownerId: 'user-1' }), makeProject({ id: 'p2', name: 'beta', ownerId: 'user-2' }), ]); await createApp(repo); const res = await app.inject({ method: 'GET', url: '/api/v1/projects' }); expect(res.statusCode).toBe(200); const body = res.json>(); expect(body).toHaveLength(2); }); it('lists all projects without ownerId filtering', async () => { // This is the bug fix: the route must call list() without ownerId // so that RBAC (preSerialization) handles access filtering, not the DB query. const repo = mockProjectRepo(); vi.mocked(repo.findAll).mockResolvedValue([makeProject()]); await createApp(repo); await app.inject({ method: 'GET', url: '/api/v1/projects' }); // findAll must be called with NO arguments (undefined ownerId) expect(repo.findAll).toHaveBeenCalledWith(undefined); }); }); describe('GET /api/v1/projects/:id', () => { it('returns 404 when not found', async () => { const repo = mockProjectRepo(); await createApp(repo); const res = await app.inject({ method: 'GET', url: '/api/v1/projects/missing' }); expect(res.statusCode).toBe(404); }); it('returns project when found by ID', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1', name: 'my-proj' })); await createApp(repo); const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('my-proj'); }); it('resolves by name when ID not found', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findByName).mockResolvedValue(makeProject({ name: 'my-proj' })); await createApp(repo); const res = await app.inject({ method: 'GET', url: '/api/v1/projects/my-proj' }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('my-proj'); }); }); describe('POST /api/v1/projects', () => { it('creates a project and returns 201', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'new-proj' })); await createApp(repo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects', payload: { name: 'new-proj' }, }); expect(res.statusCode).toBe(201); }); it('returns 400 for invalid input', async () => { const repo = mockProjectRepo(); await createApp(repo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects', payload: { name: '' }, }); expect(res.statusCode).toBe(400); }); it('returns 409 when name already exists', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findByName).mockResolvedValue(makeProject()); await createApp(repo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects', payload: { name: 'taken' }, }); expect(res.statusCode).toBe(409); }); }); describe('PUT /api/v1/projects/:id', () => { it('updates a project', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await createApp(repo); const res = await app.inject({ method: 'PUT', url: '/api/v1/projects/p1', payload: { description: 'Updated' }, }); expect(res.statusCode).toBe(200); }); it('returns 404 when not found', async () => { const repo = mockProjectRepo(); await createApp(repo); const res = await app.inject({ method: 'PUT', url: '/api/v1/projects/missing', payload: { description: 'x' }, }); expect(res.statusCode).toBe(404); }); }); describe('DELETE /api/v1/projects/:id', () => { it('deletes a project and returns 204', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await createApp(repo); const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1' }); expect(res.statusCode).toBe(204); }); it('returns 404 when not found', async () => { const repo = mockProjectRepo(); await createApp(repo); const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/missing' }); expect(res.statusCode).toBe(404); }); }); describe('POST /api/v1/projects/:id/servers (attach)', () => { it('attaches a server to a project', async () => { const projectRepo = mockProjectRepo(); const serverRepo = mockServerRepo(); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never); await createApp(projectRepo, serverRepo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects/p1/servers', payload: { server: 'my-ha' }, }); expect(res.statusCode).toBe(200); expect(projectRepo.addServer).toHaveBeenCalledWith('p1', 'srv-1'); }); it('returns 400 when server field is missing', async () => { const repo = mockProjectRepo(); vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await createApp(repo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects/p1/servers', payload: {}, }); expect(res.statusCode).toBe(400); }); it('returns 404 when server not found', async () => { const projectRepo = mockProjectRepo(); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await createApp(projectRepo); const res = await app.inject({ method: 'POST', url: '/api/v1/projects/p1/servers', payload: { server: 'nonexistent' }, }); expect(res.statusCode).toBe(404); }); }); describe('DELETE /api/v1/projects/:id/servers/:serverName (detach)', () => { it('detaches a server from a project', async () => { const projectRepo = mockProjectRepo(); const serverRepo = mockServerRepo(); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never); await createApp(projectRepo, serverRepo); const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/my-ha' }); expect(res.statusCode).toBe(204); expect(projectRepo.removeServer).toHaveBeenCalledWith('p1', 'srv-1'); }); it('returns 404 when server not found', async () => { const projectRepo = mockProjectRepo(); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await createApp(projectRepo); const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/nonexistent' }); expect(res.statusCode).toBe(404); }); }); });