import { describe, it, expect, vi, afterEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerPromptRoutes } from '../src/routes/prompts.js'; import { PromptService } from '../src/services/prompt.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; import type { IPromptRequestRepository } from '../src/repositories/prompt-request.repository.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js'; import type { Prompt, PromptRequest, Project } from '@prisma/client'; let app: FastifyInstance; function makePrompt(overrides: Partial = {}): Prompt { return { id: 'prompt-1', name: 'test-prompt', content: 'Hello world', projectId: null, priority: 5, summary: null, chapters: null, linkTarget: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } function makePromptRequest(overrides: Partial = {}): PromptRequest { return { id: 'req-1', name: 'test-request', content: 'Proposed content', projectId: null, priority: 5, createdBySession: 'session-abc', createdByUserId: null, createdAt: new Date(), ...overrides, }; } function makeProject(overrides: Partial = {}): Project { return { id: 'proj-1', name: 'homeautomation', description: '', prompt: '', proxyMode: 'direct', gated: true, llmProvider: null, llmModel: null, ownerId: 'user-1', createdAt: new Date(), updatedAt: new Date(), ...overrides, } as Project; } function mockPromptRepo(): IPromptRepository { return { findAll: vi.fn(async () => []), findGlobal: vi.fn(async () => []), findById: vi.fn(async () => null), findByNameAndProject: vi.fn(async () => null), create: vi.fn(async (data) => makePrompt(data)), update: vi.fn(async (id, data) => makePrompt({ id, ...data })), delete: vi.fn(async () => {}), }; } function mockPromptRequestRepo(): IPromptRequestRepository { return { findAll: vi.fn(async () => []), findGlobal: vi.fn(async () => []), findById: vi.fn(async () => null), findByNameAndProject: vi.fn(async () => null), findBySession: vi.fn(async () => []), create: vi.fn(async (data) => makePromptRequest(data)), update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })), delete: vi.fn(async () => {}), }; } function makeProjectWithServers( overrides: Partial = {}, serverNames: string[] = [], ) { return { ...makeProject(overrides), servers: serverNames.map((name, i) => ({ id: `ps-${i}`, projectId: overrides.id ?? 'proj-1', serverId: `srv-${i}`, server: { id: `srv-${i}`, name }, })), }; } function mockProjectRepo(): IProjectRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async (data) => makeProject(data)), update: vi.fn(async (_id, data) => makeProject({ ...data as Partial })), delete: vi.fn(async () => {}), }; } afterEach(async () => { if (app) await app.close(); }); function buildApp(opts?: { promptRepo?: IPromptRepository; promptRequestRepo?: IPromptRequestRepository; projectRepo?: IProjectRepository; }) { const promptRepo = opts?.promptRepo ?? mockPromptRepo(); const promptRequestRepo = opts?.promptRequestRepo ?? mockPromptRequestRepo(); const projectRepo = opts?.projectRepo ?? mockProjectRepo(); const service = new PromptService(promptRepo, promptRequestRepo, projectRepo); app = Fastify(); app.setErrorHandler(errorHandler); registerPromptRoutes(app, service, projectRepo); return { app, promptRepo, promptRequestRepo, projectRepo, service }; } describe('Prompt routes', () => { describe('GET /api/v1/prompts', () => { it('returns all prompts without project filter', async () => { const promptRepo = mockPromptRepo(); const globalPrompt = makePrompt({ id: 'p-1', name: 'global-rule', projectId: null }); const scopedPrompt = makePrompt({ id: 'p-2', name: 'scoped-rule', projectId: 'proj-1' }); vi.mocked(promptRepo.findAll).mockResolvedValue([globalPrompt, scopedPrompt]); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Prompt[]; expect(body).toHaveLength(2); expect(promptRepo.findAll).toHaveBeenCalledWith(undefined); }); it('filters by project name when ?project= is given', async () => { const promptRepo = mockPromptRepo(); const projectRepo = mockProjectRepo(); vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1', name: 'homeautomation' })); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'ha-rule', projectId: 'proj-1' }), makePrompt({ id: 'p-2', name: 'global-rule', projectId: null }), ]); const { app: a } = buildApp({ promptRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=homeautomation' }); expect(res.statusCode).toBe(200); expect(projectRepo.findByName).toHaveBeenCalledWith('homeautomation'); expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); }); it('returns only global prompts when ?scope=global', async () => { const promptRepo = mockPromptRepo(); const globalOnly = [makePrompt({ id: 'p-g', name: 'global-rule', projectId: null })]; vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalOnly); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?scope=global' }); expect(res.statusCode).toBe(200); const body = res.json() as Prompt[]; expect(body).toHaveLength(1); expect(promptRepo.findGlobal).toHaveBeenCalled(); expect(promptRepo.findAll).not.toHaveBeenCalled(); }); it('returns 404 when ?project= references unknown project', async () => { const { app: a } = buildApp(); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=nonexistent' }); expect(res.statusCode).toBe(404); const body = res.json() as { error: string }; expect(body.error).toContain('Project not found'); }); }); describe('GET /api/v1/promptrequests', () => { it('returns all prompt requests without project filter', async () => { const promptRequestRepo = mockPromptRequestRepo(); vi.mocked(promptRequestRepo.findAll).mockResolvedValue([ makePromptRequest({ id: 'r-1', name: 'req-a' }), ]); const { app: a } = buildApp({ promptRequestRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests' }); expect(res.statusCode).toBe(200); expect(promptRequestRepo.findAll).toHaveBeenCalledWith(undefined); }); it('returns only global prompt requests when ?scope=global', async () => { const promptRequestRepo = mockPromptRequestRepo(); vi.mocked(promptRequestRepo.findGlobal).mockResolvedValue([]); const { app: a } = buildApp({ promptRequestRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?scope=global' }); expect(res.statusCode).toBe(200); expect(promptRequestRepo.findGlobal).toHaveBeenCalled(); expect(promptRequestRepo.findAll).not.toHaveBeenCalled(); }); it('filters by project name when ?project= is given', async () => { const promptRequestRepo = mockPromptRequestRepo(); const projectRepo = mockProjectRepo(); vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' })); const { app: a } = buildApp({ promptRequestRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=homeautomation' }); expect(res.statusCode).toBe(200); expect(promptRequestRepo.findAll).toHaveBeenCalledWith('proj-1'); }); it('returns 404 for unknown project on promptrequests', async () => { const { app: a } = buildApp(); const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=nope' }); expect(res.statusCode).toBe(404); }); }); describe('POST /api/v1/promptrequests', () => { it('creates a global prompt request (no project)', async () => { const promptRequestRepo = mockPromptRequestRepo(); const { app: a } = buildApp({ promptRequestRepo }); const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests', payload: { name: 'global-req', content: 'some content' }, }); expect(res.statusCode).toBe(201); expect(promptRequestRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'global-req', content: 'some content' }), ); }); it('resolves project name to ID when project given', async () => { const promptRequestRepo = mockPromptRequestRepo(); const projectRepo = mockProjectRepo(); const proj = makeProject({ id: 'proj-1', name: 'myproj' }); vi.mocked(projectRepo.findByName).mockResolvedValue(proj); vi.mocked(projectRepo.findById).mockResolvedValue(proj); const { app: a } = buildApp({ promptRequestRepo, projectRepo }); const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests', payload: { name: 'scoped-req', content: 'text', project: 'myproj' }, }); expect(res.statusCode).toBe(201); expect(projectRepo.findByName).toHaveBeenCalledWith('myproj'); expect(promptRequestRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'scoped-req', projectId: 'proj-1' }), ); }); it('returns 404 for unknown project name', async () => { const { app: a } = buildApp(); const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests', payload: { name: 'bad-req', content: 'x', project: 'nope' }, }); expect(res.statusCode).toBe(404); }); }); describe('POST /api/v1/promptrequests/:id/approve', () => { it('atomically approves a prompt request', async () => { const promptRequestRepo = mockPromptRequestRepo(); const promptRepo = mockPromptRepo(); const req = makePromptRequest({ id: 'req-1', name: 'my-rule', projectId: 'proj-1' }); vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); const { app: a } = buildApp({ promptRepo, promptRequestRepo }); const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests/req-1/approve' }); expect(res.statusCode).toBe(200); expect(promptRepo.create).toHaveBeenCalledWith({ name: 'my-rule', content: 'Proposed content', projectId: 'proj-1', }); expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); }); }); describe('Security: projectId tampering', () => { it('rejects projectId in prompt update payload', async () => { const promptRepo = mockPromptRepo(); vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ id: 'p-1', projectId: 'proj-a' })); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'PUT', url: '/api/v1/prompts/p-1', payload: { content: 'new content', projectId: 'proj-evil' }, }); // Should succeed but ignore projectId — UpdatePromptSchema strips it expect(res.statusCode).toBe(200); expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' }); // projectId must NOT be in the update call const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1]; expect(updateArg).not.toHaveProperty('projectId'); }); it('rejects projectId in promptrequest update payload', async () => { const promptRequestRepo = mockPromptRequestRepo(); vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest({ id: 'r-1', projectId: 'proj-a' })); const { app: a } = buildApp({ promptRequestRepo }); const res = await a.inject({ method: 'PUT', url: '/api/v1/promptrequests/r-1', payload: { content: 'new content', projectId: 'proj-evil' }, }); expect(res.statusCode).toBe(200); expect(promptRequestRepo.update).toHaveBeenCalledWith('r-1', { content: 'new content' }); const updateArg = vi.mocked(promptRequestRepo.update).mock.calls[0]![1]; expect(updateArg).not.toHaveProperty('projectId'); }); }); describe('linkStatus enrichment', () => { it('returns linkStatus=null for non-linked prompts', async () => { const promptRepo = mockPromptRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'plain', linkTarget: null }), ]); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ linkStatus: string | null }>; expect(body[0]!.linkStatus).toBeNull(); }); it('returns linkStatus=alive when project and server exist', async () => { const promptRepo = mockPromptRepo(); const projectRepo = mockProjectRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'source-proj/docmost-mcp:docmost://pages/abc' }), ]); vi.mocked(projectRepo.findByName).mockImplementation(async (name) => { if (name === 'source-proj') { return makeProjectWithServers({ id: 'sp-1', name: 'source-proj' }, ['docmost-mcp']) as never; } return null; }); const { app: a } = buildApp({ promptRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ linkStatus: string }>; expect(body[0]!.linkStatus).toBe('alive'); }); it('returns linkStatus=dead when source project not found', async () => { const promptRepo = mockPromptRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'broken', linkTarget: 'missing-proj/srv:some://uri' }), ]); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ linkStatus: string }>; expect(body[0]!.linkStatus).toBe('dead'); }); it('returns linkStatus=dead when server not in project', async () => { const promptRepo = mockPromptRepo(); const projectRepo = mockProjectRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'wrong-srv', linkTarget: 'proj/wrong-server:some://uri' }), ]); vi.mocked(projectRepo.findByName).mockResolvedValue( makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['other-server']) as never, ); const { app: a } = buildApp({ promptRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ linkStatus: string }>; expect(body[0]!.linkStatus).toBe('dead'); }); it('enriches single prompt GET with linkStatus', async () => { const promptRepo = mockPromptRepo(); const projectRepo = mockProjectRepo(); vi.mocked(promptRepo.findById).mockResolvedValue( makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'proj/srv:some://uri' }), ); vi.mocked(projectRepo.findByName).mockResolvedValue( makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never, ); const { app: a } = buildApp({ promptRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts/p-1' }); expect(res.statusCode).toBe(200); const body = res.json() as { linkStatus: string }; expect(body.linkStatus).toBe('alive'); }); it('caches project lookup for multiple linked prompts', async () => { const promptRepo = mockPromptRepo(); const projectRepo = mockProjectRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'link-a', linkTarget: 'proj/srv:uri-a' }), makePrompt({ id: 'p-2', name: 'link-b', linkTarget: 'proj/srv:uri-b' }), ]); vi.mocked(projectRepo.findByName).mockResolvedValue( makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never, ); const { app: a } = buildApp({ promptRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ linkStatus: string }>; expect(body).toHaveLength(2); expect(body[0]!.linkStatus).toBe('alive'); expect(body[1]!.linkStatus).toBe('alive'); // Should only call findByName once (cached) expect(projectRepo.findByName).toHaveBeenCalledTimes(1); }); it('supports ?projectId= query parameter', async () => { const promptRepo = mockPromptRepo(); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ id: 'p-1', name: 'scoped', projectId: 'proj-1' }), ]); const { app: a } = buildApp({ promptRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?projectId=proj-1' }); expect(res.statusCode).toBe(200); expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); }); }); describe('GET /api/v1/projects/:name/prompts/visible', () => { it('returns approved prompts + session pending requests', async () => { const promptRepo = mockPromptRepo(); const promptRequestRepo = mockPromptRequestRepo(); const projectRepo = mockProjectRepo(); vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' })); vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ name: 'approved-one', projectId: 'proj-1' }), makePrompt({ name: 'global-one', projectId: null }), ]); vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([ makePromptRequest({ name: 'pending-one', projectId: 'proj-1' }), ]); const { app: a } = buildApp({ promptRepo, promptRequestRepo, projectRepo }); const res = await a.inject({ method: 'GET', url: '/api/v1/projects/homeautomation/prompts/visible?session=sess-123', }); expect(res.statusCode).toBe(200); const body = res.json() as Array<{ name: string; type: string }>; expect(body).toHaveLength(3); expect(body.map((b) => b.name)).toContain('approved-one'); expect(body.map((b) => b.name)).toContain('global-one'); expect(body.map((b) => b.name)).toContain('pending-one'); const pending = body.find((b) => b.name === 'pending-one'); expect(pending?.type).toBe('promptrequest'); }); it('returns 404 for unknown project', async () => { const { app: a } = buildApp(); const res = await a.inject({ method: 'GET', url: '/api/v1/projects/nonexistent/prompts/visible', }); expect(res.statusCode).toBe(404); }); }); });