import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test" import type { Doc, Id } from "../convex/_generated/dataModel" import { resolveTicketHandler, reopenTicketHandler } from "../convex/tickets" import { requireStaff, requireUser } from "../convex/rbac" type ResolveCtx = Parameters[0] type ReopenCtx = Parameters[0] type TicketDoc = Doc<"tickets"> type MockedTicket = TicketDoc & { _id: Id<"tickets"> } defineMocks() function defineMocks() { vi.mock("../convex/rbac", () => ({ requireStaff: vi.fn(), requireUser: vi.fn(), requireAdmin: vi.fn(), })) } const mockedRequireStaff = vi.mocked(requireStaff) const mockedRequireUser = vi.mocked(requireUser) const NOW = 1_706_700_000_000 beforeEach(() => { vi.setSystemTime(NOW) }) afterEach(() => { vi.clearAllMocks() }) function buildTicket(overrides: Partial = {}): MockedTicket { const base: TicketDoc = { _id: "ticket_main" as Id<"tickets">, _creationTime: NOW - 10_000, tenantId: "tenant-1", reference: 41_000, subject: "Computador não liga", summary: undefined, status: "AWAITING_ATTENDANCE", priority: "MEDIUM", channel: "EMAIL", queueId: undefined, requesterId: "user_requester" as Id<"users">, requesterSnapshot: { name: "Cliente", email: "cliente@example.com" }, assigneeId: "user_agent" as Id<"users">, assigneeSnapshot: { name: "Agente", email: "agente@example.com" }, companyId: undefined, companySnapshot: undefined, machineId: undefined, machineSnapshot: undefined, slaPolicyId: undefined, dueAt: undefined, firstResponseAt: undefined, resolvedAt: undefined, createdAt: NOW - 50_000, updatedAt: NOW - 1_000, tags: [], customFields: [], activeSessionId: undefined, totalWorkedMs: 0, internalWorkedMs: 0, externalWorkedMs: 0, csatScore: undefined, csatMaxScore: undefined, csatComment: undefined, csatRatedAt: undefined, csatRatedBy: undefined, csatAssigneeId: undefined, csatAssigneeSnapshot: undefined, reopenDeadline: undefined, reopenedAt: undefined, formTemplate: undefined, chatEnabled: true, relatedTicketIds: undefined, resolvedWithTicketId: undefined, } return { ...(base as TicketDoc), ...overrides } } function createResolveCtx(tickets: Record, events: Doc<"ticketEvents">[] = []) { const patch = vi.fn(async () => undefined) const insert = vi.fn(async () => undefined) const collect = vi.fn(async () => events) const query = vi.fn(() => ({ withIndex: vi.fn((_index: string, cb: (builder: { eq: (field: string, value: unknown) => { collect: typeof collect } }) => { collect: typeof collect }) => { const cursor = { eq: vi.fn(() => ({ collect })), } const result = cb(cursor as { eq: (field: string, value: unknown) => { collect: typeof collect } }) return result ?? { collect } }), })) const ctx: ResolveCtx = { db: { get: vi.fn(async (id: Id<"tickets"> | Id<"users">) => { return tickets[String(id)] ?? null }), patch, insert, query, delete: vi.fn(), }, } as unknown as ResolveCtx return { ctx, patch, insert } } function createReopenCtx(ticket: TicketDoc) { const patch = vi.fn(async () => undefined) const insert = vi.fn(async () => undefined) const ctx: ReopenCtx = { db: { get: vi.fn(async () => ticket), patch, insert, query: vi.fn(), delete: vi.fn(), }, } as unknown as ReopenCtx return { ctx, patch, insert } } describe("convex.tickets.resolveTicketHandler", () => { it("marca o ticket como resolvido, vincula outro ticket e define prazo de reabertura", async () => { const ticketMain = buildTicket() const ticketLinked = buildTicket({ _id: "ticket_related" as Id<"tickets">, reference: 41_001, status: "AWAITING_ATTENDANCE", }) const { ctx, patch, insert } = createResolveCtx({ [String(ticketMain._id)]: ticketMain, [String(ticketLinked._id)]: ticketLinked, }) mockedRequireStaff.mockResolvedValue({ user: { _id: "user_agent" as Id<"users">, _creationTime: NOW - 5_000, tenantId: "tenant-1", name: "Agente", email: "agente@example.com", role: "ADMIN", teams: [], }, role: "ADMIN", }) const result = await resolveTicketHandler(ctx, { ticketId: ticketMain._id, actorId: "user_agent" as Id<"users">, resolvedWithTicketId: ticketLinked._id, reopenWindowDays: 14, }) expect(result.ok).toBe(true) expect(result.reopenWindowDays).toBe(14) expect(result.reopenDeadline).toBe(NOW + 14 * 24 * 60 * 60 * 1000) expect(patch).toHaveBeenCalledWith( ticketMain._id, expect.objectContaining({ status: "RESOLVED", reopenDeadline: result.reopenDeadline, resolvedWithTicketId: ticketLinked._id, }) ) expect(patch).toHaveBeenCalledWith( ticketLinked._id, expect.objectContaining({ relatedTicketIds: expect.arrayContaining([ticketMain._id]), }) ) expect(insert).toHaveBeenCalledWith( "ticketEvents", expect.objectContaining({ ticketId: ticketMain._id, type: "STATUS_CHANGED", payload: expect.objectContaining({ to: "RESOLVED" }), }) ) expect(insert).toHaveBeenCalledWith( "ticketEvents", expect.objectContaining({ ticketId: ticketLinked._id, type: "TICKET_LINKED", payload: expect.objectContaining({ linkedTicketId: ticketMain._id }), }) ) }) it("impede encerrar ticket quando há checklist obrigatório pendente", async () => { const ticketMain = buildTicket({ checklist: [{ id: "c1", text: "Validar backup", done: false, required: true }] as unknown as TicketDoc["checklist"], }) const { ctx, patch } = createResolveCtx({ [String(ticketMain._id)]: ticketMain, }) mockedRequireStaff.mockResolvedValue({ user: { _id: "user_agent" as Id<"users">, _creationTime: NOW - 5_000, tenantId: "tenant-1", name: "Agente", email: "agente@example.com", role: "ADMIN", teams: [], }, role: "ADMIN", }) await expect( resolveTicketHandler(ctx, { ticketId: ticketMain._id, actorId: "user_agent" as Id<"users">, }) ).rejects.toThrow("checklist") expect(patch).not.toHaveBeenCalled() }) }) describe("convex.tickets.reopenTicketHandler", () => { it("reabre o ticket quando dentro do prazo e permissões válidas", async () => { const ticket = buildTicket({ status: "RESOLVED", reopenDeadline: NOW + 3 * 24 * 60 * 60 * 1000, resolvedAt: NOW - 60_000, }) const { ctx, patch, insert } = createReopenCtx(ticket) mockedRequireUser.mockResolvedValue({ user: { _id: ticket.requesterId, _creationTime: NOW - 20_000, tenantId: "tenant-1", email: "cliente@example.com", name: "Cliente", role: "COLLABORATOR", companyId: undefined, teams: [], }, role: "COLLABORATOR", }) const result = await reopenTicketHandler(ctx, { ticketId: ticket._id, actorId: ticket.requesterId, }) expect(result.ok).toBe(true) expect(result.reopenedAt).toBe(NOW) expect(patch).toHaveBeenCalledWith( ticket._id, expect.objectContaining({ status: "AWAITING_ATTENDANCE", reopenedAt: NOW, resolvedAt: undefined }) ) expect(insert).toHaveBeenCalledWith( "ticketEvents", expect.objectContaining({ ticketId: ticket._id, type: "TICKET_REOPENED" }) ) }) it("falha quando o prazo para reabrir expirou", async () => { const ticket = buildTicket({ status: "RESOLVED", reopenDeadline: NOW - 1, }) const { ctx } = createReopenCtx(ticket) mockedRequireUser.mockResolvedValue({ user: { _id: ticket.requesterId, _creationTime: NOW - 25_000, tenantId: "tenant-1", email: "cliente@example.com", name: "Cliente", role: "COLLABORATOR", companyId: undefined, teams: [], }, role: "COLLABORATOR", }) await expect( reopenTicketHandler(ctx, { ticketId: ticket._id, actorId: ticket.requesterId, }) ).rejects.toThrow("O prazo para reabrir este chamado expirou") }) })