363 lines
14 KiB
JavaScript
363 lines
14 KiB
JavaScript
import StatusService from "@/services/infrastructure/StatusService.js";
|
|
import { MonitorStats } from "@/db/models/index.js";
|
|
import ApiError from "@/utils/ApiError.js";
|
|
import { Types } from "mongoose";
|
|
const buildStats = (overrides = {}) => {
|
|
const saveMock = overrides.save ||
|
|
jest.fn().mockImplementation(async function () {
|
|
return this;
|
|
});
|
|
return {
|
|
monitorId: {},
|
|
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,
|
|
};
|
|
};
|
|
const buildStatusResponse = (overrides = {}) => ({
|
|
monitorId: "monitor-id",
|
|
teamId: "team-id",
|
|
type: "http",
|
|
status: "up",
|
|
message: "OK",
|
|
responseTime: 0,
|
|
...overrides,
|
|
});
|
|
const buildMonitor = (overrides) => {
|
|
const monitor = {
|
|
_id: new Types.ObjectId(),
|
|
teamId: "team-id",
|
|
status: "up",
|
|
latestChecks: [],
|
|
n: 1,
|
|
save: jest.fn(),
|
|
...overrides,
|
|
};
|
|
if (!monitor.latestChecks) {
|
|
monitor.latestChecks = [];
|
|
}
|
|
monitor.save.mockImplementation(async () => monitor);
|
|
return {
|
|
monitor: monitor,
|
|
saveMock: monitor.save,
|
|
};
|
|
};
|
|
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",
|
|
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",
|
|
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",
|
|
n: 2,
|
|
latestChecks: [
|
|
{
|
|
status: "down",
|
|
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",
|
|
responseTime: index,
|
|
checkedAt: new Date(`2024-01-${index + 1}`),
|
|
}));
|
|
const { monitor, saveMock } = buildMonitor({
|
|
status: "up",
|
|
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.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;
|
|
let createSpy;
|
|
beforeEach(() => {
|
|
findOneSpy = jest.spyOn(MonitorStats, "findOne");
|
|
createSpy = jest.spyOn(MonitorStats, "create");
|
|
jest.useFakeTimers();
|
|
});
|
|
afterEach(() => {
|
|
jest.useRealTimers();
|
|
jest.restoreAllMocks();
|
|
});
|
|
it("creates MonitorStats when missing and returns saved stats", async () => {
|
|
const now = new Date("2024-02-01T00:00:00Z");
|
|
jest.setSystemTime(now);
|
|
findOneSpy.mockResolvedValue(null);
|
|
const { monitor } = buildMonitor({});
|
|
const statusResponse = buildStatusResponse({ status: "up", responseTime: 123 });
|
|
const stats = buildStats({ totalChecks: 0, totalUpChecks: 0, totalDownChecks: 0 });
|
|
const saveMock = stats.save;
|
|
createSpy.mockResolvedValue(stats);
|
|
|
|
const result = await service.updateMonitorStats(monitor, statusResponse, false);
|
|
|
|
expect(findOneSpy).toHaveBeenCalledWith({ monitorId: monitor._id });
|
|
expect(createSpy).toHaveBeenCalledWith({
|
|
monitorId: monitor._id,
|
|
currentStreakStartedAt: expect.any(Number),
|
|
});
|
|
expect(saveMock).toHaveBeenCalledTimes(1);
|
|
expect(result).toBe(stats);
|
|
expect(stats.totalChecks).toBe(1);
|
|
expect(stats.totalUpChecks).toBe(1);
|
|
expect(stats.lastResponseTime).toBe(123);
|
|
expect(stats.lastCheckTimestamp).toBe(now.getTime());
|
|
});
|
|
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;
|
|
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;
|
|
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);
|
|
});
|
|
});
|
|
|
|
// Additional coverage: evaluateThresholds branches
|
|
describe("StatusService.evaluateThresholds", () => {
|
|
const service = new StatusService();
|
|
|
|
const buildCheck = (systemOverrides = {}) => ({
|
|
system: {
|
|
cpu: { usage_percent: 0.2, temperature: [40, 45] },
|
|
memory: { usage_percent: 0.3 },
|
|
disk: [{ usage_percent: 0.4 }],
|
|
...systemOverrides,
|
|
},
|
|
});
|
|
|
|
it("returns early when monitor is not infrastructure or not up", async () => {
|
|
const { monitor } = buildMonitor({ type: "http", status: "up", thresholds: { cpu: 50 } });
|
|
const check = buildCheck();
|
|
const res1 = await service.evaluateThresholds(monitor, check);
|
|
expect(res1).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) });
|
|
|
|
const { monitor: m2 } = buildMonitor({ type: "infrastructure", status: "down", thresholds: { cpu: 50 } });
|
|
const res2 = await service.evaluateThresholds(m2, check);
|
|
expect(res2).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) });
|
|
});
|
|
|
|
it("returns early when thresholds or system missing", async () => {
|
|
const { monitor } = buildMonitor({ type: "infrastructure", status: "up" });
|
|
const check = buildCheck();
|
|
const res1 = await service.evaluateThresholds(monitor, check);
|
|
expect(res1.hasBreach).toBe(false);
|
|
|
|
const { monitor: m2 } = buildMonitor({ type: "infrastructure", status: "up", thresholds: { cpu: 50 } });
|
|
const res2 = await service.evaluateThresholds(m2, { system: undefined });
|
|
expect(res2.hasBreach).toBe(false);
|
|
});
|
|
|
|
it("detects cpu/memory/disk/temperature breaches and fills notes", async () => {
|
|
const { monitor } = buildMonitor({
|
|
type: "infrastructure",
|
|
status: "up",
|
|
thresholds: { cpu: 50, memory: 60, disk: 80, temperature: 70 },
|
|
});
|
|
const check = buildCheck({
|
|
cpu: { usage_percent: 0.8, temperature: [60, 75] }, // 80% & 75C
|
|
memory: { usage_percent: 0.7 }, // 70%
|
|
disk: [{ usage_percent: 0.5 }, { usage_percent: 0.95 }], // max 95%
|
|
});
|
|
const res = await service.evaluateThresholds(monitor, check);
|
|
expect(res.hasBreach).toBe(true);
|
|
expect(res.notes).toEqual(
|
|
expect.arrayContaining([
|
|
"cpu 80% > 50%",
|
|
"memory 70% > 60%",
|
|
"disk 95.00% > 80%",
|
|
"temperature 75C > 70C",
|
|
])
|
|
);
|
|
expect(res.metrics.cpu).toMatchObject({ value: 80, threshold: 50, breached: true });
|
|
expect(res.metrics.memory).toMatchObject({ value: 70, threshold: 60, breached: true });
|
|
expect(res.metrics.disk).toMatchObject({ value: 95, threshold: 80, breached: true });
|
|
});
|
|
|
|
it("handles non-breach values and undefined fields safely", async () => {
|
|
const { monitor } = buildMonitor({
|
|
type: "infrastructure",
|
|
status: "up",
|
|
thresholds: { cpu: 95, memory: 95, disk: 99, temperature: 100 },
|
|
});
|
|
const check = buildCheck({
|
|
cpu: { usage_percent: undefined, temperature: [] },
|
|
memory: {},
|
|
disk: [{}, { usage_percent: 0.1 }], // max 10%
|
|
});
|
|
const res = await service.evaluateThresholds(monitor, check);
|
|
expect(res.hasBreach).toBe(false);
|
|
expect(res.notes).toEqual([]);
|
|
expect(res.metrics.cpu.breached).toBe(false);
|
|
expect(res.metrics.memory.breached).toBe(false);
|
|
expect(res.metrics.disk.breached).toBe(false);
|
|
});
|
|
|
|
it("returns safe default when unexpected error occurs", async () => {
|
|
const { monitor } = buildMonitor({
|
|
type: "infrastructure",
|
|
status: "up",
|
|
thresholds: { cpu: 50 },
|
|
});
|
|
// Create a system object that throws when accessed to exercise catch {}
|
|
const throwingSystem = new Proxy(
|
|
{},
|
|
{
|
|
get() {
|
|
throw new Error("boom");
|
|
},
|
|
}
|
|
);
|
|
const res = await service.evaluateThresholds(monitor, { system: throwingSystem });
|
|
expect(res).toEqual({ hasBreach: false, notes: [], metrics: expect.any(Object) });
|
|
});
|
|
});
|