From 0f2a93f2f0fd3bfeef7ad1535617c0a742bb39b6 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 23 Feb 2026 18:57:46 +0000 Subject: [PATCH] feat: add tests.sh runner and project routes integration tests - tests.sh: run all tests with `bash tests.sh`, summary with `--short` - tests.sh --filter mcpd/cli: run specific package - project-routes.test.ts: 17 new route-level tests covering CRUD, attach/detach, and the ownerId filtering bug fix Co-Authored-By: Claude Opus 4.6 --- src/mcpd/tests/project-routes.test.ts | 283 ++++++++++++++++++++++++++ tests.sh | 82 ++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/mcpd/tests/project-routes.test.ts create mode 100755 tests.sh diff --git a/src/mcpd/tests/project-routes.test.ts b/src/mcpd/tests/project-routes.test.ts new file mode 100644 index 0000000..d1d4a58 --- /dev/null +++ b/src/mcpd/tests/project-routes.test.ts @@ -0,0 +1,283 @@ +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); + }); + }); +}); diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..5ec71d4 --- /dev/null +++ b/tests.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +PATH="$HOME/.npm-global/bin:$PATH" + +SHORT=false +FILTER="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --short|-s) SHORT=true; shift ;; + --filter|-f) FILTER="$2"; shift 2 ;; + *) echo "Usage: tests.sh [--short|-s] [--filter|-f ]"; exit 1 ;; + esac +done + +strip_ansi() { + sed $'s/\033\[[0-9;]*m//g' +} + +run_tests() { + local pkg="$1" + local label="$2" + + if $SHORT; then + local tmpfile + tmpfile=$(mktemp) + trap "rm -f $tmpfile" RETURN + + local exit_code=0 + pnpm --filter "$pkg" test:run >"$tmpfile" 2>&1 || exit_code=$? + + # Parse from cleaned output + local clean + clean=$(strip_ansi < "$tmpfile") + + local tests_line files_line duration_line + tests_line=$(echo "$clean" | grep -oP 'Tests\s+\K.*' | tail -1 | xargs) + files_line=$(echo "$clean" | grep -oP 'Test Files\s+\K.*' | tail -1 | xargs) + duration_line=$(echo "$clean" | grep -oP 'Duration\s+\K[0-9.]+s' | tail -1) + + if [[ $exit_code -eq 0 ]]; then + printf " \033[32mPASS\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line" + else + printf " \033[31mFAIL\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line" + echo "$clean" | grep -E 'FAIL |AssertionError|expected .* to' | head -10 | sed 's/^/ /' + fi + + rm -f "$tmpfile" + return $exit_code + else + echo "=== $label ===" + pnpm --filter "$pkg" test:run + echo "" + fi +} + +if $SHORT; then + echo "Running tests..." + echo "" +fi + +failed=0 + +if [[ -z "$FILTER" || "$FILTER" == "mcpd" ]]; then + run_tests mcpd "mcpd" || failed=1 +fi + +if [[ -z "$FILTER" || "$FILTER" == "cli" ]]; then + run_tests cli "cli" || failed=1 +fi + +if $SHORT; then + echo "" + if [[ $failed -eq 0 ]]; then + echo "All tests passed." + else + echo "Some tests FAILED." + fi +fi + +exit $failed