add test env

This commit is contained in:
Alex Holliday committed 2025-11-14 17:17:41 +00:00
1 parent 75acf7b38f
commit b4da045770
7 files changed
+6809 -2

No files matched your search

+2 -1
View File
@@ -8,8 +8,9 @@ yarn-error.log*
.env
.DS_Store
/logs/
coverage/
# TypeScript
/dist/
/build/
*.tsbuildinfo
*.tsbuildinfo
+24
View File
@@ -0,0 +1,24 @@
/** @type {import('jest').Config} */
const config = {
preset: "ts-jest/presets/default-esm",
testEnvironment: "node",
rootDir: "./",
roots: ["<rootDir>/tests"],
testMatch: ["**/*.test.ts"],
verbose: true,
moduleNameMapper: {
"^@/(.*)\\.js$": "<rootDir>/src/$1.ts",
"^@/(.*)$": "<rootDir>/src/$1",
"^(\\.\\.?/.*)\\.js$": "$1"
},
extensionsToTreatAsEsm: [".ts"],
transform: {
"^.+\\.(t|j)s$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }]
},
collectCoverageFrom: ["src/**/*.{ts,js}", "!src/**/*.d.ts"],
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov"],
coveragePathIgnorePatterns: ["/node_modules/", "<rootDir>/src/index.ts"]
};
export default config;
+6427
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "nodemon --exec tsx src/index.js",
"build": "tsc && tsc-alias",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"keywords": [],
"author": "",
@@ -15,10 +17,13 @@
"devDependencies": {
"@types/cookie-parser": "1.4.9",
"@types/cors": "2.8.19",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "24.7.1",
"@types/nodemailer": "7.0.2",
"jest": "^29.7.0",
"nodemon": "3.1.10",
"ts-jest": "^29.2.5",
"tsc-alias": "1.8.16",
"tsx": "4.20.6",
"typescript": "5.9.3"
@@ -0,0 +1,321 @@
import StatusService from "@/services/infrastructure/StatusService.js";
import { MonitorStats } from "@/db/models/index.js";
import ApiError from "@/utils/ApiError.js";
import { Types } from "mongoose";
import type { IMonitorStats } from "@/db/models/index.js";
import type { StatusResponse } from "@/services/infrastructure/NetworkService.js";
import type { IMonitor } from "@/db/models/monitors/Monitor.js";
import type { MonitorStatus } from "@/db/models/monitors/Monitor.js";
const buildStats = (
overrides: Partial<IMonitorStats & { save: jest.Mock<any, any> }> = {}
): IMonitorStats => {
const saveMock =
overrides.save ||
jest.fn().mockImplementation(async function (this: unknown) {
return this;
});
return {
monitorId: {} as any,
avgResponseTime: 0,
maxResponseTime: 0,
totalChecks: 1,
totalUpChecks: 0,
totalDownChecks: 0,
uptimePercentage: 0,
lastCheckTimestamp: 0,
lastResponseTime: 0,
timeOfLastFailure: 0,
currentStreak: 0,
currentStreakStatus: "up",
currentStreakStartedAt: 0,
createdAt: new Date(),
updatedAt: new Date(),
save: saveMock,
...overrides,
} as unknown as IMonitorStats;
};
const buildStatusResponse = (
overrides: Partial<StatusResponse> = {}
): StatusResponse => ({
monitorId: "monitor-id",
teamId: "team-id",
type: "http" as any,
status: "up",
message: "OK",
responseTime: 0,
...overrides,
});
const buildMonitor = (
overrides: Partial<IMonitor> & { status?: MonitorStatus }
) => {
const monitor: any = {
_id: new Types.ObjectId(),
teamId: "team-id",
status: "up" as MonitorStatus,
latestChecks: [],
n: 1,
save: jest.fn(),
...overrides,
};
if (!monitor.latestChecks) {
monitor.latestChecks = [];
}
monitor.save.mockImplementation(async () => monitor);
return {
monitor: monitor as IMonitor,
saveMock: monitor.save as jest.Mock,
};
};
describe("StatusService.calculateAvgResponseTime", () => {
const service = new StatusService();
it("returns latest response time when average is unset", () => {
const stats = buildStats({ avgResponseTime: 0, totalChecks: 1 });
const statusResponse = buildStatusResponse({ responseTime: 250 });
const result = service.calculateAvgResponseTime(stats, statusResponse);
expect(result).toBe(250);
});
it("computes rolling average when checks exist", () => {
const stats = buildStats({ avgResponseTime: 120, totalChecks: 5 });
const statusResponse = buildStatusResponse({ responseTime: 300 });
const result = service.calculateAvgResponseTime(stats, statusResponse);
expect(result).toBeCloseTo(156);
});
});
describe("StatusService.updateMonitorStatus", () => {
const service = new StatusService();
it("initializing monitor adopts first status", async () => {
const { monitor, saveMock } = buildMonitor({
status: "initializing" as MonitorStatus,
latestChecks: [],
});
const statusResponse = buildStatusResponse({ status: "up", responseTime: 120 });
const [savedMonitor, statusChanged] = await service.updateMonitorStatus(
monitor,
statusResponse
);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(monitor.status).toBe("up");
expect(monitor.latestChecks).toHaveLength(1);
expect(monitor.latestChecks?.[0]?.status).toBe("up");
expect(monitor.lastCheckedAt).toBeInstanceOf(Date);
expect(savedMonitor).toBe(monitor);
expect(statusChanged).toBe(false);
});
it("returns early when not enough checks accumulated", async () => {
const { monitor, saveMock } = buildMonitor({
status: "up" as MonitorStatus,
n: 3,
latestChecks: [],
});
const statusResponse = buildStatusResponse({ status: "down", responseTime: 90 });
const [, statusChanged] = await service.updateMonitorStatus(
monitor,
statusResponse
);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(monitor.status).toBe("up");
expect(monitor.latestChecks).toHaveLength(1);
expect(statusChanged).toBe(false);
});
it("updates status when last n checks differ", async () => {
const { monitor, saveMock } = buildMonitor({
status: "up" as MonitorStatus,
n: 2,
latestChecks: [
{
status: "down" as MonitorStatus,
responseTime: 80,
checkedAt: new Date("2024-01-01"),
},
],
});
const statusResponse = buildStatusResponse({ status: "down", responseTime: 70 });
const [, statusChanged] = await service.updateMonitorStatus(
monitor,
statusResponse
);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(monitor.status).toBe("down");
expect(statusChanged).toBe(true);
expect(monitor.latestChecks.slice(-2).every((c) => c.status === "down")).toBe(
true
);
});
it("keeps only latest 25 checks", async () => {
const existingChecks = Array.from({ length: 25 }, (_, index) => ({
status: "up" as MonitorStatus,
responseTime: index,
checkedAt: new Date(`2024-01-${index + 1}`),
}));
const { monitor, saveMock } = buildMonitor({
status: "up" as MonitorStatus,
n: 1,
latestChecks: [...existingChecks],
});
const statusResponse = buildStatusResponse({ status: "up", responseTime: 200 });
await service.updateMonitorStatus(monitor, statusResponse);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(monitor.latestChecks).toHaveLength(25);
expect(monitor.latestChecks?.[0]?.responseTime).toBe(1);
expect(
monitor.latestChecks?.some((check) => check.responseTime === 0)
).toBe(false);
const lastCheck =
monitor.latestChecks?.[monitor.latestChecks.length - 1] ?? null;
expect(lastCheck?.responseTime).toBe(200);
});
it("initializes latestChecks when missing", async () => {
const { monitor, saveMock } = buildMonitor({});
delete (monitor as any).latestChecks;
const statusResponse = buildStatusResponse({ status: "up", responseTime: 75 });
const [, statusChanged] = await service.updateMonitorStatus(
monitor,
statusResponse
);
expect(saveMock).toHaveBeenCalledTimes(1);
expect(Array.isArray(monitor.latestChecks)).toBe(true);
expect(monitor.latestChecks).toHaveLength(1);
expect(statusChanged).toBe(false);
});
});
describe("StatusService.updateMonitorStats", () => {
const service = new StatusService();
let findOneSpy: jest.SpiedFunction<typeof MonitorStats.findOne>;
beforeEach(() => {
findOneSpy = jest.spyOn(MonitorStats, "findOne");
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it("throws ApiError when monitor stats are missing", async () => {
findOneSpy.mockResolvedValue(null);
const { monitor } = buildMonitor({});
const statusResponse = buildStatusResponse({ status: "up" });
await expect(
service.updateMonitorStats(monitor, statusResponse, false)
).rejects.toBeInstanceOf(ApiError);
await expect(
service.updateMonitorStats(monitor, statusResponse, false)
).rejects.toMatchObject({ message: "MonitorStats not found", status: 500 });
expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id });
});
it("increments counters and preserves streak when status unchanged", async () => {
const now = new Date("2024-02-01T12:00:00Z");
jest.setSystemTime(now);
const stats = buildStats({
totalChecks: 4,
totalUpChecks: 3,
totalDownChecks: 1,
avgResponseTime: 100,
maxResponseTime: 250,
lastResponseTime: 90,
currentStreak: 2,
currentStreakStatus: "up",
currentStreakStartedAt: 111,
lastCheckTimestamp: 1,
timeOfLastFailure: 555,
});
const saveMock = stats.save as jest.Mock;
findOneSpy.mockResolvedValue(stats);
const { monitor } = buildMonitor({});
const statusResponse = buildStatusResponse({ status: "up", responseTime: 150 });
const result = await service.updateMonitorStats(
monitor,
statusResponse,
false
);
expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id });
expect(saveMock).toHaveBeenCalledTimes(1);
expect(result).toBe(stats);
expect(stats.totalChecks).toBe(5);
expect(stats.totalUpChecks).toBe(4);
expect(stats.totalDownChecks).toBe(1);
expect(stats.currentStreak).toBe(3);
expect(stats.currentStreakStatus).toBe("up");
expect(stats.currentStreakStartedAt).toBe(111);
expect(stats.lastCheckTimestamp).toBe(now.getTime());
expect(stats.timeOfLastFailure).toBe(555);
expect(stats.avgResponseTime).toBeCloseTo(110);
expect(stats.uptimePercentage).toBeCloseTo(4 / 5);
expect(stats.lastResponseTime).toBe(150);
expect(stats.maxResponseTime).toBe(250);
});
it("resets streak and records failure on status change to down", async () => {
const now = new Date("2024-02-01T12:05:00Z");
jest.setSystemTime(now);
const stats = buildStats({
totalChecks: 10,
totalUpChecks: 7,
totalDownChecks: 3,
avgResponseTime: 200,
maxResponseTime: 300,
currentStreak: 5,
currentStreakStatus: "up",
currentStreakStartedAt: 999,
timeOfLastFailure: 444,
});
const saveMock = stats.save as jest.Mock;
findOneSpy.mockResolvedValue(stats);
const { monitor } = buildMonitor({});
const statusResponse = buildStatusResponse({ status: "down", responseTime: 400 });
const result = await service.updateMonitorStats(
monitor,
statusResponse,
true
);
expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id });
expect(saveMock).toHaveBeenCalledTimes(1);
expect(result).toBe(stats);
expect(stats.totalChecks).toBe(11);
expect(stats.totalUpChecks).toBe(7);
expect(stats.totalDownChecks).toBe(4);
expect(stats.currentStreak).toBe(1);
expect(stats.currentStreakStatus).toBe("down");
expect(stats.currentStreakStartedAt).toBe(now.getTime());
expect(stats.lastCheckTimestamp).toBe(now.getTime());
expect(stats.timeOfLastFailure).toBe(now.getTime());
expect(stats.avgResponseTime).toBeCloseTo((200 * 10 + 400) / 11);
expect(stats.uptimePercentage).toBeCloseTo(7 / 11);
expect(stats.lastResponseTime).toBe(400);
expect(stats.maxResponseTime).toBe(400);
});
});
+17
View File
@@ -0,0 +1,17 @@
import ApiError from "@/utils/ApiError.js";
describe("ApiError", () => {
it("captures message and status", () => {
const error = new ApiError("boom", 418);
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("boom");
expect(error.status).toBe(418);
expect(error.name).toBe("ApiError");
});
it("defaults status to 500", () => {
const error = new ApiError("failure");
expect(error.status).toBe(500);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./",
"outDir": "./dist",
"module": "esnext",
"moduleResolution": "nodenext",
"types": ["jest", "node"]
},
"include": ["tests/**/*.ts", "src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}