diff --git a/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts new file mode 100644 index 000000000..5517c3a98 --- /dev/null +++ b/server/test/integration/checksRepositoryDailyStatusBuckets.test.ts @@ -0,0 +1,124 @@ +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 { ILogger } from "../../src/utils/logger.ts"; +import { createMockLogger } from "../helpers/createMockLogger.ts"; + +// ── Real-Mongo harness ───────────────────────────────────────────────────────── +// The guarantees under test — timezone-correct $dateTrunc day boundaries, $avg +// skipping missing responseTime (null bucket, not 0), and zero-check days yielding +// no rows — are all aggregation-engine behavior, so they can only be exercised +// against a live mongod. + +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_A = new mongoose.Types.ObjectId(); +const MONITOR_B = new mongoose.Types.ObjectId(); +const TEAM_ID = new mongoose.Types.ObjectId(); + +const DAY_MS = 24 * 60 * 60 * 1000; + +// One shared instant per seeded "day" keeps every check of that day in the same +// bucket no matter when the test runs; instants exactly 24h apart always land on +// distinct calendar days. +const TODAY = new Date(Date.now() - 10 * 60 * 1000); +const ONE_DAY_AGO = new Date(TODAY.getTime() - DAY_MS); + +const utcDate = (date: Date) => date.toISOString().slice(0, 10); + +// Yesterday at 03:30 UTC: in America/Toronto that is 23:30 (EDT) or 22:30 (EST) +// of the *prior* day, so the local calendar date is always one behind UTC. +const yesterdayAtUtc = (hours: number, minutes: number) => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - 1); + date.setUTCHours(hours, minutes, 0, 0); + return date; +}; + +const seedCheck = (monitorId: mongoose.Types.ObjectId, createdAt: Date, overrides: Record = {}) => + CheckModel.create({ + metadata: { monitorId, teamId: TEAM_ID, type: "http" }, + status: true, + responseTime: 100, + createdAt, + ...overrides, + }); + +describe("MongoChecksRepository getDailyStatusBuckets", () => { + let repo: MongoChecksRepository; + + beforeEach(() => { + repo = new MongoChecksRepository(createMockLogger() as unknown as ILogger); + }); + + it("groups by monitor and calendar day with up/down math; zero-check days produce no rows", async () => { + await seedCheck(MONITOR_A, TODAY, { responseTime: 100 }); + await seedCheck(MONITOR_A, TODAY, { responseTime: 300 }); + await seedCheck(MONITOR_A, TODAY, { status: false, responseTime: 200 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { responseTime: 100 }); + await seedCheck(MONITOR_A, ONE_DAY_AGO, { status: false, responseTime: undefined }); + await seedCheck(MONITOR_B, TODAY, { status: false, responseTime: undefined }); + // Two days ago is deliberately unseeded: the gap day must be absent, not zero-filled. + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString(), MONITOR_B.toString()], 7, "UTC"); + + const monitorABuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_A.toString()); + const monitorBBuckets = buckets.filter((bucket) => bucket.monitorId === MONITOR_B.toString()); + + // Ascending by day; the day with a responseTime-less check averages over the + // checks that have one ($avg skips missing) rather than coercing them to 0. + expect(monitorABuckets).toEqual([ + { monitorId: MONITOR_A.toString(), date: utcDate(ONE_DAY_AGO), totalChecks: 2, upChecks: 1, downChecks: 1, avgResponseTime: 100 }, + { monitorId: MONITOR_A.toString(), date: utcDate(TODAY), totalChecks: 3, upChecks: 2, downChecks: 1, avgResponseTime: 200 }, + ]); + // A day where no check recorded a responseTime yields null, not 0. + expect(monitorBBuckets).toEqual([ + { monitorId: MONITOR_B.toString(), date: utcDate(TODAY), totalChecks: 1, upChecks: 0, downChecks: 1, avgResponseTime: null }, + ]); + }); + + it("cuts day boundaries in the requested timezone", async () => { + const lateEveningLocal = yesterdayAtUtc(3, 30); + await seedCheck(MONITOR_A, lateEveningLocal); + + const utcBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + const torontoBuckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "America/Toronto"); + + const previousUtcDay = new Date(lateEveningLocal); + previousUtcDay.setUTCDate(previousUtcDay.getUTCDate() - 1); + + expect(utcBuckets).toHaveLength(1); + expect(utcBuckets[0].date).toBe(utcDate(lateEveningLocal)); + expect(torontoBuckets).toHaveLength(1); + expect(torontoBuckets[0].date).toBe(utcDate(previousUtcDay)); + }); + + it("excludes checks older than the requested window", async () => { + await seedCheck(MONITOR_A, new Date(Date.now() - 10 * DAY_MS)); + await seedCheck(MONITOR_A, TODAY); + + const buckets = await repo.getDailyStatusBuckets([MONITOR_A.toString()], 7, "UTC"); + + expect(buckets).toHaveLength(1); + expect(buckets[0]).toMatchObject({ date: utcDate(TODAY), totalChecks: 1 }); + }); +}); diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts index 149179bd7..9c8a4b27f 100644 --- a/server/test/unit/services/statusPageService.test.ts +++ b/server/test/unit/services/statusPageService.test.ts @@ -3,6 +3,8 @@ import { StatusPageService } from "../../../src/domain/status-pages/status-page. import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts"; import type { ISettingsService } from "../../../src/domain/app-settings/app-settings.service.ts"; import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts"; +import type { IChecksRepository } from "../../../src/domain/checks/check.repository.interface.ts"; +import type { CheckSnapshot } from "../../../src/domain/checks/check.type.ts"; import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts"; import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts"; import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/domain/status-pages/status-page.type.ts"; @@ -69,7 +71,7 @@ const createSettingsService = (themesEnabled: boolean, clientHost = "http://loca ({ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled), getSettings: jest.fn().mockReturnValue({ clientHost }), - getDBSettings: jest.fn().mockResolvedValue({ showURL }), + getDBSettings: jest.fn().mockResolvedValue({ showURL, checkTTL: 30 }), }) as unknown as jest.Mocked; const createMonitorsRepo = () => @@ -77,12 +79,18 @@ const createMonitorsRepo = () => findByIds: jest.fn().mockResolvedValue([makeMonitor()]), }) as unknown as jest.Mocked; +const createChecksRepo = () => + ({ + getDailyStatusBuckets: jest.fn().mockResolvedValue([]), + }) as unknown as jest.Mocked; + const createService = (themesEnabled = true, clientHost = "http://localhost:5173", showURL = false) => { const repo = createRepo(); const settingsService = createSettingsService(themesEnabled, clientHost, showURL); const monitorsRepo = createMonitorsRepo(); - const service = new StatusPageService(repo, settingsService, monitorsRepo); - return { service, repo, settingsService, monitorsRepo }; + const checksRepo = createChecksRepo(); + const service = new StatusPageService(repo, settingsService, monitorsRepo, checksRepo); + return { service, repo, settingsService, monitorsRepo, checksRepo }; }; // ── Tests ──────────────────────────────────────────────────────────────────── @@ -353,6 +361,59 @@ describe("StatusPageService", () => { statusPage: expect.objectContaining({ id: "sp-1" }), }); }); + + describe("range", () => { + it("omits range-mode fields and never queries buckets for the default (latest) range", async () => { + const { service, checksRepo } = createService(); + + const implicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined); + const explicitLatest = await service.getPublicStatusPagePayload(publishedPage(), undefined, "latest"); + + for (const payload of [implicitLatest, explicitLatest]) { + expect(payload).not.toHaveProperty("range"); + expect(payload).not.toHaveProperty("bucketTimezone"); + expect(payload).not.toHaveProperty("checkTTLDays"); + expect(payload.monitors[0]).not.toHaveProperty("dailyChecks"); + } + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + + it("attaches dailyChecks, empties recentChecks, and echoes range metadata for a day range", async () => { + const { service, checksRepo, monitorsRepo } = createService(); + const bucket = { monitorId: "mon-1", date: "2026-08-01", totalChecks: 10, upChecks: 9, downChecks: 1, avgResponseTime: 120 }; + (checksRepo.getDailyStatusBuckets as jest.Mock).mockResolvedValue([bucket]); + (monitorsRepo.findByIds as jest.Mock).mockResolvedValue([makeMonitor({ recentChecks: [{ id: "chk-1" } as CheckSnapshot] })]); + const page = makeStatusPage({ isPublished: true, timezone: "America/Toronto" }); + + const payload = await service.getPublicStatusPagePayload(page, undefined, "30d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 30, "America/Toronto"); + expect(payload).toMatchObject({ range: "30d", bucketTimezone: "America/Toronto", checkTTLDays: 30 }); + expect(payload.monitors[0].recentChecks).toEqual([]); + expect(payload.monitors[0].dailyChecks).toEqual([bucket]); + for (const field of SENSITIVE_FIELDS) { + expect(payload.monitors[0]).not.toHaveProperty(field); + } + }); + + it("falls back to Etc/UTC when the page has no timezone and defaults dailyChecks to empty", async () => { + const { service, checksRepo } = createService(); + + const payload = await service.getPublicStatusPagePayload(publishedPage(), undefined, "60d"); + + expect(checksRepo.getDailyStatusBuckets).toHaveBeenCalledWith(["mon-1"], 60, "Etc/UTC"); + expect(payload.bucketTimezone).toBe("Etc/UTC"); + expect(payload.monitors[0].dailyChecks).toEqual([]); + }); + + it("does not bypass the unpublished-page 403", async () => { + const { service, checksRepo } = createService(); + const unpublished = makeStatusPage({ isPublished: false, teamId: "team-A" }); + + await expect(service.getPublicStatusPagePayload(unpublished, undefined, "90d")).rejects.toMatchObject({ status: 403 }); + expect(checksRepo.getDailyStatusBuckets).not.toHaveBeenCalled(); + }); + }); }); describe("deleteStatusPage", () => { diff --git a/server/test/unit/validation/statusPageValidation.test.ts b/server/test/unit/validation/statusPageValidation.test.ts index a2d659c34..ce4e0191b 100644 --- a/server/test/unit/validation/statusPageValidation.test.ts +++ b/server/test/unit/validation/statusPageValidation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@jest/globals"; -import { createStatusPageBodyValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { createStatusPageBodyValidation, getStatusPageQueryValidation } from "../../../src/api/validation/statusPageValidation.ts"; +import { StatusPageRanges } from "../../../src/domain/status-pages/status-page.type.ts"; const baseCreateBody = (overrides: Record = {}) => ({ type: "uptime", @@ -61,3 +62,31 @@ describe("createStatusPageBodyValidation", () => { } }); }); + +describe("getStatusPageQueryValidation", () => { + it("defaults range to latest when absent", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe("latest"); + } + }); + + it("accepts every supported range", () => { + for (const range of StatusPageRanges) { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.range).toBe(range); + } + } + }); + + it("rejects an unsupported range", () => { + const result = getStatusPageQueryValidation.safeParse({ type: "uptime", range: "45d" }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual(expect.arrayContaining([expect.objectContaining({ path: ["range"] })])); + } + }); +});