import { describe, it, expect, vi, beforeEach } from "bun:test" import type { Doc, Id } from "../convex/_generated/dataModel" const FIXED_NOW = 1_706_071_200_000 const FIVE_MINUTES_MS = 5 * 60 * 1000 type MockDb = { get: ReturnType query: ReturnType insert: ReturnType patch: ReturnType } function createMockDb(): MockDb { return { get: vi.fn(), query: vi.fn(() => ({ withIndex: vi.fn(() => ({ filter: vi.fn(() => ({ first: vi.fn(async () => null), collect: vi.fn(async () => []), take: vi.fn(async () => []), })), first: vi.fn(async () => null), collect: vi.fn(async () => []), take: vi.fn(async () => []), })), filter: vi.fn(() => ({ first: vi.fn(async () => null), collect: vi.fn(async () => []), })), first: vi.fn(async () => null), collect: vi.fn(async () => []), })), insert: vi.fn(async () => "inserted_id" as Id<"liveChatSessions">), patch: vi.fn(async () => {}), } } function buildUser(overrides: Partial> = {}): Doc<"users"> { const user: Record = { _id: "user_1" as Id<"users">, _creationTime: FIXED_NOW - 100_000, tenantId: "tenant-1", name: "Agent Test", email: "agent@test.com", role: "AGENT", authId: "auth_1", avatarUrl: undefined, createdAt: FIXED_NOW - 100_000, updatedAt: FIXED_NOW - 50_000, isActive: true, isInitialAdmin: false, companyId: undefined, teamsIds: [], managingCompaniesIds: [], deletedAt: undefined, } return { ...(user as Doc<"users">), ...overrides } } function buildMachine(overrides: Partial> = {}): Doc<"machines"> { const machine: Record = { _id: "machine_1" as Id<"machines">, _creationTime: FIXED_NOW - 100_000, tenantId: "tenant-1", companyId: undefined, companySlug: undefined, authUserId: undefined, authEmail: undefined, persona: undefined, assignedUserId: undefined, assignedUserEmail: undefined, assignedUserName: undefined, assignedUserRole: undefined, hostname: "desktop-01", osName: "Windows", osVersion: "11", architecture: "x86_64", macAddresses: ["001122334455"], serialNumbers: ["SN123"], fingerprint: "fingerprint", metadata: {}, lastHeartbeatAt: FIXED_NOW - 1000, // Online status: undefined, isActive: true, createdAt: FIXED_NOW - 10_000, updatedAt: FIXED_NOW - 5_000, registeredBy: "agent:desktop", linkedUserIds: [], remoteAccess: null, } return { ...(machine as Doc<"machines">), ...overrides } } function buildTicket(overrides: Partial> = {}): Doc<"tickets"> { const ticket: Record = { _id: "ticket_1" as Id<"tickets">, _creationTime: FIXED_NOW - 50_000, tenantId: "tenant-1", reference: 1001, subject: "Test Ticket", status: "OPEN", priority: "MEDIUM", machineId: "machine_1" as Id<"machines">, requesterId: "user_2" as Id<"users">, assigneeId: undefined, companyId: undefined, categoryId: undefined, slaPolicyId: undefined, tags: [], chatEnabled: false, createdAt: FIXED_NOW - 50_000, updatedAt: FIXED_NOW - 25_000, customFieldValues: {}, channel: "HELPDESK", visibility: "ALL", } return { ...(ticket as Doc<"tickets">), ...overrides } } function buildSession(overrides: Partial> = {}): Doc<"liveChatSessions"> { const session: Record = { _id: "session_1" as Id<"liveChatSessions">, _creationTime: FIXED_NOW - 10_000, tenantId: "tenant-1", ticketId: "ticket_1" as Id<"tickets">, machineId: "machine_1" as Id<"machines">, agentId: "user_1" as Id<"users">, agentSnapshot: { name: "Agent Test", email: "agent@test.com", avatarUrl: undefined, }, status: "ACTIVE", startedAt: FIXED_NOW - 10_000, lastActivityAt: FIXED_NOW - 5_000, unreadByMachine: 0, unreadByAgent: 0, endedAt: undefined, } return { ...(session as Doc<"liveChatSessions">), ...overrides } } describe("liveChat", () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(FIXED_NOW) }) describe("startSession", () => { it("deve criar sessao quando agente valido e maquina online", async () => { const db = createMockDb() const machine = buildMachine({ lastHeartbeatAt: FIXED_NOW - 1000 }) const agent = buildUser({ role: "AGENT" }) const ticket = buildTicket({ machineId: machine._id }) db.get.mockImplementation(async (id: string) => { if (id === ticket._id) return ticket if (id === agent._id) return agent if (id === machine._id) return machine return null }) db.query.mockReturnValue({ withIndex: vi.fn(() => ({ filter: vi.fn(() => ({ first: vi.fn(async () => null), // Nenhuma sessao ativa })), })), }) db.insert.mockResolvedValue("new_session_id" as Id<"liveChatSessions">) // Simular chamada da mutation const ticketFromDb = await db.get(ticket._id) const agentFromDb = await db.get(agent._id) const machineFromDb = await db.get(ticket.machineId) expect(ticketFromDb).toBeTruthy() expect(agentFromDb?.role?.toUpperCase()).toBe("AGENT") expect(machineFromDb?.lastHeartbeatAt).toBeGreaterThan(FIXED_NOW - FIVE_MINUTES_MS) }) it("deve falhar quando maquina offline", async () => { const offlineMachine = buildMachine({ lastHeartbeatAt: FIXED_NOW - FIVE_MINUTES_MS - 1000, // Offline }) const isOffline = !offlineMachine.lastHeartbeatAt || offlineMachine.lastHeartbeatAt < FIXED_NOW - FIVE_MINUTES_MS expect(isOffline).toBe(true) }) it("deve retornar sessao existente se ja houver uma ativa", async () => { const existingSession = buildSession() const db = createMockDb() db.query.mockReturnValue({ withIndex: vi.fn(() => ({ filter: vi.fn(() => ({ first: vi.fn(async () => existingSession), })), })), }) const queryResult = db.query("liveChatSessions") const withIndexResult = queryResult.withIndex("by_ticket", vi.fn()) const filterResult = withIndexResult.filter(vi.fn()) const session = await filterResult.first() expect(session).toBeTruthy() expect(session?._id).toBe(existingSession._id) }) it("deve falhar quando usuario nao e agente", async () => { const clientUser = buildUser({ role: "CLIENT" }) const role = clientUser.role?.toUpperCase() ?? "" const isAllowed = ["ADMIN", "MANAGER", "AGENT"].includes(role) expect(isAllowed).toBe(false) }) }) describe("endSession", () => { it("deve encerrar sessao ativa", async () => { const session = buildSession({ status: "ACTIVE" }) const agent = buildUser({ role: "AGENT", tenantId: session.tenantId }) expect(session.status).toBe("ACTIVE") const role = agent.role?.toUpperCase() ?? "" const canEnd = ["ADMIN", "MANAGER", "AGENT"].includes(role) expect(canEnd).toBe(true) }) it("deve falhar quando sessao ja encerrada", async () => { const endedSession = buildSession({ status: "ENDED" }) expect(endedSession.status).not.toBe("ACTIVE") }) it("deve calcular duracao corretamente", async () => { const session = buildSession({ startedAt: FIXED_NOW - 10_000, }) const endedAt = FIXED_NOW const durationMs = endedAt - session.startedAt expect(durationMs).toBe(10_000) }) }) describe("postMachineMessage", () => { it("deve enviar mensagem quando token e sessao validos", async () => { const session = buildSession({ status: "ACTIVE" }) const machine = buildMachine() const ticket = buildTicket({ machineId: machine._id }) // Validar pre-condicoes expect(session.status).toBe("ACTIVE") expect(ticket.machineId?.toString()).toBe(machine._id.toString()) }) it("deve limitar tamanho da mensagem a 4000 caracteres", async () => { const longMessage = "a".repeat(4001) const isValid = longMessage.length <= 4000 expect(isValid).toBe(false) const validMessage = "a".repeat(4000) expect(validMessage.length <= 4000).toBe(true) }) it("deve falhar quando nao existe sessao ativa", async () => { const db = createMockDb() db.query.mockReturnValue({ withIndex: vi.fn(() => ({ filter: vi.fn(() => ({ first: vi.fn(async () => null), // Nenhuma sessao })), })), }) const queryResult = db.query("liveChatSessions") const session = await queryResult.withIndex().filter().first() expect(session).toBeNull() }) }) describe("autoEndInactiveSessions", () => { it("deve identificar sessoes inativas por mais de 5 minutos", async () => { const inactiveSession = buildSession({ status: "ACTIVE", lastActivityAt: FIXED_NOW - FIVE_MINUTES_MS - 1000, // Inativa }) const cutoffTime = FIXED_NOW - FIVE_MINUTES_MS const isInactive = inactiveSession.lastActivityAt < cutoffTime expect(isInactive).toBe(true) }) it("nao deve encerrar sessoes ativas recentemente", async () => { const activeSession = buildSession({ status: "ACTIVE", lastActivityAt: FIXED_NOW - 1000, // Ativa recentemente }) const cutoffTime = FIXED_NOW - FIVE_MINUTES_MS const isInactive = activeSession.lastActivityAt < cutoffTime expect(isInactive).toBe(false) }) it("deve respeitar limite maximo de sessoes por execucao", async () => { const maxSessionsPerRun = 50 const sessions = Array.from({ length: 100 }, (_, i) => buildSession({ _id: `session_${i}` as Id<"liveChatSessions">, lastActivityAt: FIXED_NOW - FIVE_MINUTES_MS - 1000, }) ) const sessionsToProcess = sessions.slice(0, maxSessionsPerRun) expect(sessionsToProcess.length).toBe(50) }) }) describe("markMachineMessagesRead", () => { it("deve processar no maximo 50 mensagens por chamada", async () => { const maxMessages = 50 const messageIds = Array.from( { length: 100 }, (_, i) => `msg_${i}` as Id<"ticketChatMessages"> ) const messageIdsToProcess = messageIds.slice(0, maxMessages) expect(messageIdsToProcess.length).toBe(50) }) it("deve ignorar mensagens ja lidas pelo usuario", async () => { const userId = "user_1" as Id<"users"> const readBy = [{ userId, readAt: FIXED_NOW - 1000 }] const alreadyRead = readBy.some((r) => r.userId.toString() === userId.toString()) expect(alreadyRead).toBe(true) }) }) describe("checkMachineUpdates (polling)", () => { it("deve retornar sessoes ativas para a maquina", async () => { const machine = buildMachine() const session = buildSession({ machineId: machine._id, status: "ACTIVE" }) expect(session.machineId.toString()).toBe(machine._id.toString()) expect(session.status).toBe("ACTIVE") }) it("deve filtrar mensagens por lastCheckedAt", async () => { const lastCheckedAt = FIXED_NOW - 5000 const messages = [ { createdAt: FIXED_NOW - 10_000 }, // Antes do check { createdAt: FIXED_NOW - 3000 }, // Depois do check { createdAt: FIXED_NOW - 1000 }, // Depois do check ] const newMessages = messages.filter((m) => m.createdAt > lastCheckedAt) expect(newMessages.length).toBe(2) }) }) }) describe("rate-limit", () => { it("deve respeitar limites de requisicoes", async () => { const limits = { CHAT_POLL: { maxRequests: 60, windowMs: 60_000 }, CHAT_MESSAGES: { maxRequests: 30, windowMs: 60_000 }, CHAT_SESSIONS: { maxRequests: 30, windowMs: 60_000 }, } expect(limits.CHAT_POLL.maxRequests).toBe(60) expect(limits.CHAT_MESSAGES.maxRequests).toBe(30) expect(limits.CHAT_SESSIONS.maxRequests).toBe(30) }) }) describe("retry", () => { it("deve calcular backoff exponencial corretamente", async () => { const baseDelayMs = 100 const maxDelayMs = 2000 const delays = [0, 1, 2, 3].map((attempt) => { const exponentialDelay = baseDelayMs * Math.pow(2, attempt) return Math.min(exponentialDelay, maxDelayMs) }) expect(delays[0]).toBe(100) expect(delays[1]).toBe(200) expect(delays[2]).toBe(400) expect(delays[3]).toBe(800) }) it("deve respeitar maxDelayMs", async () => { const baseDelayMs = 100 const maxDelayMs = 2000 const attempt = 10 // 100 * 2^10 = 102400 const exponentialDelay = baseDelayMs * Math.pow(2, attempt) const delay = Math.min(exponentialDelay, maxDelayMs) expect(delay).toBe(maxDelayMs) }) })