import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PromptService } from '../../src/services/prompt.service.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'; function makePrompt(overrides: Partial = {}): Prompt { return { id: 'prompt-1', name: 'test-prompt', content: 'Hello world', projectId: null, agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, semver: '0.1.0', currentRevisionId: 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: 'test-project', description: '', prompt: '', proxyModel: '', 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 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({ id, ...data })), delete: vi.fn(async () => {}), }; } describe('PromptService', () => { let promptRepo: IPromptRepository; let promptRequestRepo: IPromptRequestRepository; let projectRepo: IProjectRepository; let service: PromptService; beforeEach(() => { promptRepo = mockPromptRepo(); promptRequestRepo = mockPromptRequestRepo(); projectRepo = mockProjectRepo(); service = new PromptService(promptRepo, promptRequestRepo, projectRepo); }); // ── Prompt CRUD ── describe('listPrompts', () => { it('should return all prompts', async () => { const prompts = [makePrompt(), makePrompt({ id: 'prompt-2', name: 'other' })]; vi.mocked(promptRepo.findAll).mockResolvedValue(prompts); const result = await service.listPrompts(); expect(result).toEqual(prompts); expect(promptRepo.findAll).toHaveBeenCalledWith(undefined); }); it('should filter by projectId', async () => { await service.listPrompts('proj-1'); expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); }); }); describe('listGlobalPrompts', () => { it('should return only global prompts', async () => { const globalPrompts = [makePrompt({ name: 'global-rule', projectId: null })]; vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalPrompts); const result = await service.listGlobalPrompts(); expect(result).toEqual(globalPrompts); expect(promptRepo.findGlobal).toHaveBeenCalled(); }); }); describe('getPrompt', () => { it('should return a prompt by id', async () => { const prompt = makePrompt(); vi.mocked(promptRepo.findById).mockResolvedValue(prompt); const result = await service.getPrompt('prompt-1'); expect(result).toEqual(prompt); }); it('should throw NotFoundError for missing prompt', async () => { await expect(service.getPrompt('nope')).rejects.toThrow('Prompt not found: nope'); }); }); describe('createPrompt', () => { it('should create a prompt', async () => { const result = await service.createPrompt({ name: 'new-prompt', content: 'stuff' }); expect(promptRepo.create).toHaveBeenCalledWith({ name: 'new-prompt', content: 'stuff' }); expect(result.name).toBe('new-prompt'); }); it('should validate project exists when projectId given', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(makeProject()); await service.createPrompt({ name: 'scoped', content: 'x', projectId: 'proj-1' }); expect(projectRepo.findById).toHaveBeenCalledWith('proj-1'); }); it('should throw when project not found', async () => { await expect( service.createPrompt({ name: 'bad', content: 'x', projectId: 'nope' }), ).rejects.toThrow('Project not found: nope'); }); it('should reject invalid name format', async () => { await expect( service.createPrompt({ name: 'INVALID_NAME', content: 'x' }), ).rejects.toThrow(); }); }); describe('updatePrompt', () => { it('should update prompt content', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.updatePrompt('prompt-1', { content: 'updated' }); // Auto-patch bump on content change (PR-2): updatePrompt now also // emits the new semver in the same update call. expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated', semver: '0.1.1' }); }); it('should throw for missing prompt', async () => { await expect(service.updatePrompt('nope', { content: 'x' })).rejects.toThrow('Prompt not found'); }); }); describe('deletePrompt', () => { it('should delete an existing prompt', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.deletePrompt('prompt-1'); expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1'); }); it('should throw for missing prompt', async () => { await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found'); }); it('should reset system prompts to default on delete', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ name: 'gate-instructions', projectId: 'sys-proj' })); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' })); const result = await service.deletePrompt('prompt-1'); // Should reset via update, not delete expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ content: expect.any(String) })); expect(promptRepo.delete).not.toHaveBeenCalled(); expect(result).toBeDefined(); }); it('should allow deletion of non-system project prompts', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'proj-1' })); vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' })); await service.deletePrompt('prompt-1'); expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1'); }); }); // ── PromptRequest CRUD ── describe('listPromptRequests', () => { it('should return all prompt requests', async () => { const reqs = [makePromptRequest()]; vi.mocked(promptRequestRepo.findAll).mockResolvedValue(reqs); const result = await service.listPromptRequests(); expect(result).toEqual(reqs); }); }); describe('getPromptRequest', () => { it('should return a prompt request by id', async () => { const req = makePromptRequest(); vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); const result = await service.getPromptRequest('req-1'); expect(result).toEqual(req); }); it('should throw for missing request', async () => { await expect(service.getPromptRequest('nope')).rejects.toThrow('PromptRequest not found'); }); }); describe('deletePromptRequest', () => { it('should delete an existing request', async () => { vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest()); await service.deletePromptRequest('req-1'); expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); }); }); // ── Propose ── describe('propose', () => { it('should create a prompt request', async () => { const result = await service.propose({ name: 'my-prompt', content: 'proposal', createdBySession: 'sess-1', }); expect(promptRequestRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'my-prompt', content: 'proposal', createdBySession: 'sess-1' }), ); expect(result.name).toBe('my-prompt'); }); it('should validate project exists when projectId given', async () => { vi.mocked(projectRepo.findById).mockResolvedValue(makeProject()); await service.propose({ name: 'scoped', content: 'x', projectId: 'proj-1', }); expect(projectRepo.findById).toHaveBeenCalledWith('proj-1'); }); }); // ── Approve ── describe('approve', () => { it('should delete request and create prompt (atomic)', async () => { const req = makePromptRequest({ id: 'req-1', name: 'approved', content: 'good stuff', projectId: 'proj-1' }); vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); const result = await service.approve('req-1'); expect(promptRepo.create).toHaveBeenCalledWith( expect.objectContaining({ name: 'approved', content: 'good stuff', projectId: 'proj-1' }), ); expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); expect(result.name).toBe('approved'); }); it('should throw for missing request', async () => { await expect(service.approve('nope')).rejects.toThrow('PromptRequest not found'); }); it('should handle global prompt (no projectId)', async () => { const req = makePromptRequest({ id: 'req-2', name: 'global', content: 'stuff', projectId: null }); vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); await service.approve('req-2'); // Should NOT include projectId in the create call const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0]; expect(createArg).not.toHaveProperty('projectId'); }); }); // ── Priority ── describe('prompt priority', () => { it('creates prompt with explicit priority', async () => { const result = await service.createPrompt({ name: 'high-pri', content: 'x', priority: 8 }); expect(promptRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 8 })); expect(result.priority).toBe(8); }); it('uses default priority 5 when not specified', async () => { const result = await service.createPrompt({ name: 'default-pri', content: 'x' }); // Default in schema is 5 — create is called without priority const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0]; expect(createArg.priority).toBeUndefined(); }); it('rejects priority below 1', async () => { await expect( service.createPrompt({ name: 'bad-pri', content: 'x', priority: 0 }), ).rejects.toThrow(); }); it('rejects priority above 10', async () => { await expect( service.createPrompt({ name: 'bad-pri', content: 'x', priority: 11 }), ).rejects.toThrow(); }); it('updates prompt priority', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.updatePrompt('prompt-1', { priority: 3 }); expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ priority: 3 })); }); }); // ── Link Target ── describe('prompt links', () => { it('creates linked prompt with valid linkTarget', async () => { const result = await service.createPrompt({ name: 'linked', content: 'link content', linkTarget: 'other-project/docmost-mcp:docmost://pages/abc', }); expect(promptRepo.create).toHaveBeenCalledWith( expect.objectContaining({ linkTarget: 'other-project/docmost-mcp:docmost://pages/abc' }), ); }); it('rejects invalid link format', async () => { await expect( service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'invalid-format' }), ).rejects.toThrow(); }); it('rejects link without server part', async () => { await expect( service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'project:uri' }), ).rejects.toThrow(); }); it('approve carries priority from request to prompt', async () => { const req = makePromptRequest({ id: 'req-1', name: 'high-pri', content: 'x', projectId: 'proj-1', priority: 9 }); vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); await service.approve('req-1'); expect(promptRepo.create).toHaveBeenCalledWith( expect.objectContaining({ priority: 9 }), ); }); it('propose passes priority through', async () => { const result = await service.propose({ name: 'pri-req', content: 'x', priority: 7, }); expect(promptRequestRepo.create).toHaveBeenCalledWith( expect.objectContaining({ priority: 7 }), ); }); }); // ── Visibility ── describe('getVisiblePrompts', () => { it('should return approved prompts and session requests', async () => { vi.mocked(promptRepo.findAll).mockResolvedValue([ makePrompt({ name: 'approved-1', content: 'A' }), ]); vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([ makePromptRequest({ name: 'pending-1', content: 'B' }), ]); const result = await service.getVisiblePrompts('proj-1', 'sess-1'); expect(result).toHaveLength(2); expect(result[0]).toMatchObject({ name: 'approved-1', content: 'A', type: 'prompt' }); expect(result[1]).toMatchObject({ name: 'pending-1', content: 'B', type: 'promptrequest' }); }); it('should not include pending requests without sessionId', async () => { vi.mocked(promptRepo.findAll).mockResolvedValue([makePrompt()]); const result = await service.getVisiblePrompts('proj-1'); expect(result).toHaveLength(1); expect(promptRequestRepo.findBySession).not.toHaveBeenCalled(); }); it('should return empty when no prompts or requests', async () => { const result = await service.getVisiblePrompts(); expect(result).toEqual([]); }); }); });