chore: expand reports coverage and upgrade next

This commit is contained in:
codex-bot 2025-10-31 17:27:51 -03:00
parent 2fb587b01d
commit 8b82284e8c
21 changed files with 2952 additions and 2713 deletions

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { getById } from "../convex/machines"
import { getByIdHandler } from "../convex/machines"
const FIXED_NOW = 1_706_071_200_000
@ -65,8 +65,8 @@ describe("convex.machines.getById", () => {
})),
}
const ctx = { db } as unknown as Parameters<typeof getById>[0]
const result = await getById(ctx, { id: machine._id, includeMetadata: true })
const ctx = { db } as unknown as Parameters<typeof getByIdHandler>[0]
const result = await getByIdHandler(ctx, { id: machine._id, includeMetadata: true })
expect(result).toBeTruthy()
expect(result?.metrics).toBeTruthy()
@ -75,4 +75,3 @@ describe("convex.machines.getById", () => {
expect(result?.token).toBeTruthy()
})
})

View file

@ -0,0 +1,255 @@
import { describe, expect, it, vi } from "vitest"
import type { Doc, Id } from "../convex/_generated/dataModel"
import { getTicketsHistoryStatsHandler, listTicketsHistoryHandler } from "../convex/machines"
const MACHINE_ID = "machine_1" as Id<"machines">
const TENANT_ID = "tenant-1"
function buildMachine(overrides: Partial<Doc<"machines">> = {}): Doc<"machines"> {
const machine: Record<string, unknown> = {
_id: MACHINE_ID,
tenantId: TENANT_ID,
hostname: "desktop-01",
macAddresses: [],
serialNumbers: [],
fingerprint: "fp",
isActive: true,
lastHeartbeatAt: Date.now(),
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
linkedUserIds: [],
remoteAccess: null,
}
return { ...(machine as Doc<"machines">), ...overrides }
}
function buildTicket(overrides: Partial<Doc<"tickets">> = {}): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 42600,
subject: "Generic ticket",
summary: "",
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: MACHINE_ID,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now() - 2000,
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
type PaginateHandler = (options: { cursor: string | null; numItems: number }) => Promise<{
page: Doc<"tickets">[]
isDone: boolean
continueCursor: string
}>
function createCtx({
machine = buildMachine(),
queues = new Map<string, Doc<"queues">>(),
paginate,
}: {
machine?: Doc<"machines">
queues?: Map<string, Doc<"queues">>
paginate: PaginateHandler
}) {
const createFilterBuilder = () => {
const builder: Record<string, (..._args: unknown[]) => typeof builder> = {}
builder.eq = () => builder
builder.gte = () => builder
builder.lte = () => builder
builder.or = () => builder
return builder
}
return {
db: {
get: vi.fn(async (id: Id<"machines"> | Id<"queues">) => {
if (machine && id === machine._id) return machine
const queue = queues.get(String(id))
if (queue) return queue
return null
}),
query: vi.fn(() => {
const chain = {
filter: vi.fn((cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
cb?.(createFilterBuilder())
return chain
}),
paginate: vi.fn((options: { cursor: string | null; numItems: number }) => paginate(options)),
}
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: ReturnType<typeof createFilterBuilder>) => unknown) => {
cb?.(createFilterBuilder())
return {
order: vi.fn(() => chain),
}
}),
}
}),
},
} as unknown as Parameters<typeof listTicketsHistoryHandler>[0]
}
describe("convex.machines.listTicketsHistory", () => {
it("maps tickets metadata and resolves queue names", async () => {
const machine = buildMachine()
const ticket = buildTicket({
_id: "ticket_1" as Id<"tickets">,
subject: "Printer offline",
priority: "HIGH",
status: "PENDING",
queueId: "queue_1" as Id<"queues">,
updatedAt: 170000,
createdAt: 160000,
})
const paginate = vi.fn(async () => ({
page: [ticket],
isDone: false,
continueCursor: "cursor-next",
}))
const queues = new Map<string, Doc<"queues">>([
["queue_1", { _id: "queue_1" as Id<"queues">, name: "Atendimento", tenantId: TENANT_ID } as Doc<"queues">],
])
const ctx = createCtx({ machine, queues, paginate })
const result = await listTicketsHistoryHandler(ctx, {
machineId: machine._id,
paginationOpts: { numItems: 25, cursor: null },
})
expect(paginate).toHaveBeenCalledWith({ numItems: 25, cursor: null })
expect(result.page).toHaveLength(1)
expect(result.page[0]).toMatchObject({
id: "ticket_1",
subject: "Printer offline",
priority: "HIGH",
status: "PENDING",
queue: "Atendimento",
})
expect(result.continueCursor).toBe("cursor-next")
})
it("applies search filtering over paginated results", async () => {
const machine = buildMachine()
const ticketMatches = buildTicket({
_id: "ticket_match" as Id<"tickets">,
reference: 44321,
subject: "Notebook com tela quebrada",
requesterSnapshot: { name: "Carla", email: "carla@example.com", avatarUrl: undefined, teams: [] },
})
const ticketIgnored = buildTicket({
_id: "ticket_other" as Id<"tickets">,
subject: "Troca de teclado",
requesterSnapshot: { name: "Roberto", email: "roberto@example.com", avatarUrl: undefined, teams: [] },
})
const paginate = vi.fn(async () => ({
page: [ticketMatches, ticketIgnored],
isDone: true,
continueCursor: "",
}))
const ctx = createCtx({ machine, paginate })
const result = await listTicketsHistoryHandler(ctx, {
machineId: machine._id,
search: "notebook",
paginationOpts: { numItems: 50, cursor: null },
})
expect(result.page).toHaveLength(1)
expect(result.page[0].id).toBe("ticket_match")
expect(result.isDone).toBe(true)
})
})
describe("convex.machines.getTicketsHistoryStats", () => {
it("aggregates totals across multiple pages respecting open status", async () => {
const machine = buildMachine()
const firstPageTicket = buildTicket({
_id: "ticket_open" as Id<"tickets">,
status: "AWAITING_ATTENDANCE",
})
const secondPageTicket = buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
})
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
if (!cursor) {
return { page: [firstPageTicket], isDone: false, continueCursor: "cursor-1" }
}
return { page: [secondPageTicket], isDone: true, continueCursor: "" }
})
const ctx = createCtx({ machine, paginate })
const stats = await getTicketsHistoryStatsHandler(
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
{
machineId: machine._id,
}
)
expect(stats).toEqual({ total: 2, openCount: 1, resolvedCount: 1 })
expect(paginate).toHaveBeenCalledTimes(2)
})
it("filters results by search term when aggregating", async () => {
const machine = buildMachine()
const matchingTicket = buildTicket({
_id: "ticket_search" as Id<"tickets">,
subject: "Notebook com lentidão",
})
const nonMatchingTicket = buildTicket({
_id: "ticket_ignored" as Id<"tickets">,
subject: "Impressora parada",
})
const paginate = vi.fn(async ({ cursor }: { cursor: string | null }) => {
if (!cursor) {
return { page: [matchingTicket, nonMatchingTicket], isDone: true, continueCursor: "" }
}
return { page: [], isDone: true, continueCursor: "" }
})
const ctx = createCtx({ machine, paginate })
const stats = await getTicketsHistoryStatsHandler(
ctx as unknown as Parameters<typeof getTicketsHistoryStatsHandler>[0],
{
machineId: machine._id,
search: "notebook",
}
)
expect(stats).toEqual({ total: 1, openCount: 1, resolvedCount: 0 })
})
})

View file

@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
import { updatePersona } from "../convex/machines"
import { updatePersonaHandler } from "../convex/machines"
import type { Doc, Id } from "../convex/_generated/dataModel"
const FIXED_NOW = 1_706_071_200_000
@ -63,9 +63,9 @@ describe("convex.machines.updatePersona", () => {
return null
})
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
const result = await updatePersona(ctx, { machineId: machine._id, persona: "" })
const result = await updatePersonaHandler(ctx, { machineId: machine._id, persona: "" })
expect(result).toEqual({ ok: true, persona: null })
expect(patch).toHaveBeenCalledTimes(1)
@ -92,10 +92,10 @@ describe("convex.machines.updatePersona", () => {
return null
})
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersona>[0]
const ctx = { db: { get, patch } } as unknown as Parameters<typeof updatePersonaHandler>[0]
await expect(
updatePersona(ctx, {
updatePersonaHandler(ctx, {
machineId: machine._id,
persona: "collaborator",
assignedUserId: missingUserId,

View file

@ -0,0 +1,267 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import {
agentProductivityHandler,
dashboardOverviewHandler,
hoursByClientInternalHandler,
} from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-agent" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
const base: Record<string, unknown> = {
_id: "company_base" as Id<"companies">,
tenantId: TENANT_ID,
name: "Empresa",
slug: "empresa",
createdAt: Date.now(),
updatedAt: Date.now(),
isAvulso: false,
contractedHoursPerMonth: 40,
}
return { ...(base as Doc<"companies">), ...overrides }
}
describe("convex.reports.agentProductivity", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 10, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates per-agent metrics including work sessions", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const agentA = { _id: "agent_a" as Id<"users">, name: "Ana", email: "ana@example.com" } as Doc<"users">
const agentB = { _id: "agent_b" as Id<"users">, name: "Bruno", email: "bruno@example.com" } as Doc<"users">
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
assigneeId: agentA._id,
createdAt: Date.UTC(2024, 6, 9, 9, 0, 0),
status: "PENDING",
firstResponseAt: Date.UTC(2024, 6, 9, 9, 30, 0),
internalWorkedMs: 45 * 60 * 1000,
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
assigneeId: agentA._id,
createdAt: Date.UTC(2024, 6, 8, 10, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 8, 10, 20, 0),
resolvedAt: Date.UTC(2024, 6, 8, 12, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
assigneeId: agentB._id,
createdAt: Date.UTC(2024, 5, 20, 10, 0, 0),
status: "RESOLVED",
}),
]
const sessionsMap = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>([
[
String(agentA._id),
[
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 9, 9, 0, 0), stoppedAt: Date.UTC(2024, 6, 9, 10, 0, 0) },
{ agentId: agentA._id, startedAt: Date.UTC(2024, 6, 8, 11, 0, 0), durationMs: 30 * 60 * 1000 },
],
],
])
const ctx = createReportsCtx({
tickets,
users: new Map<string, Doc<"users">>([
[String(agentA._id), agentA],
[String(agentB._id), agentB],
]),
ticketWorkSessionsByAgent: sessionsMap,
}) as Parameters<typeof agentProductivityHandler>[0]
const result = await agentProductivityHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.items).toHaveLength(1)
expect(result.items[0]).toMatchObject({
agentId: agentA._id,
open: 1,
resolved: 1,
avgFirstResponseMinutes: 25,
})
expect(result.items[0]?.workedHours).toBeCloseTo(1.5, 1)
})
})
describe("convex.reports.dashboardOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 15, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("returns trend metrics for new, in-progress and resolution data", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_new" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 15, 8, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 15, 8, 30, 0),
status: "PENDING",
}),
buildTicket({
_id: "ticket_resolved_recent" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 8, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 8, 9, 15, 0),
resolvedAt: Date.UTC(2024, 6, 13, 12, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_prev" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 13, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 13, 9, 45, 0),
status: "PAUSED",
dueAt: Date.UTC(2024, 6, 14, 9, 0, 0),
}),
buildTicket({
_id: "ticket_prev_resolved" as Id<"tickets">,
createdAt: Date.UTC(2024, 6, 5, 9, 0, 0),
firstResponseAt: Date.UTC(2024, 6, 5, 9, 10, 0),
resolvedAt: Date.UTC(2024, 6, 7, 10, 0, 0),
status: "RESOLVED",
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof dashboardOverviewHandler>[0]
const result = await dashboardOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
})
expect(result.newTickets.last24h).toBe(1)
expect(result.inProgress.current).toBe(2)
expect(result.awaitingAction.total).toBe(2)
expect(result.resolution.resolvedLast7d).toBe(1)
})
})
describe("convex.reports.hoursByClientInternal", () => {
const FIXED_NOW = Date.UTC(2024, 7, 1, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("sums internal and external hours per company", async () => {
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A" })
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
const tickets = [
buildTicket({
_id: "ticket_a" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 6, 30, 10, 0, 0),
internalWorkedMs: 2 * 3600000,
externalWorkedMs: 3600000,
}),
buildTicket({
_id: "ticket_b" as Id<"tickets">,
companyId: companyB._id,
updatedAt: Date.UTC(2024, 6, 29, 14, 0, 0),
internalWorkedMs: 0,
externalWorkedMs: 2 * 3600000,
}),
]
const ctx = createReportsCtx({
tickets,
companies: new Map([
[String(companyA._id), companyA],
[String(companyB._id), companyB],
]),
}) as Parameters<typeof hoursByClientInternalHandler>[0]
const result = await hoursByClientInternalHandler(ctx, { tenantId: TENANT_ID, range: "7d" })
expect(result.rangeDays).toBe(7)
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ companyId: companyA._id, internalMs: 2 * 3600000, externalMs: 3600000 }),
expect.objectContaining({ companyId: companyB._id, internalMs: 0, externalMs: 2 * 3600000 }),
])
)
})
})

