import { aggregateCallsHourlyByRange, aggregateCallsRouting, aggregateCallsSummary, aggregateCallsTrend, findCallSidDraftTicketCountsByCity, findCallSidTicketCountsByCity, getCallsCollection, getDepartmentsSubjectsCollection, getTicketsCollection, findSubjectsByCityAndDateRange, rangeDurationDays, rangeDurationMonths, resolveTimeBucketFromRange, ticketStatsDateScopeFromYmd, } from "../../../index"; import type { Call } from "../calls.types"; import type { CallsStatsFilter } from "../calls.statistics.types"; import { createDepartmentSubject, createIncomingCallDoc, createOutGoingCallDoc, createTicket, } from "../../../test-utils/factories"; const CLIENT_ID = "stats-client"; const CITY = "tests" as const; const TZ = "Asia/Jerusalem"; const at = (iso: string) => new Date(iso); const insertCall = (call: Call) => getCallsCollection().insertOne({ ...call, env: call.env ?? "test" }); const incoming = (callSid: string, createdAt: string, extra?: Partial) => insertCall( createIncomingCallDoc({ clientId: CLIENT_ID, callSid, createdAt: at(createdAt), updatedAt: at(extra?.updatedAt?.toISOString() ?? createdAt), ...extra, }), ); const filter = (overrides?: Partial): CallsStatsFilter => ({ clientId: CLIENT_ID, startStr: "2026-05-01", endStr: "2026-05-31", timezone: TZ, callSidTicketCounts: new Map(), callSidDraftTicketCounts: new Map(), ...overrides, }); const ticketDateScope = (statsFilter: CallsStatsFilter) => ticketStatsDateScopeFromYmd( statsFilter.startStr, statsFilter.endStr, statsFilter.timezone, ); const summaryWithTicketSids = async (overrides?: Partial) => { const statsFilter = filter(overrides); const dateScope = ticketDateScope(statsFilter); const callSidTicketCounts = await findCallSidTicketCountsByCity( CITY, dateScope, ); const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity( CITY, dateScope, ); return aggregateCallsSummary( filter({ callSidTicketCounts, callSidDraftTicketCounts, ...overrides }), ); }; describe("calls statistics getters", () => { beforeEach(async () => { await incoming("CA-with-ticket", "2026-05-10T10:00:00.000Z", { callLength: 120, updatedAt: at("2026-05-10T10:02:00.000Z"), }); await incoming("CA-transferred", "2026-05-11T14:00:00.000Z", { callLength: 60, redirectedCall: true, updatedAt: at("2026-05-11T14:01:00.000Z"), }); await incoming("CA-hijacked", "2026-05-12T09:00:00.000Z", { callLength: 90, isConferenceCall: true, redirectedCall: false, updatedAt: at("2026-05-12T09:01:30.000Z"), }); await insertCall( createOutGoingCallDoc({ clientId: CLIENT_ID, callSid: "CA-outgoing", createdAt: at("2026-05-10T11:00:00.000Z"), updatedAt: at("2026-05-10T11:01:00.000Z"), }), ); await getTicketsCollection().insertMany([ createTicket({ cityName: CITY, callSid: "CA-with-ticket", createdAt: at("2026-05-10T10:00:00.000Z"), updatedAt: at("2026-05-10T10:00:00.000Z"), externalCallFields: { event_subject_id: "100" }, }), createTicket({ cityName: CITY, callSid: "CA-with-ticket", createdAt: at("2026-05-10T10:30:00.000Z"), updatedAt: at("2026-05-10T10:30:00.000Z"), externalCallFields: { event_subject_id: "100" }, }), ]); }); it("should exclude calls outside the filter date range from summary", async () => { await incoming("CA-old", "2024-01-01T10:00:00.000Z", { callLength: 999 }); const summary = await aggregateCallsSummary(filter()); expect(summary).toMatchObject({ totalCalls: 3, avgDurationSeconds: 90 }); }); it("should aggregate summary with ticket sids and opened-ticket filter", async () => { expect(await summaryWithTicketSids()).toEqual({ totalCalls: 3, openTickets: 2, draftTickets: 0, avgDurationSeconds: 90, callsWithTickets: 1, }); const statsFilter = filter(); const callSidTicketCounts = await findCallSidTicketCountsByCity( CITY, ticketDateScope(statsFilter), ); const openedOnly = await aggregateCallsSummary( filter({ callSidTicketCounts, ticketStatusFilters: ["opened"] }), ); expect(openedOnly).toMatchObject({ totalCalls: 1, openTickets: 2 }); }); it("should classify routing into mutually exclusive buckets", async () => { expect(await aggregateCallsRouting(filter())).toEqual({ transferred: 1, hijacked: 1, aiHandled: 1, }); }); it("should aggregate subjects only for calls passing the filter", async () => { await getDepartmentsSubjectsCollection().insertMany([ createDepartmentSubject({ cityName: CITY, subject_id: "100", subjectName: "Water", }), createDepartmentSubject({ cityName: CITY, subject_id: "200", subjectName: "Roads", }), createDepartmentSubject({ cityName: CITY, subject_id: "300", subjectName: "Lighting", }), ]); await getTicketsCollection().insertMany([ createTicket({ cityName: CITY, callSid: "CA-transferred", createdAt: at("2026-05-11T14:00:00.000Z"), updatedAt: at("2026-05-11T14:00:00.000Z"), externalCallFields: { event_subject_id: "200" }, }), createTicket({ cityName: CITY, callSid: "CA-hijacked", createdAt: at("2026-05-12T09:00:00.000Z"), updatedAt: at("2026-05-12T09:00:00.000Z"), externalCallFields: { event_subject_id: "300" }, }), ]); const statsFilter = filter(); expect( await findSubjectsByCityAndDateRange( CITY, statsFilter, ticketDateScope(statsFilter), ), ).toEqual([ { subject: "Water", count: 2 }, { subject: "Lighting", count: 1 }, { subject: "Roads", count: 1 }, ]); const transferredOnly = filter({ callRouting: ["transferred"] }); expect( await findSubjectsByCityAndDateRange( CITY, transferredOnly, ticketDateScope(transferredOnly), ), ).toEqual([{ subject: "Roads", count: 1 }]); }); it("should aggregate subjects over a wide date range", async () => { await getDepartmentsSubjectsCollection().insertMany([ createDepartmentSubject({ cityName: CITY, subject_id: "100", subjectName: "Water", }), createDepartmentSubject({ cityName: CITY, subject_id: "200", subjectName: "Roads", }), ]); await incoming("CA-old-roads", "2024-01-01T10:00:00.000Z"); await getTicketsCollection().insertOne( createTicket({ cityName: CITY, callSid: "CA-old-roads", createdAt: at("2024-01-01T10:00:00.000Z"), updatedAt: at("2024-01-01T10:00:00.000Z"), externalCallFields: { event_subject_id: "200" }, }), ); const statsFilter = filter({ startStr: "2024-01-01", endStr: "2026-12-31", }); expect( await findSubjectsByCityAndDateRange( CITY, statsFilter, ticketDateScope(statsFilter), ), ).toEqual([ { subject: "Water", count: 2 }, { subject: "Roads", count: 1 }, ]); }); it("should aggregate hourly buckets with averages and optional hour window", async () => { const statsFilter = filter(); const daysInRange = rangeDurationDays( statsFilter.startStr, statsFilter.endStr, ); const full = await aggregateCallsHourlyByRange(statsFilter); expect(full).toHaveLength(24); expect(full[0]).toEqual({ hour: 0, totalCalls: 0, avgCalls: 0 }); expect(full.reduce((sum, h) => sum + h.totalCalls, 0)).toBe(3); expect(full.find((h) => h.hour === 12)).toEqual({ hour: 12, totalCalls: 1, avgCalls: Math.round((1 / daysInRange) * 10) / 10, }); expect( ( await aggregateCallsHourlyByRange( filter({ startStr: "2026-05-12", endStr: "2026-05-12" }), ) ).find((h) => h.hour === 12), ).toEqual({ hour: 12, totalCalls: 1, avgCalls: 1 }); const avgForOneCall = Math.round((1 / daysInRange) * 10) / 10; expect( await aggregateCallsHourlyByRange( filter({ hourFrom: "10:00", hourTo: "13:00" }), ), ).toEqual([ { hour: 10, totalCalls: 0, avgCalls: 0 }, { hour: 11, totalCalls: 0, avgCalls: 0 }, { hour: 12, totalCalls: 1, avgCalls: avgForOneCall }, ]); // hourTo is exclusive: ending at "12:00" stops before the 12:00 call, last bucket is 11. expect( await aggregateCallsHourlyByRange( filter({ hourFrom: "10:00", hourTo: "12:00" }), ), ).toEqual([ { hour: 10, totalCalls: 0, avgCalls: 0 }, { hour: 11, totalCalls: 0, avgCalls: 0 }, ]); }); it("should emit a wrap-around hour window and count calls on both sides of midnight", async () => { // 2026-05-12 local: 23:30 (20:30Z) and 01:00 (2026-05-12 22:00Z) straddle midnight; // CA-two-am at 02:00 local (2026-05-11 23:00Z) sits exactly on the exclusive end. await incoming("CA-late", "2026-05-12T20:30:00.000Z"); await incoming("CA-early", "2026-05-12T22:00:00.000Z"); await incoming("CA-two-am", "2026-05-11T23:00:00.000Z"); const oneDay = { startStr: "2026-05-12", endStr: "2026-05-13" } as const; const buckets = await aggregateCallsHourlyByRange( filter({ ...oneDay, hourFrom: "22:00", hourTo: "02:00" }), ); // hourTo "02:00" is exclusive: last bucket is 1, and the 02:00 call is not counted. expect(buckets.map((b) => b.hour)).toEqual([22, 23, 0, 1]); expect(buckets.find((b) => b.hour === 23)?.totalCalls).toBe(1); expect(buckets.find((b) => b.hour === 1)?.totalCalls).toBe(1); expect(buckets.reduce((sum, b) => sum + b.totalCalls, 0)).toBe(2); }); it("should respect wrap-around hour window boundaries on both the first and last day", async () => { await incoming("CA-jun8-morning", "2026-06-08T07:00:00.000Z"); // Jun 8 10:00 local — excluded (first-day guard) await incoming("CA-jun8-night", "2026-06-08T20:30:00.000Z"); // Jun 8 23:30 local — included await incoming("CA-jun9-morning", "2026-06-09T05:00:00.000Z"); // Jun 9 08:00 local — included await incoming("CA-jun9-noon", "2026-06-09T10:30:00.000Z"); // Jun 9 13:30 local — excluded (>= hourTo) await incoming("CA-jun9-evening", "2026-06-09T20:30:00.000Z"); // Jun 9 23:30 local — excluded (last-day guard) const twoDay = { startStr: "2026-06-08", endStr: "2026-06-09" } as const; const f = filter({ ...twoDay, hourFrom: "23:00", hourTo: "13:00" }); // Trend: exactly 1 call per day — Jun 8 at 23:30, Jun 9 at 08:00. expect(await aggregateCallsTrend(f, "day")).toEqual([ { bucket: "2026-06-08", totalCalls: 1, openTickets: 0 }, { bucket: "2026-06-09", totalCalls: 1, openTickets: 0 }, ]); // Hourly: hours [23, 0, ..., 12] — only the 2 included calls appear. const hourly = await aggregateCallsHourlyByRange(f); expect(hourly.map((b) => b.hour)).toEqual([ 23, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, ]); expect(hourly.find((b) => b.hour === 23)?.totalCalls).toBe(1); // Jun 8 23:30 expect(hourly.find((b) => b.hour === 8)?.totalCalls).toBe(1); // Jun 9 08:00 expect(hourly.reduce((sum, b) => sum + b.totalCalls, 0)).toBe(2); }); it("should fill day trend buckets with zeros", async () => { expect( await aggregateCallsTrend( filter({ startStr: "2026-05-10", endStr: "2026-05-14" }), "day", ), ).toEqual([ { bucket: "2026-05-10", totalCalls: 1, openTickets: 0 }, { bucket: "2026-05-11", totalCalls: 1, openTickets: 0 }, { bucket: "2026-05-12", totalCalls: 1, openTickets: 0 }, { bucket: "2026-05-13", totalCalls: 0, openTickets: 0 }, { bucket: "2026-05-14", totalCalls: 0, openTickets: 0 }, ]); }); it("should bucket calls by calendar day in filter timezone", async () => { await incoming("CA-jerusalem-midnight", "2026-05-09T21:00:00.000Z", { callLength: 45, }); expect( await aggregateCallsTrend( filter({ startStr: "2026-05-10", endStr: "2026-05-12" }), "day", ), ).toEqual([ { bucket: "2026-05-10", totalCalls: 2, openTickets: 0 }, { bucket: "2026-05-11", totalCalls: 1, openTickets: 0 }, { bucket: "2026-05-12", totalCalls: 1, openTickets: 0 }, ]); }); it("should pick month, week, or day bucket from calendar-month span", () => { expect(rangeDurationMonths("2025-06-01", "2026-05-31")).toBe(12); expect(resolveTimeBucketFromRange("2025-06-01", "2026-05-31")).toBe( "month", ); expect(resolveTimeBucketFromRange("2026-01-01", "2026-06-30")).toBe("week"); expect(resolveTimeBucketFromRange("2026-04-01", "2026-06-30")).toBe("day"); }); it("should fill month trend buckets with zeros", async () => { await incoming("CA-feb", "2026-02-15T10:00:00.000Z"); await incoming("CA-apr", "2026-04-15T10:00:00.000Z"); const month = await aggregateCallsTrend( filter({ startStr: "2026-01-01", endStr: "2026-06-30" }), "month", ); expect(month.map((row) => row.bucket)).toEqual([ "2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01", "2026-05-01", "2026-06-01", ]); expect(month.find((row) => row.bucket === "2026-02-01")).toMatchObject({ totalCalls: 1, openTickets: 0, }); expect(month.find((row) => row.bucket === "2026-05-01")).toMatchObject({ totalCalls: 3, openTickets: 0, }); }); it("should fill week trend buckets with zeros", async () => { await incoming("CA-week-2", "2026-05-18T10:00:00.000Z"); expect( await aggregateCallsTrend( filter({ startStr: "2026-05-04", endStr: "2026-05-24" }), "week", ), ).toEqual([ { bucket: "2026-05-04/2026-05-09", totalCalls: 0, openTickets: 0 }, { bucket: "2026-05-10/2026-05-16", totalCalls: 3, openTickets: 0 }, { bucket: "2026-05-17/2026-05-23", totalCalls: 1, openTickets: 0 }, { bucket: "2026-05-24/2026-05-24", totalCalls: 0, openTickets: 0 }, ]); }); it("should complete every day bucket across a 12-month range without hanging (DST regression)", async () => { const startStr = "2025-07-02"; const endStr = "2026-06-02"; const startedAt = Date.now(); const trend = await aggregateCallsTrend( filter({ startStr, endStr }), "day", ); const elapsedMs = Date.now() - startedAt; expect(trend).toHaveLength(rangeDurationDays(startStr, endStr)); expect(trend[0]).toEqual({ bucket: startStr, totalCalls: 0, openTickets: 0, }); expect(trend[trend.length - 1].bucket).toBe(endStr); expect(elapsedMs).toBeLessThan(5000); }); it("should count draft tickets and distinct draft call sids", async () => { const descriptionDraft = createTicket({ cityName: CITY, callSid: "CA-hijacked", createdAt: at("2026-05-12T09:00:00.000Z"), updatedAt: at("2026-05-12T09:00:00.000Z"), }); descriptionDraft.externalCallFields = { event_description: "draft ticket" }; await getTicketsCollection().insertMany([ createTicket({ cityName: CITY, callSid: "CA-transferred", createdAt: at("2026-05-11T14:00:00.000Z"), updatedAt: at("2026-05-11T14:00:00.000Z"), externalCallFields: { event_subject_id: "100" }, }), descriptionDraft, createTicket({ cityName: CITY, callSid: "CA-whitespace-draft", createdAt: at("2026-05-12T10:00:00.000Z"), updatedAt: at("2026-05-12T10:00:00.000Z"), externalCallFields: { event_subject_id: " " }, }), ]); const statsFilter = filter(); const dateScope = ticketDateScope(statsFilter); const callSidDraftTicketCounts = await findCallSidDraftTicketCountsByCity( CITY, dateScope, ); const summary = await summaryWithTicketSids(); expect([...callSidDraftTicketCounts.keys()].sort()).toEqual( ["CA-hijacked", "CA-whitespace-draft"].sort(), ); expect(summary).toMatchObject({ openTickets: 4, draftTickets: 1 }); }); });