conversations-advanced.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. import { describe, expect, it, vi } from "vitest";
  2. import { appRouter } from "./routers";
  3. import type { TrpcContext } from "./_core/context";
  4. type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
  5. /* ─── Context helpers ─── */
  6. function createContext(role: "admin" | "agent" | "user" | null): TrpcContext {
  7. if (!role) {
  8. return {
  9. user: null,
  10. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  11. res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
  12. };
  13. }
  14. const user: AuthenticatedUser = {
  15. id: role === "admin" ? 1 : role === "agent" ? 2 : 3,
  16. openId: `${role}-openid`,
  17. email: `${role}@homelegance.com`,
  18. name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`,
  19. loginMethod: "manus",
  20. role,
  21. createdAt: new Date(),
  22. updatedAt: new Date(),
  23. lastSignedIn: new Date(),
  24. };
  25. return {
  26. user,
  27. req: { protocol: "https", headers: {} } as TrpcContext["req"],
  28. res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"],
  29. };
  30. }
  31. /* ─── Mock database ─── */
  32. const mockConversations = [
  33. { id: 1, sessionId: "s1", visitorName: "Alice", visitorEmail: "alice@test.com", status: "active", assignedAgentId: 2, metadata: {}, createdAt: new Date(), updatedAt: new Date() },
  34. { id: 2, sessionId: "s2", visitorName: "Bob", visitorEmail: "bob@test.com", status: "escalated", assignedAgentId: null, metadata: {}, createdAt: new Date(), updatedAt: new Date() },
  35. { id: 3, sessionId: "s3", visitorName: "Charlie", visitorEmail: null, status: "resolved", assignedAgentId: 1, metadata: {}, createdAt: new Date(), updatedAt: new Date() },
  36. ];
  37. const mockAgents = [
  38. { id: 1, name: "Admin User", email: "admin@homelegance.com", role: "admin" },
  39. { id: 2, name: "Agent User", email: "agent@homelegance.com", role: "agent" },
  40. ];
  41. vi.mock("./db", () => {
  42. const users = [
  43. { id: 1, openId: "admin-openid", name: "Test Admin", email: "admin@homelegance.com", role: "admin", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  44. { id: 2, openId: "agent-openid", name: "Test Agent", email: "agent@homelegance.com", role: "agent", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  45. { id: 3, openId: "user-openid", name: "Test User", email: "user@homelegance.com", role: "user", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() },
  46. ];
  47. return {
  48. createConversation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })),
  49. getConversations: vi.fn(async () => []),
  50. getConversationsAdvanced: vi.fn(async (params: any) => ({
  51. conversations: [
  52. { id: 1, sessionId: "s1", visitorName: "Alice", status: "active", assignedAgentId: 2, customerId: "CUST-001", salesRep: "Jane Smith", agentName: "Test Agent", createdAt: new Date(), updatedAt: new Date() },
  53. { id: 2, sessionId: "s2", visitorName: "Bob", status: "escalated", assignedAgentId: null, customerId: null, salesRep: null, agentName: null, createdAt: new Date(), updatedAt: new Date() },
  54. ],
  55. total: 25,
  56. page: params?.page || 1,
  57. pageSize: params?.pageSize || 20,
  58. totalPages: 2,
  59. })),
  60. getConversationById: vi.fn(async (id: number) => ({ id, sessionId: "test", status: "active", createdAt: new Date(), updatedAt: new Date() })),
  61. getConversationBySessionId: vi.fn(async () => null),
  62. updateConversationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
  63. getConversationStats: vi.fn(async () => ({ total: 25, active: 10, escalated: 5, resolved: 7, closed: 3 })),
  64. addMessage: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  65. getMessagesByConversation: vi.fn(async () => []),
  66. getConversationMessageCounts: vi.fn(async (ids: number[]) => {
  67. const counts: Record<number, number> = {};
  68. ids.forEach((id, i) => { counts[id] = (i + 1) * 3; });
  69. return counts;
  70. }),
  71. getAgentUsers: vi.fn(async () => [
  72. { id: 1, name: "Admin User", email: "admin@homelegance.com", role: "admin" },
  73. { id: 2, name: "Agent User", email: "agent@homelegance.com", role: "agent" },
  74. ]),
  75. bulkUpdateConversationStatus: vi.fn(async (ids: number[], status: string) => ({ updated: ids.length })),
  76. deleteConversations: vi.fn(async (ids: number[]) => ({ deleted: ids.length })),
  77. saveWorkflow: vi.fn(async (wid: string, nodes: any[], edges: any[]) => ({ workflowId: wid, nodeCount: nodes.length, edgeCount: edges.length })),
  78. getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })),
  79. getAllUsers: vi.fn(async () => users),
  80. updateUserRole: vi.fn(async (userId: number, role: string) => {
  81. const u = users.find(u => u.id === userId);
  82. if (!u) return null;
  83. return { ...u, role };
  84. }),
  85. getUserById: vi.fn(async (userId: number) => users.find(u => u.id === userId)),
  86. getUserByEmail: vi.fn(async (email: string) => users.find(u => u.email === email) || null),
  87. deleteUser: vi.fn(async (userId: number) => users.find(u => u.id === userId) || null),
  88. getUserByEmailWithPassword: vi.fn(async () => null),
  89. createUserWithPassword: vi.fn(async (data: any) => ({ id: 10, ...data })),
  90. updateUserPassword: vi.fn(async () => {}),
  91. createPasswordResetToken: vi.fn(async (data: any) => ({ id: 1, ...data })),
  92. getPasswordResetToken: vi.fn(async () => null),
  93. markPasswordResetTokenUsed: vi.fn(async () => {}),
  94. createInvitation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  95. getAllInvitations: vi.fn(async () => []),
  96. getInvitationByToken: vi.fn(async () => null),
  97. getInvitationByEmail: vi.fn(async () => []),
  98. updateInvitationStatus: vi.fn(async (id: number, status: string) => ({ id, status })),
  99. expireOldInvitations: vi.fn(async () => {}),
  100. createAuditLog: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })),
  101. getAuditLogs: vi.fn(async () => []),
  102. upsertUser: vi.fn(),
  103. getUserByOpenId: vi.fn(),
  104. };
  105. });
  106. vi.mock("./_core/llm", () => ({
  107. invokeLLM: vi.fn(async () => ({
  108. id: "test",
  109. created: Date.now(),
  110. model: "test",
  111. choices: [{ index: 0, message: { role: "assistant", content: "Test response from Ellie" }, finish_reason: "stop" }],
  112. })),
  113. }));
  114. /* ═══════════════════════════════════════════════════════════════
  115. ADVANCED CONVERSATION QUERIES
  116. ═══════════════════════════════════════════════════════════════ */
  117. describe("Advanced conversation queries", () => {
  118. it("returns paginated conversations with message counts for admin", async () => {
  119. const caller = appRouter.createCaller(createContext("admin"));
  120. const result = await caller.agent.conversationsAdvanced({
  121. page: 1,
  122. pageSize: 20,
  123. sortBy: "updated",
  124. sortOrder: "desc",
  125. });
  126. expect(result.total).toBe(25);
  127. expect(result.page).toBe(1);
  128. expect(result.totalPages).toBe(2);
  129. expect(result.conversations).toHaveLength(2);
  130. // Message counts should be enriched
  131. expect(result.conversations[0]).toHaveProperty("messageCount");
  132. expect(typeof result.conversations[0].messageCount).toBe("number");
  133. });
  134. it("returns paginated conversations for agent role", async () => {
  135. const caller = appRouter.createCaller(createContext("agent"));
  136. const result = await caller.agent.conversationsAdvanced({
  137. page: 1,
  138. pageSize: 20,
  139. sortBy: "updated",
  140. sortOrder: "desc",
  141. });
  142. expect(result.total).toBe(25);
  143. expect(result.conversations).toHaveLength(2);
  144. });
  145. it("rejects advanced query for regular user", async () => {
  146. const caller = appRouter.createCaller(createContext("user"));
  147. await expect(
  148. caller.agent.conversationsAdvanced({
  149. page: 1,
  150. pageSize: 20,
  151. sortBy: "updated",
  152. sortOrder: "desc",
  153. })
  154. ).rejects.toThrow("Agent or admin access required");
  155. });
  156. it("rejects advanced query for unauthenticated user", async () => {
  157. const caller = appRouter.createCaller(createContext(null));
  158. await expect(
  159. caller.agent.conversationsAdvanced({
  160. page: 1,
  161. pageSize: 20,
  162. sortBy: "updated",
  163. sortOrder: "desc",
  164. })
  165. ).rejects.toThrow();
  166. });
  167. it("supports filtering by status", async () => {
  168. const caller = appRouter.createCaller(createContext("admin"));
  169. const result = await caller.agent.conversationsAdvanced({
  170. page: 1,
  171. pageSize: 20,
  172. status: "escalated",
  173. sortBy: "updated",
  174. sortOrder: "desc",
  175. });
  176. expect(result).toBeDefined();
  177. expect(result.conversations).toBeDefined();
  178. });
  179. it("supports filtering by agent", async () => {
  180. const caller = appRouter.createCaller(createContext("admin"));
  181. const result = await caller.agent.conversationsAdvanced({
  182. page: 1,
  183. pageSize: 20,
  184. agentId: 2,
  185. sortBy: "updated",
  186. sortOrder: "desc",
  187. });
  188. expect(result).toBeDefined();
  189. });
  190. it("supports search query", async () => {
  191. const caller = appRouter.createCaller(createContext("agent"));
  192. const result = await caller.agent.conversationsAdvanced({
  193. page: 1,
  194. pageSize: 20,
  195. search: "Alice",
  196. sortBy: "updated",
  197. sortOrder: "desc",
  198. });
  199. expect(result).toBeDefined();
  200. });
  201. it("supports date range filtering", async () => {
  202. const caller = appRouter.createCaller(createContext("admin"));
  203. const result = await caller.agent.conversationsAdvanced({
  204. page: 1,
  205. pageSize: 20,
  206. dateFrom: "2025-01-01",
  207. dateTo: "2026-12-31",
  208. sortBy: "created",
  209. sortOrder: "asc",
  210. });
  211. expect(result).toBeDefined();
  212. });
  213. it("supports page 2 navigation", async () => {
  214. const caller = appRouter.createCaller(createContext("admin"));
  215. const result = await caller.agent.conversationsAdvanced({
  216. page: 2,
  217. pageSize: 20,
  218. sortBy: "updated",
  219. sortOrder: "desc",
  220. });
  221. expect(result.page).toBe(2);
  222. });
  223. it("supports sorting by customerId", async () => {
  224. const caller = appRouter.createCaller(createContext("admin"));
  225. const result = await caller.agent.conversationsAdvanced({
  226. page: 1,
  227. pageSize: 20,
  228. sortBy: "customerId",
  229. sortOrder: "asc",
  230. });
  231. expect(result).toBeDefined();
  232. expect(result.conversations).toBeDefined();
  233. });
  234. it("supports sorting by salesRep", async () => {
  235. const caller = appRouter.createCaller(createContext("agent"));
  236. const result = await caller.agent.conversationsAdvanced({
  237. page: 1,
  238. pageSize: 20,
  239. sortBy: "salesRep",
  240. sortOrder: "desc",
  241. });
  242. expect(result).toBeDefined();
  243. expect(result.conversations).toBeDefined();
  244. });
  245. it("supports sorting by agent name", async () => {
  246. const caller = appRouter.createCaller(createContext("admin"));
  247. const result = await caller.agent.conversationsAdvanced({
  248. page: 1,
  249. pageSize: 20,
  250. sortBy: "agent",
  251. sortOrder: "asc",
  252. });
  253. expect(result).toBeDefined();
  254. expect(result.conversations).toBeDefined();
  255. });
  256. it("returns agentName field in conversation data", async () => {
  257. const caller = appRouter.createCaller(createContext("admin"));
  258. const result = await caller.agent.conversationsAdvanced({
  259. page: 1,
  260. pageSize: 20,
  261. sortBy: "updated",
  262. sortOrder: "desc",
  263. });
  264. // First conversation has an assigned agent
  265. expect(result.conversations[0]).toHaveProperty("agentName");
  266. expect(result.conversations[0].agentName).toBe("Test Agent");
  267. // Second conversation has no assigned agent
  268. expect(result.conversations[1]).toHaveProperty("agentName");
  269. expect(result.conversations[1].agentName).toBeNull();
  270. });
  271. it("returns customerId and salesRep fields in conversation data", async () => {
  272. const caller = appRouter.createCaller(createContext("admin"));
  273. const result = await caller.agent.conversationsAdvanced({
  274. page: 1,
  275. pageSize: 20,
  276. sortBy: "updated",
  277. sortOrder: "desc",
  278. });
  279. expect(result.conversations[0]).toHaveProperty("customerId");
  280. expect(result.conversations[0].customerId).toBe("CUST-001");
  281. expect(result.conversations[0]).toHaveProperty("salesRep");
  282. expect(result.conversations[0].salesRep).toBe("Jane Smith");
  283. });
  284. });
  285. /* ═══════════════════════════════════════════════════════════════
  286. AGENT LIST
  287. ═══════════════════════════════════════════════════════════════ */
  288. describe("Agent list for filter dropdown", () => {
  289. it("returns agent list for admin", async () => {
  290. const caller = appRouter.createCaller(createContext("admin"));
  291. const agents = await caller.agent.agents();
  292. expect(agents).toHaveLength(2);
  293. expect(agents[0]).toHaveProperty("name");
  294. expect(agents[0]).toHaveProperty("role");
  295. });
  296. it("returns agent list for agent role", async () => {
  297. const caller = appRouter.createCaller(createContext("agent"));
  298. const agents = await caller.agent.agents();
  299. expect(agents).toHaveLength(2);
  300. });
  301. it("rejects agent list for regular user", async () => {
  302. const caller = appRouter.createCaller(createContext("user"));
  303. await expect(caller.agent.agents()).rejects.toThrow("Agent or admin access required");
  304. });
  305. });
  306. /* ═══════════════════════════════════════════════════════════════
  307. BULK ACTIONS
  308. ═══════════════════════════════════════════════════════════════ */
  309. describe("Bulk conversation actions", () => {
  310. it("allows agent to bulk resolve conversations", async () => {
  311. const caller = appRouter.createCaller(createContext("agent"));
  312. const result = await caller.agent.bulkUpdateStatus({
  313. conversationIds: [1, 2, 3],
  314. status: "resolved",
  315. });
  316. expect(result.updated).toBe(3);
  317. });
  318. it("allows admin to bulk close conversations", async () => {
  319. const caller = appRouter.createCaller(createContext("admin"));
  320. const result = await caller.agent.bulkUpdateStatus({
  321. conversationIds: [1, 2],
  322. status: "closed",
  323. });
  324. expect(result.updated).toBe(2);
  325. });
  326. it("rejects bulk action for regular user", async () => {
  327. const caller = appRouter.createCaller(createContext("user"));
  328. await expect(
  329. caller.agent.bulkUpdateStatus({ conversationIds: [1], status: "resolved" })
  330. ).rejects.toThrow("Agent or admin access required");
  331. });
  332. it("rejects bulk action for unauthenticated user", async () => {
  333. const caller = appRouter.createCaller(createContext(null));
  334. await expect(
  335. caller.agent.bulkUpdateStatus({ conversationIds: [1], status: "resolved" })
  336. ).rejects.toThrow();
  337. });
  338. });
  339. /* ═══════════════════════════════════════════════════════════════
  340. DELETE CONVERSATIONS (admin only)
  341. ═══════════════════════════════════════════════════════════════ */
  342. describe("Delete conversations (admin only)", () => {
  343. it("allows admin to delete conversations", async () => {
  344. const caller = appRouter.createCaller(createContext("admin"));
  345. const result = await caller.agent.deleteConversations({
  346. conversationIds: [1, 2],
  347. });
  348. expect(result.deleted).toBe(2);
  349. });
  350. it("rejects delete for agent role", async () => {
  351. const caller = appRouter.createCaller(createContext("agent"));
  352. await expect(
  353. caller.agent.deleteConversations({ conversationIds: [1] })
  354. ).rejects.toThrow();
  355. });
  356. it("rejects delete for regular user", async () => {
  357. const caller = appRouter.createCaller(createContext("user"));
  358. await expect(
  359. caller.agent.deleteConversations({ conversationIds: [1] })
  360. ).rejects.toThrow();
  361. });
  362. it("rejects delete for unauthenticated user", async () => {
  363. const caller = appRouter.createCaller(createContext(null));
  364. await expect(
  365. caller.agent.deleteConversations({ conversationIds: [1] })
  366. ).rejects.toThrow();
  367. });
  368. });