chore: expand reports coverage and upgrade next
This commit is contained in:
parent
2fb587b01d
commit
8b82284e8c
21 changed files with 2952 additions and 2713 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
255
tests/machines.listTicketsHistory.test.ts
Normal file
255
tests/machines.listTicketsHistory.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
267
tests/reports.productivity-dashboard.test.ts
Normal file
267
tests/reports.productivity-dashboard.test.ts
Normal 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 }),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
199
tests/reports.sla-backlog.test.ts
Normal file
199
tests/reports.sla-backlog.test.ts
Normal 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 },
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
111
tests/reports.ticketsByChannel.test.ts
Normal file
111
tests/reports.ticketsByChannel.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
260
tests/reports.timeline-hours.test.ts
Normal file
260
tests/reports.timeline-hours.test.ts
Normal 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,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
127
tests/utils/report-test-helpers.ts
Normal file
127
tests/utils/report-test-helpers.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue