feat: dispositivos e ajustes de csat e relatórios
This commit is contained in:
parent
25d2a9b062
commit
e0ef66555d
86 changed files with 5811 additions and 992 deletions
|
|
@ -1,15 +1,15 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { normalizeMachineRemoteAccess } from "@/components/admin/machines/admin-machines-overview"
|
||||
import { normalizeDeviceRemoteAccess } from "@/components/admin/devices/admin-devices-overview"
|
||||
|
||||
describe("normalizeMachineRemoteAccess", () => {
|
||||
describe("normalizeDeviceRemoteAccess", () => {
|
||||
it("returns null when value is empty", () => {
|
||||
expect(normalizeMachineRemoteAccess(undefined)).toBeNull()
|
||||
expect(normalizeMachineRemoteAccess(" ")).toBeNull()
|
||||
expect(normalizeDeviceRemoteAccess(undefined)).toBeNull()
|
||||
expect(normalizeDeviceRemoteAccess(" ")).toBeNull()
|
||||
})
|
||||
|
||||
it("parses plain identifier strings", () => {
|
||||
const result = normalizeMachineRemoteAccess("PC-001")
|
||||
const result = normalizeDeviceRemoteAccess("PC-001")
|
||||
expect(result).toEqual({
|
||||
provider: null,
|
||||
identifier: "PC-001",
|
||||
|
|
@ -21,7 +21,7 @@ describe("normalizeMachineRemoteAccess", () => {
|
|||
})
|
||||
|
||||
it("detects URLs in string input", () => {
|
||||
const result = normalizeMachineRemoteAccess("https://remote.example.com/session/123")
|
||||
const result = normalizeDeviceRemoteAccess("https://remote.example.com/session/123")
|
||||
expect(result).toEqual({
|
||||
provider: null,
|
||||
identifier: null,
|
||||
|
|
@ -34,7 +34,7 @@ describe("normalizeMachineRemoteAccess", () => {
|
|||
|
||||
it("normalizes object payload with aliases", () => {
|
||||
const timestamp = 1_701_234_567_890
|
||||
const result = normalizeMachineRemoteAccess({
|
||||
const result = normalizeDeviceRemoteAccess({
|
||||
provider: "AnyDesk",
|
||||
code: "123-456-789",
|
||||
remoteUrl: "https://anydesk.com/session/123",
|
||||
263
tests/tickets.lifecycle.test.ts
Normal file
263
tests/tickets.lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { resolveTicketHandler, reopenTicketHandler } from "../convex/tickets"
|
||||
import { requireStaff, requireUser } from "../convex/rbac"
|
||||
|
||||
type ResolveCtx = Parameters<typeof resolveTicketHandler>[0]
|
||||
type ReopenCtx = Parameters<typeof reopenTicketHandler>[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<TicketDoc> = {}): 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: null,
|
||||
status: "AWAITING_ATTENDANCE",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: null,
|
||||
queueSnapshot: null,
|
||||
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: null,
|
||||
companySnapshot: null,
|
||||
machineId: null,
|
||||
machineSnapshot: null,
|
||||
slaPolicyId: null,
|
||||
dueAt: null,
|
||||
firstResponseAt: null,
|
||||
resolvedAt: null,
|
||||
createdAt: NOW - 50_000,
|
||||
updatedAt: NOW - 1_000,
|
||||
tags: [],
|
||||
customFields: [],
|
||||
activeSessionId: null,
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
csatScore: undefined,
|
||||
csatMaxScore: undefined,
|
||||
csatComment: undefined,
|
||||
csatRatedAt: undefined,
|
||||
csatRatedBy: undefined,
|
||||
csatAssigneeId: undefined,
|
||||
csatAssigneeSnapshot: undefined,
|
||||
workSummary: undefined,
|
||||
reopenDeadline: undefined,
|
||||
reopenedAt: undefined,
|
||||
formTemplate: undefined,
|
||||
chatEnabled: true,
|
||||
relatedTicketIds: undefined,
|
||||
resolvedWithTicketId: undefined,
|
||||
reopenWindowDays: undefined,
|
||||
reopenedBy: undefined,
|
||||
}
|
||||
return { ...(base as TicketDoc), ...overrides }
|
||||
}
|
||||
|
||||
function createResolveCtx(tickets: Record<string, TicketDoc>, 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">,
|
||||
name: "Agente",
|
||||
email: "agente@example.com",
|
||||
},
|
||||
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 }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
email: "cliente@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
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,
|
||||
email: "cliente@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
role: "COLLABORATOR",
|
||||
})
|
||||
|
||||
await expect(
|
||||
reopenTicketHandler(ctx, {
|
||||
ticketId: ticket._id,
|
||||
actorId: ticket.requesterId,
|
||||
})
|
||||
).rejects.toThrow("O prazo para reabrir este chamado expirou")
|
||||
})
|
||||
})
|
||||
219
tests/tickets.submitCsat.test.ts
Normal file
219
tests/tickets.submitCsat.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { Doc, Id } from "../convex/_generated/dataModel"
|
||||
import { submitCsatHandler } from "../convex/tickets"
|
||||
import { requireUser } from "../convex/rbac"
|
||||
|
||||
vi.mock("../convex/rbac", () => ({
|
||||
requireUser: vi.fn(),
|
||||
requireStaff: vi.fn(),
|
||||
requireAdmin: vi.fn(),
|
||||
}))
|
||||
|
||||
type SubmitCsatCtx = Parameters<typeof submitCsatHandler>[0]
|
||||
|
||||
const mockedRequireUser = vi.mocked(requireUser)
|
||||
|
||||
const FIXED_NOW = 1_706_500_000_000
|
||||
|
||||
function makeTicket(overrides: Partial<Doc<"tickets">> = {}): Doc<"tickets"> {
|
||||
const ticket = {
|
||||
_id: "ticket_1" as Id<"tickets">,
|
||||
_creationTime: FIXED_NOW - 10_000,
|
||||
tenantId: "tenant-1",
|
||||
reference: 42_100,
|
||||
subject: "Computador não liga",
|
||||
summary: null,
|
||||
status: "RESOLVED",
|
||||
priority: "MEDIUM",
|
||||
channel: "EMAIL",
|
||||
queueId: null,
|
||||
queueSnapshot: null,
|
||||
requesterId: "user_requester" as Id<"users">,
|
||||
requesterSnapshot: { name: "Cliente", email: "cliente@example.com" },
|
||||
assigneeId: null,
|
||||
assigneeSnapshot: null,
|
||||
companyId: null,
|
||||
companySnapshot: null,
|
||||
machineId: null,
|
||||
machineSnapshot: null,
|
||||
slaPolicyId: null,
|
||||
dueAt: null,
|
||||
firstResponseAt: null,
|
||||
resolvedAt: FIXED_NOW - 1000,
|
||||
createdAt: FIXED_NOW - 20_000,
|
||||
updatedAt: FIXED_NOW - 100,
|
||||
tags: [],
|
||||
customFields: [],
|
||||
activeSessionId: null,
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
csatScore: undefined,
|
||||
csatMaxScore: undefined,
|
||||
csatComment: undefined,
|
||||
csatRatedAt: undefined,
|
||||
csatRatedBy: undefined,
|
||||
} satisfies Partial<Doc<"tickets">>
|
||||
return { ...(ticket as Doc<"tickets">), ...overrides }
|
||||
}
|
||||
|
||||
function createCtx(ticket: Doc<"tickets">, events: Doc<"ticketEvents">[] = []) {
|
||||
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 patch = vi.fn(async () => undefined)
|
||||
const insert = vi.fn(async () => undefined)
|
||||
const deleteFn = vi.fn(async () => undefined)
|
||||
|
||||
const ctx: SubmitCsatCtx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: Id<"tickets"> | Id<"users">) => {
|
||||
if (id === ticket._id) return ticket
|
||||
return null
|
||||
}),
|
||||
patch,
|
||||
query,
|
||||
delete: deleteFn,
|
||||
insert,
|
||||
},
|
||||
} as unknown as SubmitCsatCtx
|
||||
|
||||
return { ctx, patch, insert, deleteFn, query }
|
||||
}
|
||||
|
||||
describe("convex.tickets.submitCsat", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("permite que o solicitante avalie um chamado resolvido", async () => {
|
||||
const ticket = makeTicket()
|
||||
const { ctx, patch, insert, deleteFn } = createCtx(ticket)
|
||||
mockedRequireUser.mockResolvedValue({
|
||||
user: {
|
||||
_id: "user_requester" as Id<"users">,
|
||||
email: "cliente@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
role: "COLLABORATOR",
|
||||
})
|
||||
|
||||
const result = await submitCsatHandler(ctx, {
|
||||
ticketId: ticket._id,
|
||||
actorId: "user_requester" as Id<"users">,
|
||||
score: 4,
|
||||
maxScore: 5,
|
||||
comment: "Atendimento excelente!",
|
||||
})
|
||||
|
||||
expect(result.score).toBe(4)
|
||||
expect(result.comment).toBe("Atendimento excelente!")
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
ticket._id,
|
||||
expect.objectContaining({
|
||||
csatScore: 4,
|
||||
csatMaxScore: 5,
|
||||
csatComment: "Atendimento excelente!",
|
||||
csatRatedBy: "user_requester",
|
||||
})
|
||||
)
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"ticketEvents",
|
||||
expect.objectContaining({
|
||||
ticketId: ticket._id,
|
||||
type: "CSAT_RATED",
|
||||
payload: expect.objectContaining({ score: 4, maxScore: 5, comment: "Atendimento excelente!" }),
|
||||
})
|
||||
)
|
||||
expect(deleteFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("bloqueia avaliações antes do encerramento do chamado para colaboradores", async () => {
|
||||
const ticket = makeTicket({ status: "PENDING" })
|
||||
const { ctx, patch, insert } = createCtx(ticket)
|
||||
mockedRequireUser.mockResolvedValue({
|
||||
user: {
|
||||
_id: "user_requester" as Id<"users">,
|
||||
email: "cliente@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
role: "COLLABORATOR",
|
||||
})
|
||||
|
||||
await expect(
|
||||
submitCsatHandler(ctx, {
|
||||
ticketId: ticket._id,
|
||||
actorId: "user_requester" as Id<"users">,
|
||||
score: 5,
|
||||
comment: "Perfeito",
|
||||
})
|
||||
).rejects.toThrow("Avaliações só são permitidas após o encerramento do chamado")
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("não permite registrar uma segunda avaliação", async () => {
|
||||
const ticket = makeTicket({
|
||||
csatScore: 4,
|
||||
csatMaxScore: 5,
|
||||
csatRatedAt: FIXED_NOW - 2000,
|
||||
csatRatedBy: "user_requester" as Id<"users">,
|
||||
})
|
||||
const { ctx, patch, insert, deleteFn } = createCtx(ticket)
|
||||
mockedRequireUser.mockResolvedValue({
|
||||
user: {
|
||||
_id: "user_requester" as Id<"users">,
|
||||
email: "cliente@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
role: "COLLABORATOR",
|
||||
})
|
||||
|
||||
await expect(
|
||||
submitCsatHandler(ctx, {
|
||||
ticketId: ticket._id,
|
||||
actorId: "user_requester" as Id<"users">,
|
||||
score: 5,
|
||||
comment: "Vou tentar atualizar",
|
||||
})
|
||||
).rejects.toThrow("Este chamado já possui uma avaliação registrada")
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
expect(deleteFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("impede que administradores registrem avaliação", async () => {
|
||||
const ticket = makeTicket()
|
||||
const { ctx, patch, insert } = createCtx(ticket)
|
||||
mockedRequireUser.mockResolvedValue({
|
||||
user: {
|
||||
_id: "user_admin" as Id<"users">,
|
||||
email: "admin@example.com",
|
||||
companyId: null,
|
||||
},
|
||||
role: "ADMIN",
|
||||
})
|
||||
|
||||
await expect(
|
||||
submitCsatHandler(ctx, {
|
||||
ticketId: ticket._id,
|
||||
actorId: "user_admin" as Id<"users">,
|
||||
score: 4,
|
||||
comment: "somente testes",
|
||||
})
|
||||
).rejects.toThrow("Somente o solicitante pode avaliar o chamado")
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
expect(insert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue