diff --git a/server/test/integration/checksRepositoryGroupedChecks.test.ts b/server/test/integration/checksRepositoryGroupedChecks.test.ts new file mode 100644 index 000000000..cf5384d0c --- /dev/null +++ b/server/test/integration/checksRepositoryGroupedChecks.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, beforeAll, afterAll, beforeEach } from "@jest/globals"; +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import MongoChecksRepository from "../../src/domain/checks/check.repository.mongo.ts"; +import { CheckModel } from "../../src/domain/checks/check.model.ts"; +import type { UptimeChecksResult } from "../../src/domain/checks/check.type.ts"; +import type { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantee under test — every phase average uses the full bucket as its +// denominator — IS the $ifNull-inside-$avg behavior of the groupedChecks facet, so it +// can only be exercised against a live aggregation engine. Reused keep-alive sockets +// produce checks with no timings subdocument at all; without the coercion, $avg would +// skip them and average dns/tcp/tls over fresh-connection checks only. + +let mongod: MongoMemoryServer; + +beforeAll(async () => { + mongod = await MongoMemoryServer.create(); + await mongoose.connect(mongod.getUri()); + await CheckModel.createCollection(); // timeseries collections must exist before insert +}, 120_000); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await CheckModel.deleteMany({}); +}); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const MONITOR_ID = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +// One shared createdAt puts every seeded check in the same hourly bucket of the "day" +// range, keeping the facet's group stage deterministic. +const BUCKET_TIME = new Date(Date.now() - 10 * 60 * 1000); + +const seedCheck = (overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId: MONITOR_ID, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt: BUCKET_TIME, + ...overrides, + }); + +const findGrouped = async (repo: MongoChecksRepository) => + (await repo.findByDateRangeAndMonitorId(MONITOR_ID.toString(), "day", { type: "http" })) as UptimeChecksResult; + +describe("MongoChecksRepository groupedChecks facet", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("averages each phase over the full bucket, counting checks without timings as 0", async () => { + await seedCheck({ + responseTime: 100, + timings: { phases: { dns: 10, tcp: 20, tls: 30, request: 8, firstByte: 40, download: 6 } }, + }); + await seedCheck({ responseTime: 200 }); // reused socket: no timings subdocument at all + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // Plain $avg would skip the timings-less check and report 10/20/30/8/40/6 here. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 5, + avgTcp: 10, + avgTls: 15, + avgRequest: 4, + avgFirstByte: 20, + avgDownload: 3, + avgResponseTime: 150, + }); + }); + + it("yields 0 (not null) for every phase in a bucket of only timings-less checks", async () => { + await seedCheck(); + await seedCheck(); + + const result = await findGrouped(repo); + + expect(result.groupedChecks).toHaveLength(1); + // The client sums these fields for the stacked chart; null would poison the math. + expect(result.groupedChecks[0]).toMatchObject({ + totalChecks: 2, + avgDns: 0, + avgTcp: 0, + avgTls: 0, + avgRequest: 0, + avgFirstByte: 0, + avgDownload: 0, + }); + }); +});