import { describe, expect, it, vi, beforeEach } from "vitest"; import { appRouter } from "./routers"; import type { TrpcContext } from "./_core/context"; type AuthenticatedUser = NonNullable; /* ─── Context helpers ─── */ function createContext(role: "admin" | "agent" | "user" | null): TrpcContext { if (!role) { return { user: null, req: { protocol: "https", headers: {} } as TrpcContext["req"], res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"], }; } const user: AuthenticatedUser = { id: role === "admin" ? 1 : role === "agent" ? 2 : 3, openId: `${role}-openid`, email: `${role}@homelegance.com`, name: `Test ${role.charAt(0).toUpperCase() + role.slice(1)}`, loginMethod: "manus", role, 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 database ─── */ const mockUsers = [ { id: 1, openId: "admin-openid", name: "Test Admin", email: "admin@homelegance.com", role: "admin", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() }, { id: 2, openId: "agent-openid", name: "Test Agent", email: "agent@homelegance.com", role: "agent", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() }, { id: 3, openId: "user-openid", name: "Test User", email: "user@homelegance.com", role: "user", lastSignedIn: new Date(), createdAt: new Date(), updatedAt: new Date() }, ]; const mockInvitations: any[] = []; const mockAuditLogs: any[] = []; let inviteIdCounter = 1; let auditIdCounter = 1; vi.mock("./db", () => ({ createConversation: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date(), updatedAt: new Date() })), getConversations: vi.fn(async () => []), getConversationById: vi.fn(async (id: number) => ({ id, sessionId: "test", status: "active", createdAt: new Date(), updatedAt: new Date() })), getConversationBySessionId: vi.fn(async () => null), updateConversationStatus: vi.fn(async (id: number, status: string) => ({ id, status })), getConversationStats: vi.fn(async () => ({ total: 5, active: 2, escalated: 1, resolved: 1, closed: 1 })), addMessage: vi.fn(async (data: any) => ({ id: 1, ...data, createdAt: new Date() })), getMessagesByConversation: vi.fn(async () => []), saveWorkflow: vi.fn(async (wid: string, nodes: any[], edges: any[]) => ({ workflowId: wid, nodeCount: nodes.length, edgeCount: edges.length })), getWorkflow: vi.fn(async () => ({ nodes: [], edges: [] })), getAllUsers: vi.fn(async () => [...mockUsers]), updateUserRole: vi.fn(async (userId: number, role: string) => { const u = mockUsers.find(u => u.id === userId); if (!u) return null; return { ...u, role }; }), getUserById: vi.fn(async (userId: number) => mockUsers.find(u => u.id === userId) || null), getUserByEmail: vi.fn(async (email: string) => mockUsers.find(u => u.email === email) || null), deleteUser: vi.fn(async (userId: number) => { const u = mockUsers.find(u => u.id === userId); return u || null; }), createInvitation: vi.fn(async (data: any) => { const inv = { id: inviteIdCounter++, ...data, createdAt: new Date() }; mockInvitations.push(inv); return inv; }), getAllInvitations: vi.fn(async () => [...mockInvitations]), getInvitationByToken: vi.fn(async (token: string) => mockInvitations.find(i => i.token === token) || null), getInvitationByEmail: vi.fn(async (email: string) => mockInvitations.filter(i => i.email === email)), updateInvitationStatus: vi.fn(async (id: number, status: string, acceptedById?: number) => { const inv = mockInvitations.find(i => i.id === id); if (inv) { inv.status = status; if (acceptedById) inv.acceptedById = acceptedById; inv.acceptedAt = status === "accepted" ? new Date() : null; } return inv; }), expireOldInvitations: vi.fn(async () => {}), createAuditLog: vi.fn(async (data: any) => { const log = { id: auditIdCounter++, ...data, createdAt: new Date() }; mockAuditLogs.push(log); return log; }), getAuditLogs: vi.fn(async (limit?: number) => mockAuditLogs.slice(0, limit || 50)), upsertUser: vi.fn(), getUserByOpenId: vi.fn(), })); vi.mock("./_core/llm", () => ({ invokeLLM: vi.fn(async () => ({ id: "test", created: Date.now(), model: "test", choices: [{ index: 0, message: { role: "assistant", content: "Test response" }, finish_reason: "stop" }], })), })); vi.mock("./_core/notification", () => ({ notifyOwner: vi.fn(async () => true), })); /* ═══════════════════════════════════════════════════════════════ INVITATION SYSTEM ═══════════════════════════════════════════════════════════════ */ describe("Invitation system", () => { beforeEach(() => { mockInvitations.length = 0; mockAuditLogs.length = 0; inviteIdCounter = 1; auditIdCounter = 1; }); describe("Send invitation", () => { it("admin can send an invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const result = await caller.invitations.send({ email: "newuser@example.com", role: "agent", message: "Welcome to the team!", }); expect(result).toHaveProperty("email", "newuser@example.com"); expect(result).toHaveProperty("role", "agent"); expect(result).toHaveProperty("token"); expect(result).toHaveProperty("status", "pending"); }); it("rejects invitation for existing user email", async () => { const caller = appRouter.createCaller(createContext("admin")); await expect( caller.invitations.send({ email: "agent@homelegance.com", role: "admin" }) ).rejects.toThrow("already exists"); }); it("rejects duplicate pending invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); await caller.invitations.send({ email: "dup@example.com", role: "agent" }); await expect( caller.invitations.send({ email: "dup@example.com", role: "admin" }) ).rejects.toThrow("pending invitation already exists"); }); it("agent cannot send invitations", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect( caller.invitations.send({ email: "test@example.com", role: "agent" }) ).rejects.toThrow(); }); it("regular user cannot send invitations", async () => { const caller = appRouter.createCaller(createContext("user")); await expect( caller.invitations.send({ email: "test@example.com", role: "agent" }) ).rejects.toThrow(); }); }); describe("List invitations", () => { it("admin can list invitations", async () => { const caller = appRouter.createCaller(createContext("admin")); await caller.invitations.send({ email: "list@example.com", role: "agent" }); const result = await caller.invitations.list(); expect(Array.isArray(result)).toBe(true); expect(result.length).toBeGreaterThan(0); }); it("agent cannot list invitations", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect(caller.invitations.list()).rejects.toThrow(); }); }); describe("Revoke invitation", () => { it("admin can revoke a pending invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const inv = await caller.invitations.send({ email: "revoke@example.com", role: "agent" }); const result = await caller.invitations.revoke({ invitationId: inv.id }); expect(result).toHaveProperty("status", "revoked"); }); it("cannot revoke a non-pending invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const inv = await caller.invitations.send({ email: "revoke2@example.com", role: "agent" }); await caller.invitations.revoke({ invitationId: inv.id }); await expect( caller.invitations.revoke({ invitationId: inv.id }) ).rejects.toThrow("Cannot revoke"); }); }); describe("Validate invitation", () => { it("validates a pending invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const inv = await caller.invitations.send({ email: "validate@example.com", role: "agent" }); const publicCaller = appRouter.createCaller(createContext(null)); const result = await publicCaller.invitations.validate({ token: inv.token }); expect(result.valid).toBe(true); if (result.valid) { expect(result.email).toBe("validate@example.com"); expect(result.role).toBe("agent"); } }); it("returns invalid for non-existent token", async () => { const publicCaller = appRouter.createCaller(createContext(null)); const result = await publicCaller.invitations.validate({ token: "nonexistent" }); expect(result.valid).toBe(false); }); it("returns invalid for revoked invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const inv = await caller.invitations.send({ email: "revoked@example.com", role: "agent" }); await caller.invitations.revoke({ invitationId: inv.id }); const publicCaller = appRouter.createCaller(createContext(null)); const result = await publicCaller.invitations.validate({ token: inv.token }); expect(result.valid).toBe(false); }); }); describe("Accept invitation", () => { it("authenticated user can accept a valid invitation", async () => { const adminCaller = appRouter.createCaller(createContext("admin")); const inv = await adminCaller.invitations.send({ email: "accept@example.com", role: "agent" }); const userCaller = appRouter.createCaller(createContext("user")); const result = await userCaller.invitations.accept({ token: inv.token }); expect(result.success).toBe(true); expect(result.role).toBe("agent"); }); it("unauthenticated user cannot accept invitation", async () => { const adminCaller = appRouter.createCaller(createContext("admin")); const inv = await adminCaller.invitations.send({ email: "noauth@example.com", role: "agent" }); const publicCaller = appRouter.createCaller(createContext(null)); await expect( publicCaller.invitations.accept({ token: inv.token }) ).rejects.toThrow(); }); }); describe("Resend invitation", () => { it("admin can resend a pending invitation", async () => { const caller = appRouter.createCaller(createContext("admin")); const inv = await caller.invitations.send({ email: "resend@example.com", role: "agent" }); const result = await caller.invitations.resend({ invitationId: inv.id }); expect(result).toHaveProperty("email", "resend@example.com"); expect(result).toHaveProperty("status", "pending"); expect(result.token).not.toBe(inv.token); // New token }); }); }); /* ═══════════════════════════════════════════════════════════════ USER DELETION ═══════════════════════════════════════════════════════════════ */ describe("User deletion", () => { beforeEach(() => { mockAuditLogs.length = 0; auditIdCounter = 1; }); it("admin can delete a user", async () => { const caller = appRouter.createCaller(createContext("admin")); const result = await caller.users.delete({ userId: 2 }); expect(result.success).toBe(true); }); it("admin cannot delete themselves", async () => { const caller = appRouter.createCaller(createContext("admin")); await expect( caller.users.delete({ userId: 1 }) ).rejects.toThrow("You cannot delete your own account"); }); it("agent cannot delete users", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect( caller.users.delete({ userId: 3 }) ).rejects.toThrow(); }); it("regular user cannot delete users", async () => { const caller = appRouter.createCaller(createContext("user")); await expect( caller.users.delete({ userId: 2 }) ).rejects.toThrow(); }); }); /* ═══════════════════════════════════════════════════════════════ BULK ACTIONS ═══════════════════════════════════════════════════════════════ */ describe("Bulk actions", () => { beforeEach(() => { mockAuditLogs.length = 0; auditIdCounter = 1; }); it("admin can bulk update roles", async () => { const caller = appRouter.createCaller(createContext("admin")); const results = await caller.users.bulkUpdateRole({ userIds: [2, 3], role: "agent" }); expect(results).toHaveLength(2); expect(results.every(r => r.success)).toBe(true); }); it("bulk update skips self", async () => { const caller = appRouter.createCaller(createContext("admin")); const results = await caller.users.bulkUpdateRole({ userIds: [1, 2], role: "user" }); expect(results[0].success).toBe(false); expect(results[0].error).toBe("Cannot change own role"); expect(results[1].success).toBe(true); }); it("admin can bulk delete users", async () => { const caller = appRouter.createCaller(createContext("admin")); const results = await caller.users.bulkDelete({ userIds: [2, 3] }); expect(results).toHaveLength(2); expect(results.every(r => r.success)).toBe(true); }); it("bulk delete skips self", async () => { const caller = appRouter.createCaller(createContext("admin")); const results = await caller.users.bulkDelete({ userIds: [1, 2] }); expect(results[0].success).toBe(false); expect(results[0].error).toBe("Cannot delete own account"); expect(results[1].success).toBe(true); }); it("agent cannot perform bulk actions", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect( caller.users.bulkUpdateRole({ userIds: [3], role: "agent" }) ).rejects.toThrow(); await expect( caller.users.bulkDelete({ userIds: [3] }) ).rejects.toThrow(); }); }); /* ═══════════════════════════════════════════════════════════════ AUDIT LOGS ═══════════════════════════════════════════════════════════════ */ describe("Audit logs", () => { beforeEach(() => { mockAuditLogs.length = 0; auditIdCounter = 1; }); it("admin can view audit logs", async () => { const caller = appRouter.createCaller(createContext("admin")); const result = await caller.auditLogs.list({ limit: 10 }); expect(Array.isArray(result)).toBe(true); }); it("agent cannot view audit logs", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect(caller.auditLogs.list({ limit: 10 })).rejects.toThrow(); }); it("audit log is created on role change", async () => { const caller = appRouter.createCaller(createContext("admin")); await caller.users.updateRole({ userId: 2, role: "admin" }); expect(mockAuditLogs.length).toBeGreaterThan(0); expect(mockAuditLogs[0].action).toBe("role_change"); }); it("audit log is created on user deletion", async () => { const caller = appRouter.createCaller(createContext("admin")); await caller.users.delete({ userId: 2 }); expect(mockAuditLogs.some(l => l.action === "user_deleted")).toBe(true); }); it("audit log is created on invitation send", async () => { mockInvitations.length = 0; inviteIdCounter = 1; const caller = appRouter.createCaller(createContext("admin")); await caller.invitations.send({ email: "auditlog@example.com", role: "agent" }); expect(mockAuditLogs.some(l => l.action === "invitation_sent")).toBe(true); }); }); /* ═══════════════════════════════════════════════════════════════ CSV EXPORT ═══════════════════════════════════════════════════════════════ */ describe("CSV export", () => { it("admin can export users as CSV", async () => { const caller = appRouter.createCaller(createContext("admin")); const result = await caller.users.exportCsv(); expect(result).toHaveProperty("csv"); expect(result).toHaveProperty("count", 3); expect(result.csv).toContain("ID,Name,Email,Role"); expect(result.csv).toContain("admin@homelegance.com"); }); it("agent cannot export CSV", async () => { const caller = appRouter.createCaller(createContext("agent")); await expect(caller.users.exportCsv()).rejects.toThrow(); }); });