View file

@ -0,0 +1,199 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import { backlogOverviewHandler, slaOverviewHandler } from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-staff" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_agent" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildQueue(overrides: Partial<Doc<"queues">>): Doc<"queues"> {
const base: Record<string, unknown> = {
_id: "queue_base" as Id<"queues">,
tenantId: TENANT_ID,
name: "Suporte",
slug: "suporte",
createdAt: Date.now(),
updatedAt: Date.now(),
}
return { ...(base as Doc<"queues">), ...overrides }
}
describe("convex.reports.slaOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("summarizes totals, averages and queue breakdown", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const queue = buildQueue({
_id: "queue_1" as Id<"queues">,
name: "Suporte Nível 1",
})
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
status: "PENDING",
queueId: queue._id,
createdAt: Date.UTC(2024, 4, 7, 9, 0, 0),
dueAt: Date.UTC(2024, 4, 7, 11, 0, 0),
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
queueId: queue._id,
createdAt: Date.UTC(2024, 4, 6, 8, 0, 0),
firstResponseAt: Date.UTC(2024, 4, 6, 8, 30, 0),
resolvedAt: Date.UTC(2024, 4, 6, 10, 0, 0),
}),
]
const ctx = createReportsCtx({ tickets, queues: [queue] }) as Parameters<typeof slaOverviewHandler>[0]
const result = await slaOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.totals).toEqual({ total: 2, open: 1, resolved: 1, overdue: 1 })
expect(result.response).toEqual({ averageFirstResponseMinutes: 30, responsesRegistered: 1 })
expect(result.resolution).toEqual({ averageResolutionMinutes: 120, resolvedCount: 1 })
expect(result.queueBreakdown).toEqual([{ id: queue._id, name: queue.name, open: 1 }])
})
})
describe("convex.reports.backlogOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 6, 1, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates status, priority and queue counts for open tickets", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const queueA = buildQueue({ _id: "queue_a" as Id<"queues">, name: "Atendimento" })
const queueB = buildQueue({ _id: "queue_b" as Id<"queues">, name: "Infraestrutura" })
const tickets = [
buildTicket({
_id: "ticket_pending" as Id<"tickets">,
status: "PENDING",
priority: "HIGH",
queueId: queueA._id,
createdAt: Date.UTC(2024, 5, 28, 10, 0, 0),
}),
buildTicket({
_id: "ticket_paused" as Id<"tickets">,
status: "PAUSED",
priority: "MEDIUM",
queueId: queueB._id,
createdAt: Date.UTC(2024, 5, 29, 15, 0, 0),
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
status: "RESOLVED",
priority: "URGENT",
queueId: undefined,
createdAt: Date.UTC(2024, 5, 27, 9, 0, 0),
}),
]
const ctx = createReportsCtx({
tickets,
createdRangeTickets: tickets,
queues: [queueA, queueB],
}) as Parameters<typeof backlogOverviewHandler>[0]
const result = await backlogOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.statusCounts).toEqual({
PENDING: 1,
PAUSED: 1,
RESOLVED: 1,
})
expect(result.priorityCounts).toEqual({
HIGH: 1,
MEDIUM: 1,
URGENT: 1,
})
expect(result.totalOpen).toBe(2)
expect(result.queueCounts).toHaveLength(2)
expect(result.queueCounts).toEqual(
expect.arrayContaining([
{ id: "queue_a", name: queueA.name, total: 1 },
{ id: "queue_b", name: queueB.name, total: 1 },
])
)
})
})

View file

@ -0,0 +1,111 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import { ticketsByChannelHandler } from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-viewer" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
describe("convex.reports.ticketsByChannel", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 8, 12, 0, 0) // 8 May 2024 12:00 UTC
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("builds timeline grouped by channel within the requested range", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_email" as Id<"tickets">,
channel: "EMAIL",
createdAt: Date.UTC(2024, 4, 7, 10, 0, 0),
}),
buildTicket({
_id: "ticket_chat" as Id<"tickets">,
channel: "CHAT",
createdAt: Date.UTC(2024, 4, 7, 12, 0, 0),
}),
buildTicket({
_id: "ticket_other" as Id<"tickets">,
channel: undefined,
createdAt: Date.UTC(2024, 4, 6, 9, 0, 0),
}),
buildTicket({
_id: "ticket_outside" as Id<"tickets">,
createdAt: Date.UTC(2024, 3, 25, 12, 0, 0), // outside 7-day window
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof ticketsByChannelHandler>[0]
const result = await ticketsByChannelHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.channels).toEqual(["CHAT", "EMAIL", "OUTRO"])
const may06 = result.points.find((point) => point.date === "2024-05-06")
const may07 = result.points.find((point) => point.date === "2024-05-07")
expect(may06?.values).toEqual({ CHAT: 0, EMAIL: 0, OUTRO: 1 })
expect(may07?.values).toEqual({ CHAT: 1, EMAIL: 1, OUTRO: 0 })
expect(may06).toBeTruthy()
expect(may07).toBeTruthy()
})
})

View file

@ -0,0 +1,260 @@
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
vi.mock("../convex/rbac", () => ({
requireStaff: vi.fn(),
}))
import type { Doc, Id } from "../convex/_generated/dataModel"
import {
csatOverviewHandler,
hoursByClientHandler,
openedResolvedByDayHandler,
} from "../convex/reports"
import { requireStaff } from "../convex/rbac"
import { createReportsCtx } from "./utils/report-test-helpers"
const TENANT_ID = "tenant-1"
const VIEWER_ID = "user-reports" as Id<"users">
function buildTicket(overrides: Partial<Doc<"tickets">>): Doc<"tickets"> {
const base: Record<string, unknown> = {
_id: "ticket_base" as Id<"tickets">,
tenantId: TENANT_ID,
reference: 50000,
subject: "Chamado",
summary: null,
status: "PENDING",
priority: "MEDIUM",
channel: "EMAIL",
queueId: undefined,
requesterId: "user_req" as Id<"users">,
requesterSnapshot: { name: "Alice", email: "alice@example.com", avatarUrl: undefined, teams: [] },
assigneeId: "user_assignee" as Id<"users">,
assigneeSnapshot: { name: "Bob", email: "bob@example.com", avatarUrl: undefined, teams: [] },
companyId: undefined,
companySnapshot: undefined,
machineId: undefined,
machineSnapshot: undefined,
working: false,
dueAt: undefined,
firstResponseAt: undefined,
resolvedAt: undefined,
closedAt: undefined,
updatedAt: Date.now(),
createdAt: Date.now(),
tags: [],
customFields: [],
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
activeSessionId: undefined,
}
return { ...(base as Doc<"tickets">), ...overrides }
}
function buildCompany(overrides: Partial<Doc<"companies">>): Doc<"companies"> {
const base: Record<string, unknown> = {
_id: "company_base" as Id<"companies">,
tenantId: TENANT_ID,
name: "Empresa",
slug: "empresa",
createdAt: Date.now(),
updatedAt: Date.now(),
isAvulso: false,
contractedHoursPerMonth: 40,
}
return { ...(base as Doc<"companies">), ...overrides }
}
describe("convex.reports.openedResolvedByDay", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 15, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("counts opened and resolved tickets per day within the range", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const tickets = [
buildTicket({
_id: "ticket_open" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 13, 9, 0, 0),
status: "PENDING",
}),
buildTicket({
_id: "ticket_resolved" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 12, 10, 0, 0),
resolvedAt: Date.UTC(2024, 4, 14, 8, 0, 0),
status: "RESOLVED",
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
createdAt: Date.UTC(2024, 3, 30, 10, 0, 0),
status: "PENDING",
}),
]
const ctx = createReportsCtx({ tickets }) as Parameters<typeof openedResolvedByDayHandler>[0]
const result = await openedResolvedByDayHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
const opened13 = result.series.find((point) => point.date === "2024-05-13")
const resolved14 = result.series.find((point) => point.date === "2024-05-14")
expect(result.rangeDays).toBe(7)
expect(opened13).toMatchObject({ opened: 1, resolved: 0 })
expect(resolved14).toMatchObject({ opened: 0, resolved: 1 })
})
})
describe("convex.reports.csatOverview", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 4, 20, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("summarizes survey averages and distribution", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const ticketA = buildTicket({
_id: "ticket_a" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 18, 9, 0, 0),
})
const ticketB = buildTicket({
_id: "ticket_b" as Id<"tickets">,
createdAt: Date.UTC(2024, 4, 17, 15, 0, 0),
})
const eventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>([
["ticket_a", [{ type: "CSAT_RECEIVED", payload: { score: 5 }, createdAt: Date.UTC(2024, 4, 19, 8, 0, 0) }]],
[
"ticket_b",
[
{ type: "CSAT_RECEIVED", payload: { score: 3 }, createdAt: Date.UTC(2024, 4, 18, 14, 0, 0) },
{ type: "COMMENT", payload: {}, createdAt: Date.UTC(2024, 4, 18, 15, 0, 0) },
],
],
])
const ctx = createReportsCtx({ tickets: [ticketA, ticketB], ticketEventsByTicket: eventsByTicket }) as Parameters<typeof csatOverviewHandler>[0]
const result = await csatOverviewHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.totalSurveys).toBe(2)
expect(result.averageScore).toBe(4)
expect(result.distribution.find((entry) => entry.score === 5)?.total).toBe(1)
expect(result.distribution.find((entry) => entry.score === 3)?.total).toBe(1)
expect(result.recent[0]?.ticketId).toBe("ticket_a")
})
})
describe("convex.reports.hoursByClient", () => {
const requireStaffMock = vi.mocked(requireStaff)
const FIXED_NOW = Date.UTC(2024, 5, 5, 12, 0, 0)
beforeAll(() => {
vi.useFakeTimers()
vi.setSystemTime(FIXED_NOW)
})
afterAll(() => {
vi.useRealTimers()
})
it("aggregates worked hours by company", async () => {
requireStaffMock.mockResolvedValue({
role: "ADMIN",
user: { companyId: undefined },
} as unknown as Awaited<ReturnType<typeof requireStaff>>)
const companyA = buildCompany({ _id: "company_a" as Id<"companies">, name: "Empresa A", contractedHoursPerMonth: 60 })
const companyB = buildCompany({ _id: "company_b" as Id<"companies">, name: "Empresa B", isAvulso: true })
const tickets = [
buildTicket({
_id: "ticket_a" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 5, 4, 10, 0, 0),
internalWorkedMs: 3 * 3600000,
externalWorkedMs: 1 * 3600000,
}),
buildTicket({
_id: "ticket_b" as Id<"tickets">,
companyId: companyB._id,
updatedAt: Date.UTC(2024, 5, 3, 15, 0, 0),
internalWorkedMs: 3600000,
externalWorkedMs: 2 * 3600000,
}),
buildTicket({
_id: "ticket_old" as Id<"tickets">,
companyId: companyA._id,
updatedAt: Date.UTC(2024, 4, 20, 12, 0, 0),
internalWorkedMs: 5 * 3600000,
externalWorkedMs: 0,
}),
]
const companies = new Map<string, Doc<"companies">>([
[String(companyA._id), companyA],
[String(companyB._id), companyB],
])
const ctx = createReportsCtx({ tickets, companies }) as Parameters<typeof hoursByClientHandler>[0]
const result = await hoursByClientHandler(ctx, {
tenantId: TENANT_ID,
viewerId: VIEWER_ID,
range: "7d",
})
expect(result.rangeDays).toBe(7)
expect(result.items).toHaveLength(2)
expect(result.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
companyId: companyA._id,
internalMs: 3 * 3600000,
externalMs: 3600000,
totalMs: 4 * 3600000,
contractedHoursPerMonth: 60,
}),
expect.objectContaining({
companyId: companyB._id,
internalMs: 3600000,
externalMs: 2 * 3600000,
totalMs: 3 * 3600000,
isAvulso: true,
}),
])
)
})
})

View file

@ -0,0 +1,127 @@
import { vi } from "vitest"
import type { Doc, Id } from "../../convex/_generated/dataModel"
type ReportsCtxOptions = {
tickets?: Doc<"tickets">[]
createdRangeTickets?: Doc<"tickets">[]
queues?: Doc<"queues">[]
companies?: Map<string, Doc<"companies">>
users?: Map<string, Doc<"users">>
ticketEventsByTicket?: Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>
ticketWorkSessionsByAgent?: Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>
}
const noopFilterBuilder = {
lt: () => noopFilterBuilder,
field: () => "createdAt",
}
const noopIndexBuilder = {
eq: () => noopIndexBuilder,
gte: () => noopIndexBuilder,
}
function ticketsChain(collection: Doc<"tickets">[]) {
const chain = {
filter: vi.fn((cb?: (builder: typeof noopFilterBuilder) => unknown) => {
cb?.(noopFilterBuilder)
return chain
}),
order: vi.fn(() => chain),
collect: vi.fn(async () => collection),
}
return chain
}
export function createReportsCtx({
tickets = [],
createdRangeTickets = tickets,
queues = [],
companies = new Map<string, Doc<"companies">>(),
users = new Map<string, Doc<"users">>(),
ticketEventsByTicket = new Map<string, Array<{ type: string; payload?: unknown; createdAt: number }>>(),
ticketWorkSessionsByAgent = new Map<string, Array<{ agentId: Id<"users">; startedAt: number; stoppedAt?: number; durationMs?: number }>>(),
}: ReportsCtxOptions = {}) {
const db = {
get: vi.fn(async (id: Id<"companies"> | Id<"users">) => {
const company = companies.get(String(id))
if (company) return company
const user = users.get(String(id))
if (user) return user
return null
}),
query: vi.fn((table: string) => {
if (table === "tickets") {
return {
withIndex: vi.fn((indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
cb?.(noopIndexBuilder)
const collection =
indexName.includes("created") || indexName.includes("tenant_company_created")
? createdRangeTickets
: tickets
return ticketsChain(collection)
}),
collect: vi.fn(async () => tickets),
}
}
if (table === "queues") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: typeof noopIndexBuilder) => unknown) => {
cb?.(noopIndexBuilder)
return {
collect: vi.fn(async () => queues),
}
}),
collect: vi.fn(async () => queues),
}
}
if (table === "ticketEvents") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
let ticketId: string | null = null
const builder = {
eq: (_field: unknown, value: unknown) => {
ticketId = String(value)
return builder
},
}
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
return {
collect: vi.fn(async () => (ticketId ? ticketEventsByTicket.get(ticketId) ?? [] : [])),
}
}),
}
}
if (table === "ticketWorkSessions") {
return {
withIndex: vi.fn((_indexName: string, cb?: (builder: { eq: (field: unknown, value: unknown) => unknown }) => unknown) => {
let agentId: string | null = null
const builder = {
eq: (_field: unknown, value: unknown) => {
agentId = String(value)
return builder
},
}
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
return {
collect: vi.fn(async () => (agentId ? ticketWorkSessionsByAgent.get(agentId) ?? [] : [])),
}
}),
}
}
return {
withIndex: vi.fn(() => ({
collect: vi.fn(async () => []),
})),
collect: vi.fn(async () => []),
}
}),
}
return { db } as unknown
}