import type { AgentProject } from '@prisma/client'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { IAgentProjectRepository } from '../../src/repositories/agent-project.repository.js'; import { AgentProjectService } from '../../src/services/agent-project.service.js'; import type { AgentService } from '../../src/services/agent.service.js'; import type { LlmService } from '../../src/services/llm.service.js'; import { NotFoundError } from '../../src/services/mcp-server.service.js'; import type { ProjectService } from '../../src/services/project.service.js'; const NOW = new Date('2026-01-01T00:00:00Z'); /** * In-memory AgentProject junction. The unique (agentId, projectId) constraint * is modelled here because the service's idempotent re-join depends on it — * without it the "join twice" test would pass for the wrong reason. */ function makeHarness(seed: Array> = []) { let idCounter = 1; let rows: AgentProject[] = seed.map((r) => ({ id: r.id ?? `ap-${String(idCounter++)}`, agentId: r.agentId ?? 'agent-alice', projectId: r.projectId ?? 'proj-sre', role: r.role ?? 'developer', llmId: r.llmId ?? null, createdAt: NOW, }) as AgentProject); const repo = { findByAgent: vi.fn(async (agentId: string) => rows.filter((r) => r.agentId === agentId)), findByProject: vi.fn(async (projectId: string) => rows.filter((r) => r.projectId === projectId)), find: vi.fn(async (agentId: string, projectId: string) => rows.find((r) => r.agentId === agentId && r.projectId === projectId) ?? null, ), create: vi.fn(async (data: { agentId: string; projectId: string; role?: string; llmId?: string | null }) => { const row = { id: `ap-${String(idCounter++)}`, agentId: data.agentId, projectId: data.projectId, role: data.role ?? 'developer', llmId: data.llmId ?? null, createdAt: NOW, } as AgentProject; rows = [...rows, row]; return row; }), update: vi.fn(async (id: string, data: { role?: string; llmId?: string | null }) => { const row = rows.find((r) => r.id === id); if (row === undefined) throw new Error(`no such membership: ${id}`); if (data.role !== undefined) row.role = data.role; if (data.llmId !== undefined) row.llmId = data.llmId; return row; }), delete: vi.fn(async (id: string) => { rows = rows.filter((r) => r.id !== id); }), } as unknown as IAgentProjectRepository & Record>; const agents = { getByName: vi.fn(async (name: string) => { if (name === 'ghost') throw new NotFoundError(`Agent not found: ${name}`); return { id: `agent-${name}`, name }; }), getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^agent-/, '') })), } as unknown as AgentService; const projects = { resolveAndGet: vi.fn(async (name: string) => { if (name === 'nowhere') throw new NotFoundError(`Project not found: ${name}`); return { id: `proj-${name}`, name }; }), getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^proj-/, '') })), } as unknown as ProjectService; const llms = { getByName: vi.fn(async (name: string) => { if (name === 'missing-brain') throw new NotFoundError(`Llm not found: ${name}`); return { id: `llm-${name}`, name }; }), getById: vi.fn(async (id: string) => ({ id, name: id.replace(/^llm-/, '') })), } as unknown as LlmService; return { repo, agents, projects, llms, service: new AgentProjectService(repo, agents, projects, llms), all: () => rows, }; } describe('AgentProjectService — joining and leaving', () => { beforeEach(() => { vi.clearAllMocks(); }); it('joins a project with the default role', async () => { const { service } = makeHarness(); const m = await service.join('alice', 'sre', {}); expect(m.project.name).toBe('sre'); expect(m.agent.name).toBe('alice'); expect(m.role).toBe('developer'); expect(m.llm).toBeNull(); }); it('joins the SAME agent to several projects — the whole point of the junction', async () => { const { service } = makeHarness(); await service.join('alice', 'sre', {}); await service.join('alice', 'payments', { role: 'security' }); const memberships = await service.listForAgent('alice'); expect(memberships.map((m) => m.project.name).sort()).toEqual(['payments', 'sre']); expect(memberships.find((m) => m.project.name === 'payments')?.role).toBe('security'); }); it('re-joining updates the role instead of 409-ing — repeat applies converge', async () => { const { service, repo, all } = makeHarness(); await service.join('alice', 'sre', { role: 'developer' }); const second = await service.join('alice', 'sre', { role: 'qa' }); expect(second.role).toBe('qa'); expect(all()).toHaveLength(1); expect(repo.create).toHaveBeenCalledTimes(1); expect(repo.update).toHaveBeenCalledTimes(1); }); it('rejects a role outside the v1 set', async () => { const { service } = makeHarness(); await expect(service.join('alice', 'sre', { role: 'chief-vibes-officer' })).rejects.toThrow(); }); it('accepts every v1 role', async () => { const { service } = makeHarness(); for (const role of AgentProjectService.roles()) { const m = await service.join('alice', 'sre', { role }); expect(m.role).toBe(role); } }); it('rejects unknown body fields rather than silently dropping them', async () => { const { service } = makeHarness(); // A typo'd `llmName` would otherwise leave the membership with no brain // override and no complaint — the exact failure the strict schema exists for. await expect(service.join('alice', 'sre', { llmName: 'x' })).rejects.toThrow(); }); it('404s on an unknown agent and on an unknown project', async () => { const { service } = makeHarness(); await expect(service.join('ghost', 'sre', {})).rejects.toThrow(/Agent not found/); await expect(service.join('alice', 'nowhere', {})).rejects.toThrow(/Project not found/); }); it('leaves a project', async () => { const { service, all } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre' }]); await service.leave('alice', 'sre'); expect(all()).toHaveLength(0); }); it('404s when leaving a project the agent never joined — a typo must not no-op', async () => { const { service } = makeHarness(); await expect(service.leave('alice', 'sre')).rejects.toThrow(/is not a member of project 'sre'/); }); }); describe('AgentProjectService — the per-membership brain override', () => { beforeEach(() => { vi.clearAllMocks(); }); it('records the named Llm', async () => { const { service } = makeHarness(); const m = await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); expect(m.llm).toEqual({ id: 'llm-qwen3-thinking', name: 'qwen3-thinking' }); }); it('clears the override with an explicit null', async () => { const { service } = makeHarness(); await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); const cleared = await service.join('alice', 'sre', { llm: null }); expect(cleared.llm).toBeNull(); }); it('leaves an existing override untouched when `llm` is absent', async () => { const { service } = makeHarness(); await service.join('alice', 'sre', { llm: 'qwen3-thinking' }); const roleOnly = await service.join('alice', 'sre', { role: 'qa' }); expect(roleOnly.role).toBe('qa'); expect(roleOnly.llm?.name).toBe('qwen3-thinking'); }); it('404s on an Llm that does not exist', async () => { const { service } = makeHarness(); await expect(service.join('alice', 'sre', { llm: 'missing-brain' })).rejects.toThrow(/Llm not found/); }); it('survives a brain that vanished under the membership', async () => { // onDelete: SetNull should prevent this, but a torn read must not 500 the // whole roster. const { service, llms } = makeHarness([{ agentId: 'agent-alice', projectId: 'proj-sre', llmId: 'llm-gone' }]); (llms.getById as ReturnType).mockRejectedValueOnce(new NotFoundError('Llm not found: llm-gone')); const [m] = await service.listForAgent('alice'); expect(m?.llm).toBeNull(); }); }); describe('AgentProjectService — the reverse view', () => { beforeEach(() => { vi.clearAllMocks(); }); it('lists the whole roster for a project, with roles', async () => { const { service } = makeHarness([ { agentId: 'agent-alice', projectId: 'proj-sre', role: 'sre' }, { agentId: 'agent-bob', projectId: 'proj-sre', role: 'qa' }, { agentId: 'agent-carol', projectId: 'proj-payments', role: 'security' }, ]); const roster = await service.listForProject('sre'); expect(roster.map((m) => `${m.agent.name}:${m.role}`)).toEqual(['alice:sre', 'bob:qa']); }); it('404s on an unknown project', async () => { const { service } = makeHarness(); await expect(service.listForProject('nowhere')).rejects.toThrow(/Project not found/); }); });