add tests
This commit is contained in:
1 parent
ab7f2cd8f4
commit
9001a8d294
3 files changed
+218
-4
No files matched your search
@@ -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<string, unknown> = {}) =>
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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<ISettingsService>;
|
||||
|
||||
const createMonitorsRepo = () =>
|
||||
@@ -77,12 +79,18 @@ const createMonitorsRepo = () =>
|
||||
findByIds: jest.fn().mockResolvedValue([makeMonitor()]),
|
||||
}) as unknown as jest.Mocked<IMonitorsRepository>;
|
||||
|
||||
const createChecksRepo = () =>
|
||||
({
|
||||
getDailyStatusBuckets: jest.fn().mockResolvedValue([]),
|
||||
}) as unknown as jest.Mocked<IChecksRepository>;
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, unknown> = {}) => ({
|
||||
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"] })]));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user