diff --git a/apps/api/src/chat-proxy/chat-proxy.service.spec.ts b/apps/api/src/chat-proxy/chat-proxy.service.spec.ts index 63b595c..90e65f4 100644 --- a/apps/api/src/chat-proxy/chat-proxy.service.spec.ts +++ b/apps/api/src/chat-proxy/chat-proxy.service.spec.ts @@ -1,4 +1,8 @@ -import { ServiceUnavailableException } from "@nestjs/common"; +import { + ServiceUnavailableException, + NotFoundException, + BadGatewayException, +} from "@nestjs/common"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ChatProxyService } from "./chat-proxy.service"; @@ -9,6 +13,9 @@ describe("ChatProxyService", () => { userAgentConfig: { findUnique: vi.fn(), }, + userAgent: { + findUnique: vi.fn(), + }, }; const containerLifecycle = { @@ -16,13 +23,17 @@ describe("ChatProxyService", () => { touch: vi.fn(), }; + const config = { + get: vi.fn(), + }; + let service: ChatProxyService; let fetchMock: ReturnType; beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - service = new ChatProxyService(prisma as never, containerLifecycle as never); + service = new ChatProxyService(prisma as never, containerLifecycle as never, config as never); }); afterEach(() => { @@ -105,4 +116,135 @@ describe("ChatProxyService", () => { ); }); }); + + describe("proxyChat with agent routing", () => { + it("includes agent config when agentName is specified", async () => { + const mockAgent = { + name: "jarvis", + displayName: "Jarvis", + personality: "Capable, direct, proactive.", + primaryModel: "opus", + isActive: true, + }; + + containerLifecycle.ensureRunning.mockResolvedValue({ + url: "http://mosaic-user-user-123:19000", + token: "gateway-token", + }); + containerLifecycle.touch.mockResolvedValue(undefined); + prisma.userAgent.findUnique.mockResolvedValue(mockAgent); + fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n")); + + const messages = [{ role: "user", content: "Hello Jarvis" }]; + await service.proxyChat(userId, messages, undefined, "jarvis"); + + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsedBody = JSON.parse(String(request.body)); + + expect(parsedBody).toEqual({ + messages, + model: "opus", + stream: true, + agent: "jarvis", + agent_personality: "Capable, direct, proactive.", + }); + }); + + it("throws NotFoundException when agent not found", async () => { + containerLifecycle.ensureRunning.mockResolvedValue({ + url: "http://mosaic-user-user-123:19000", + token: "gateway-token", + }); + containerLifecycle.touch.mockResolvedValue(undefined); + prisma.userAgent.findUnique.mockResolvedValue(null); + + const messages = [{ role: "user", content: "Hello" }]; + await expect(service.proxyChat(userId, messages, undefined, "nonexistent")).rejects.toThrow( + NotFoundException + ); + }); + + it("throws NotFoundException when agent is not active", async () => { + containerLifecycle.ensureRunning.mockResolvedValue({ + url: "http://mosaic-user-user-123:19000", + token: "gateway-token", + }); + containerLifecycle.touch.mockResolvedValue(undefined); + prisma.userAgent.findUnique.mockResolvedValue({ + name: "inactive-agent", + displayName: "Inactive", + personality: "...", + primaryModel: null, + isActive: false, + }); + + const messages = [{ role: "user", content: "Hello" }]; + await expect( + service.proxyChat(userId, messages, undefined, "inactive-agent") + ).rejects.toThrow(NotFoundException); + }); + + it("falls back to default model when agent has no primaryModel", async () => { + const mockAgent = { + name: "jarvis", + displayName: "Jarvis", + personality: "Capable, direct, proactive.", + primaryModel: null, + isActive: true, + }; + + containerLifecycle.ensureRunning.mockResolvedValue({ + url: "http://mosaic-user-user-123:19000", + token: "gateway-token", + }); + containerLifecycle.touch.mockResolvedValue(undefined); + prisma.userAgent.findUnique.mockResolvedValue(mockAgent); + prisma.userAgentConfig.findUnique.mockResolvedValue(null); + fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n")); + + const messages = [{ role: "user", content: "Hello" }]; + await service.proxyChat(userId, messages, undefined, "jarvis"); + + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsedBody = JSON.parse(String(request.body)); + + expect(parsedBody.model).toBe("openclaw:default"); + }); + }); + + describe("proxyGuestChat", () => { + it("uses environment variables for guest LLM configuration", async () => { + config.get.mockImplementation((key: string) => { + if (key === "GUEST_LLM_URL") return "http://10.1.1.42:11434/v1"; + if (key === "GUEST_LLM_MODEL") return "llama3.2"; + return undefined; + }); + fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n")); + + const messages = [{ role: "user", content: "Hello" }]; + await service.proxyGuestChat(messages); + + expect(fetchMock).toHaveBeenCalledWith( + "http://10.1.1.42:11434/v1/chat/completions", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }) + ); + + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + const parsedBody = JSON.parse(String(request.body)); + expect(parsedBody.model).toBe("llama3.2"); + }); + + it("throws BadGatewayException on guest LLM errors", async () => { + config.get.mockReturnValue(undefined); + fetchMock.mockResolvedValue(new Response("Internal Server Error", { status: 500 })); + + const messages = [{ role: "user", content: "Hello" }]; + await expect(service.proxyGuestChat(messages)).rejects.toThrow(BadGatewayException); + }); + }); }); diff --git a/apps/api/src/user-agent/user-agent.service.spec.ts b/apps/api/src/user-agent/user-agent.service.spec.ts new file mode 100644 index 0000000..88fce60 --- /dev/null +++ b/apps/api/src/user-agent/user-agent.service.spec.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { UserAgentService } from "./user-agent.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { NotFoundException, ConflictException, ForbiddenException } from "@nestjs/common"; + +describe("UserAgentService", () => { + let service: UserAgentService; + let prisma: PrismaService; + + const mockPrismaService = { + userAgent: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + agentTemplate: { + findUnique: vi.fn(), + }, + }; + + const mockUserId = "550e8400-e29b-41d4-a716-446655440001"; + const mockAgentId = "550e8400-e29b-41d4-a716-446655440002"; + const mockTemplateId = "550e8400-e29b-41d4-a716-446655440003"; + + const mockAgent = { + id: mockAgentId, + userId: mockUserId, + templateId: null, + name: "jarvis", + displayName: "Jarvis", + role: "orchestrator", + personality: "Capable, direct, proactive.", + primaryModel: "opus", + fallbackModels: ["sonnet"], + toolPermissions: ["all"], + discordChannel: "jarvis", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockTemplate = { + id: mockTemplateId, + name: "builder", + displayName: "Builder", + role: "coding", + personality: "Focused, thorough.", + primaryModel: "codex", + fallbackModels: ["sonnet"], + toolPermissions: ["exec", "read", "write"], + discordChannel: "builder", + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserAgentService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(UserAgentService); + prisma = module.get(PrismaService); + + vi.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("findAll", () => { + it("should return all agents for a user", async () => { + mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]); + + const result = await service.findAll(mockUserId); + + expect(result).toEqual([mockAgent]); + expect(mockPrismaService.userAgent.findMany).toHaveBeenCalledWith({ + where: { userId: mockUserId }, + orderBy: { createdAt: "asc" }, + }); + }); + + it("should return empty array if no agents", async () => { + mockPrismaService.userAgent.findMany.mockResolvedValue([]); + + const result = await service.findAll(mockUserId); + + expect(result).toEqual([]); + }); + }); + + describe("findOne", () => { + it("should return an agent by id", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + + const result = await service.findOne(mockUserId, mockAgentId); + + expect(result).toEqual(mockAgent); + }); + + it("should throw NotFoundException if agent not found", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(null); + + await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(NotFoundException); + }); + + it("should throw ForbiddenException if agent belongs to different user", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue({ + ...mockAgent, + userId: "different-user-id", + }); + + await expect(service.findOne(mockUserId, mockAgentId)).rejects.toThrow(ForbiddenException); + }); + }); + + describe("findByName", () => { + it("should return an agent by name", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + + const result = await service.findByName(mockUserId, "jarvis"); + + expect(result).toEqual(mockAgent); + expect(mockPrismaService.userAgent.findUnique).toHaveBeenCalledWith({ + where: { userId_name: { userId: mockUserId, name: "jarvis" } }, + }); + }); + + it("should throw NotFoundException if agent not found", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(null); + + await expect(service.findByName(mockUserId, "nonexistent")).rejects.toThrow( + NotFoundException + ); + }); + }); + + describe("create", () => { + it("should create a new agent", async () => { + const createDto = { + name: "jarvis", + displayName: "Jarvis", + role: "orchestrator", + personality: "Capable, direct, proactive.", + }; + + mockPrismaService.userAgent.findUnique.mockResolvedValue(null); + mockPrismaService.userAgent.create.mockResolvedValue(mockAgent); + + const result = await service.create(mockUserId, createDto); + + expect(result).toEqual(mockAgent); + }); + + it("should throw ConflictException if agent name already exists", async () => { + const createDto = { + name: "jarvis", + displayName: "Jarvis", + role: "orchestrator", + personality: "Capable, direct, proactive.", + }; + + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + + await expect(service.create(mockUserId, createDto)).rejects.toThrow(ConflictException); + }); + + it("should throw NotFoundException if templateId is invalid", async () => { + const createDto = { + name: "custom", + displayName: "Custom", + role: "custom", + personality: "Custom agent", + templateId: "nonexistent-template", + }; + + mockPrismaService.userAgent.findUnique.mockResolvedValue(null); + mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null); + + await expect(service.create(mockUserId, createDto)).rejects.toThrow(NotFoundException); + }); + }); + + describe("createFromTemplate", () => { + it("should create an agent from a template", async () => { + mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate); + mockPrismaService.userAgent.findUnique.mockResolvedValue(null); + mockPrismaService.userAgent.create.mockResolvedValue({ + ...mockAgent, + templateId: mockTemplateId, + name: mockTemplate.name, + displayName: mockTemplate.displayName, + role: mockTemplate.role, + }); + + const result = await service.createFromTemplate(mockUserId, mockTemplateId); + + expect(result.name).toBe(mockTemplate.name); + expect(result.displayName).toBe(mockTemplate.displayName); + }); + + it("should throw NotFoundException if template not found", async () => { + mockPrismaService.agentTemplate.findUnique.mockResolvedValue(null); + + await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow( + NotFoundException + ); + }); + + it("should throw ConflictException if agent name already exists", async () => { + mockPrismaService.agentTemplate.findUnique.mockResolvedValue(mockTemplate); + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + + await expect(service.createFromTemplate(mockUserId, mockTemplateId)).rejects.toThrow( + ConflictException + ); + }); + }); + + describe("update", () => { + it("should update an agent", async () => { + const updateDto = { displayName: "Updated Jarvis" }; + const updatedAgent = { ...mockAgent, ...updateDto }; + + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + mockPrismaService.userAgent.update.mockResolvedValue(updatedAgent); + + const result = await service.update(mockUserId, mockAgentId, updateDto); + + expect(result.displayName).toBe("Updated Jarvis"); + }); + + it("should throw ConflictException if new name already exists", async () => { + const updateDto = { name: "existing-name" }; + + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + // Second call checks for existing name + mockPrismaService.userAgent.findUnique.mockResolvedValue({ ...mockAgent, id: "other-id" }); + + await expect(service.update(mockUserId, mockAgentId, updateDto)).rejects.toThrow( + ConflictException + ); + }); + }); + + describe("remove", () => { + it("should delete an agent", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + mockPrismaService.userAgent.delete.mockResolvedValue(mockAgent); + + const result = await service.remove(mockUserId, mockAgentId); + + expect(result).toEqual(mockAgent); + }); + }); + + describe("getStatus", () => { + it("should return agent status", async () => { + mockPrismaService.userAgent.findUnique.mockResolvedValue(mockAgent); + + const result = await service.getStatus(mockUserId, mockAgentId); + + expect(result).toEqual({ + id: mockAgentId, + name: "jarvis", + displayName: "Jarvis", + role: "orchestrator", + isActive: true, + }); + }); + }); + + describe("getAllStatuses", () => { + it("should return all agent statuses", async () => { + mockPrismaService.userAgent.findMany.mockResolvedValue([mockAgent]); + + const result = await service.getAllStatuses(mockUserId); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: mockAgentId, + name: "jarvis", + displayName: "Jarvis", + role: "orchestrator", + isActive: true, + }); + }); + }); +});