216 lines
8.9 KiB
TypeScript
216 lines
8.9 KiB
TypeScript
|
|
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<Partial<AgentProject>> = []) {
|
||
|
|
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<string, ReturnType<typeof vi.fn>>;
|
||
|
|
|
||
|
|
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<typeof vi.fn>).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/);
|
||
|
|
});
|
||
|
|
});
|