| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- import { describe, expect, it, vi, beforeEach } from "vitest";
- import { appRouter } from "./routers";
- import type { TrpcContext } from "./_core/context";
- type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
- function createPublicContext(): TrpcContext {
- return {
- user: null,
- req: { protocol: "https", headers: {} } as TrpcContext["req"],
- res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
- };
- }
- function createAuthContext(): TrpcContext {
- const user: AuthenticatedUser = {
- id: 1,
- openId: "agent-user",
- email: "agent@homelegance.com",
- name: "Test Agent",
- loginMethod: "manus",
- role: "admin",
- createdAt: new Date(),
- updatedAt: new Date(),
- lastSignedIn: new Date(),
- };
- return {
- user,
- req: { protocol: "https", headers: {} } as TrpcContext["req"],
- res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
- };
- }
- // Mock the database module
- vi.mock("./db", () => {
- const conversations = new Map<string, any>();
- const messageStore = new Map<number, any[]>();
- let convIdCounter = 1;
- let msgIdCounter = 1;
- return {
- createConversation: vi.fn(async (data: any) => {
- const id = convIdCounter++;
- const conv = { id, ...data, createdAt: new Date(), updatedAt: new Date() };
- conversations.set(data.sessionId, conv);
- messageStore.set(id, []);
- return conv;
- }),
- getConversations: vi.fn(async (status?: string) => {
- const all = Array.from(conversations.values());
- if (status) return all.filter((c) => c.status === status);
- return all;
- }),
- getConversationById: vi.fn(async (id: number) => {
- return Array.from(conversations.values()).find((c) => c.id === id);
- }),
- getConversationBySessionId: vi.fn(async (sessionId: string) => {
- return conversations.get(sessionId);
- }),
- updateConversationStatus: vi.fn(async (id: number, status: string, agentId?: number) => {
- const conv = Array.from(conversations.values()).find((c) => c.id === id);
- if (conv) {
- conv.status = status;
- if (agentId) conv.assignedAgentId = agentId;
- conversations.set(conv.sessionId, conv);
- }
- return conv;
- }),
- getConversationStats: vi.fn(async () => ({
- total: conversations.size,
- active: Array.from(conversations.values()).filter((c) => c.status === "active").length,
- escalated: Array.from(conversations.values()).filter((c) => c.status === "escalated").length,
- resolved: 0,
- closed: 0,
- })),
- addMessage: vi.fn(async (data: any) => {
- const id = msgIdCounter++;
- const msg = { id, ...data, createdAt: new Date() };
- const msgs = messageStore.get(data.conversationId) || [];
- msgs.push(msg);
- messageStore.set(data.conversationId, msgs);
- return msg;
- }),
- getMessagesByConversation: vi.fn(async (conversationId: number) => {
- return messageStore.get(conversationId) || [];
- }),
- saveWorkflow: vi.fn(async (workflowId: string, nodes: any[], edges: any[]) => ({
- workflowId,
- nodeCount: nodes.length,
- edgeCount: edges.length,
- })),
- getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
- getWorkflowSuggestions: vi.fn(async () => []),
- updateWorkflowSuggestionStatus: vi.fn(async (id: number, status: string, reviewedById: number) => ({ id, status })),
- bulkCreateWorkflowSuggestions: vi.fn(async (suggestions: any[]) => ({ created: suggestions.length })),
- getDb: vi.fn(async () => ({
- select: vi.fn().mockReturnThis(),
- from: vi.fn().mockReturnThis(),
- where: vi.fn().mockReturnThis(),
- orderBy: vi.fn().mockReturnThis(),
- limit: vi.fn(async () => [
- { content: "What is the shipping time?", sender: "visitor" },
- { content: "How do I check my order status?", sender: "visitor" },
- { content: "Do you have modern bedroom sets?", sender: "visitor" },
- ]),
- })),
- upsertUser: vi.fn(),
- getUserByOpenId: vi.fn(),
- // Analytics
- trackAnalyticsEvent: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
- getAnalyticsEvents: vi.fn(async () => []),
- getAnalyticsSummary: vi.fn(async () => ({
- totalConversations: 150,
- resolvedByBot: 95,
- escalatedToAgent: 40,
- abandoned: 15,
- avgResponseTime: 1.8,
- resolutionRate: 63.3,
- topIntents: [
- { intent: "order_status", count: 45 },
- { intent: "shipping", count: 35 },
- { intent: "returns", count: 25 },
- ],
- })),
- // Data Sources
- getDataSources: vi.fn(async () => []),
- createDataSource: vi.fn(async (data: any) => ({ id: 1, ...data, status: "active", createdAt: new Date(), updatedAt: new Date() })),
- updateDataSource: vi.fn(async (id: number, data: any) => ({ id, ...data })),
- deleteDataSource: vi.fn(async () => true),
- // API Connections
- getApiConnections: vi.fn(async () => []),
- createApiConnection: vi.fn(async (data: any) => ({ id: 1, ...data, status: "active", createdAt: new Date(), updatedAt: new Date() })),
- updateApiConnection: vi.fn(async (id: number, data: any) => ({ id, ...data })),
- deleteApiConnection: vi.fn(async () => true),
- testApiConnection: vi.fn(async () => ({ success: true, responseTime: 120, statusCode: 200 })),
- };
- });
- // Mock the LLM module
- vi.mock("./_core/llm", () => ({
- invokeLLM: vi.fn(async () => ({
- id: "test",
- created: Date.now(),
- model: "test",
- choices: [
- {
- index: 0,
- message: { role: "assistant", content: "I can help you find furniture! What style are you looking for?" },
- finish_reason: "stop",
- },
- ],
- })),
- }));
- describe("chat.startSession", () => {
- it("creates a new conversation and returns sessionId", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.chat.startSession({});
- expect(result).toHaveProperty("sessionId");
- expect(result).toHaveProperty("conversationId");
- expect(typeof result.sessionId).toBe("string");
- expect(result.sessionId.length).toBeGreaterThan(0);
- });
- it("creates a session with visitor name", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.chat.startSession({ visitorName: "John Doe" });
- expect(result.sessionId).toBeTruthy();
- expect(result.conversationId).toBeGreaterThan(0);
- });
- });
- describe("chat.sendMessage", () => {
- it("sends a message and receives an AI response", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- // Start a session first
- const session = await caller.chat.startSession({});
- // Send a message
- const result = await caller.chat.sendMessage({
- sessionId: session.sessionId,
- content: "I'm looking for a sofa",
- });
- expect(result).toHaveProperty("reply");
- expect(result.reply).toBeTruthy();
- expect(typeof result.reply).toBe("string");
- });
- it("triggers escalation when user asks for human agent", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- const session = await caller.chat.startSession({});
- const result = await caller.chat.sendMessage({
- sessionId: session.sessionId,
- content: "I want to speak to human agent please",
- });
- expect(result.status).toBe("escalated");
- expect(result.reply).toContain("team member");
- });
- it("throws error for invalid session", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.chat.sendMessage({ sessionId: "nonexistent", content: "Hello" })
- ).rejects.toThrow("Conversation not found");
- });
- });
- describe("chat.getMessages", () => {
- it("returns messages for a valid session", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- const session = await caller.chat.startSession({});
- const result = await caller.chat.getMessages({ sessionId: session.sessionId });
- expect(result).toHaveProperty("messages");
- expect(result).toHaveProperty("status");
- expect(Array.isArray(result.messages)).toBe(true);
- });
- it("returns empty for invalid session", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.chat.getMessages({ sessionId: "nonexistent" });
- expect(result.messages).toEqual([]);
- expect(result.status).toBe("closed");
- });
- });
- describe("agent.stats", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(caller.agent.stats()).rejects.toThrow();
- });
- it("returns stats for authenticated user", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const stats = await caller.agent.stats();
- expect(stats).toHaveProperty("total");
- expect(stats).toHaveProperty("active");
- expect(stats).toHaveProperty("escalated");
- expect(stats).toHaveProperty("resolved");
- expect(stats).toHaveProperty("closed");
- });
- });
- describe("agent.reply", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.agent.reply({ conversationId: 1, content: "Hello" })
- ).rejects.toThrow();
- });
- });
- describe("workflow.save", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.workflow.save({
- workflowId: "test",
- nodes: [],
- edges: [],
- })
- ).rejects.toThrow();
- });
- it("saves workflow for authenticated user", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "test-workflow",
- nodes: [
- {
- workflowId: "test-workflow",
- nodeId: "n1",
- type: "greeting",
- label: "Welcome",
- config: { message: "Hello!" },
- positionX: 100,
- positionY: 100,
- },
- ],
- edges: [],
- });
- expect(result.workflowId).toBe("test-workflow");
- expect(result.nodeCount).toBe(1);
- expect(result.edgeCount).toBe(0);
- });
- });
- describe("workflow.load", () => {
- it("loads workflow for authenticated user", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.load({ workflowId: "test-workflow" });
- expect(result).toHaveProperty("nodes");
- expect(result).toHaveProperty("edges");
- });
- });
- describe("workflow.save with new node types", () => {
- it("saves customer_data node", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "test-workflow",
- nodes: [
- {
- workflowId: "test-workflow",
- nodeId: "n_cust",
- type: "customer_data",
- label: "Customer Lookup",
- config: { apiEndpoint: "/api/crm/customer", lookupField: "customerId", returnFields: ["name", "email"] },
- positionX: 100,
- positionY: 200,
- },
- ],
- edges: [],
- });
- expect(result.nodeCount).toBe(1);
- });
- it("saves sales_order node", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "test-workflow",
- nodes: [
- {
- workflowId: "test-workflow",
- nodeId: "n_ord",
- type: "sales_order",
- label: "Order History",
- config: { apiEndpoint: "/api/orders/history", lookupField: "customerId" },
- positionX: 200,
- positionY: 300,
- },
- ],
- edges: [],
- });
- expect(result.nodeCount).toBe(1);
- });
- it("saves guardrail node with blocked topics", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "test-workflow",
- nodes: [
- {
- workflowId: "test-workflow",
- nodeId: "n_guard",
- type: "guardrail",
- label: "Content Filter",
- config: {
- blockedTopics: ["revenue", "margin", "internal pricing"],
- blockedMessage: "Sorry, I cannot share that information.",
- },
- positionX: 300,
- positionY: 100,
- },
- ],
- edges: [],
- });
- expect(result.nodeCount).toBe(1);
- });
- it("saves greeting node with customer ID detection config", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "test-workflow",
- nodes: [
- {
- workflowId: "test-workflow",
- nodeId: "n_greet",
- type: "greeting",
- label: "Welcome & Identify",
- config: {
- message: "Welcome!",
- detectCustomerId: true,
- customerIdSource: "session",
- personalizedGreeting: "Welcome back, {{customerName}}!",
- },
- positionX: 400,
- positionY: 50,
- },
- ],
- edges: [],
- });
- expect(result.nodeCount).toBe(1);
- });
- it("saves mixed workflow with all new node types", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.save({
- workflowId: "full-workflow",
- nodes: [
- { workflowId: "full-workflow", nodeId: "n1", type: "greeting", label: "Welcome", config: { detectCustomerId: true }, positionX: 0, positionY: 0 },
- { workflowId: "full-workflow", nodeId: "n2", type: "guardrail", label: "Filter", config: { blockedTopics: ["revenue"] }, positionX: 100, positionY: 0 },
- { workflowId: "full-workflow", nodeId: "n3", type: "customer_data", label: "CRM", config: { apiEndpoint: "/api/crm" }, positionX: 200, positionY: 0 },
- { workflowId: "full-workflow", nodeId: "n4", type: "sales_order", label: "Orders", config: { apiEndpoint: "/api/orders" }, positionX: 300, positionY: 0 },
- { workflowId: "full-workflow", nodeId: "n5", type: "intent", label: "Detect", config: { intents: ["search"] }, positionX: 400, positionY: 0 },
- { workflowId: "full-workflow", nodeId: "n6", type: "end", label: "End", config: {}, positionX: 500, positionY: 0 },
- ],
- edges: [
- { workflowId: "full-workflow", sourceNodeId: "n1", targetNodeId: "n2" },
- { workflowId: "full-workflow", sourceNodeId: "n2", targetNodeId: "n3" },
- { workflowId: "full-workflow", sourceNodeId: "n3", targetNodeId: "n4" },
- { workflowId: "full-workflow", sourceNodeId: "n4", targetNodeId: "n5" },
- { workflowId: "full-workflow", sourceNodeId: "n5", targetNodeId: "n6" },
- ],
- });
- expect(result.nodeCount).toBe(6);
- expect(result.edgeCount).toBe(5);
- });
- });
- describe("workflow.getSuggestions", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.workflow.getSuggestions({ workflowId: "test" })
- ).rejects.toThrow();
- });
- it("returns suggestions for authenticated admin", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.getSuggestions({ workflowId: "test-workflow" });
- expect(Array.isArray(result)).toBe(true);
- });
- it("accepts optional status filter", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.getSuggestions({ workflowId: "test-workflow", status: "pending" });
- expect(Array.isArray(result)).toBe(true);
- });
- });
- describe("workflow.reviewSuggestion", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.workflow.reviewSuggestion({ suggestionId: 1, status: "approved" })
- ).rejects.toThrow();
- });
- it("approves a suggestion", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.reviewSuggestion({ suggestionId: 1, status: "approved" });
- expect(result.id).toBe(1);
- expect(result.status).toBe("approved");
- });
- it("declines a suggestion", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.reviewSuggestion({ suggestionId: 2, status: "declined" });
- expect(result.id).toBe(2);
- expect(result.status).toBe("declined");
- });
- it("sets a suggestion to waiting", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.reviewSuggestion({ suggestionId: 3, status: "waiting" });
- expect(result.id).toBe(3);
- expect(result.status).toBe("waiting");
- });
- });
- describe("workflow.generateSuggestions", () => {
- it("requires authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.workflow.generateSuggestions({ workflowId: "test" })
- ).rejects.toThrow();
- });
- it("generates suggestions from conversation data", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.workflow.generateSuggestions({ workflowId: "test-workflow" });
- expect(result).toHaveProperty("suggestions");
- expect(result).toHaveProperty("message");
- });
- });
- /* ─── Analytics Tests ─── */
- describe("analytics.summary", () => {
- it("requires admin authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.analytics.summary({})
- ).rejects.toThrow();
- });
- it("returns analytics summary data", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.analytics.summary({});
- expect(result).toHaveProperty("totalConversations");
- expect(result).toHaveProperty("resolvedByBot");
- expect(result).toHaveProperty("resolutionRate");
- expect(result).toHaveProperty("topIntents");
- expect(result.totalConversations).toBe(150);
- expect(result.resolutionRate).toBeCloseTo(63.3);
- });
- });
- describe("analytics.track", () => {
- it("tracks an analytics event", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.analytics.track({
- eventType: "resolved_by_bot",
- conversationId: 1,
- metadata: { intent: "order_status" },
- });
- expect(result).toHaveProperty("id");
- });
- });
- /* ─── Data Sources Tests ─── */
- describe("dataSources.list", () => {
- it("requires admin authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.dataSources.list()
- ).rejects.toThrow();
- });
- it("returns list of data sources", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.dataSources.list();
- expect(Array.isArray(result)).toBe(true);
- });
- });
- describe("dataSources.create", () => {
- it("creates a new data source", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.dataSources.create({
- name: "Product Catalog",
- type: "url",
- config: { url: "https://api.homelegance.com/products" },
- });
- expect(result).toHaveProperty("id");
- });
- });
- describe("dataSources.delete", () => {
- it("deletes a data source", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.dataSources.delete({ id: 1 });
- expect(result).toHaveProperty("success");
- });
- });
- /* ─── API Connections Tests ─── */
- describe("apiConnections.list", () => {
- it("requires admin authentication", async () => {
- const ctx = createPublicContext();
- const caller = appRouter.createCaller(ctx);
- await expect(
- caller.apiConnections.list()
- ).rejects.toThrow();
- });
- it("returns list of API connections", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.apiConnections.list();
- expect(Array.isArray(result)).toBe(true);
- });
- });
- describe("apiConnections.create", () => {
- it("creates a new API connection", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.apiConnections.create({
- name: "Order Status API",
- description: "Check order status",
- category: "orders",
- method: "GET",
- endpoint: "https://api.homelegance.com/orders/{orderId}",
- headers: { Authorization: "Bearer {{API_KEY}}" },
- inputVariables: [{ name: "orderId", type: "string", description: "Order ID" }],
- outputVariables: [{ name: "status", type: "string", description: "Order status" }],
- });
- expect(result).toHaveProperty("id");
- });
- });
- describe("apiConnections.delete", () => {
- it("deletes an API connection", async () => {
- const ctx = createAuthContext();
- const caller = appRouter.createCaller(ctx);
- const result = await caller.apiConnections.delete({ id: 1 });
- expect(result).toHaveProperty("success");
- });
- });
|