update tests
This commit is contained in:
1 parent
ee9c08b1bb
commit
fa7eaa4788
5 files changed
+461
-518
No files matched your search
@@ -1,5 +1,10 @@
|
||||
import { jest } from "@jest/globals";
|
||||
import { QueueHelper } from "../../src/worker/worker.helper.ts";
|
||||
import { WorkerHelper } from "../../src/worker/worker.helper.ts";
|
||||
import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts";
|
||||
import { CheckPipeline, GeoChecksPipeline } from "../../src/worker/worker.check-pipeline.ts";
|
||||
import { NotificationReactor } from "../../src/worker/reactors/reactor.notification.ts";
|
||||
import { IncidentReactor } from "../../src/worker/reactors/reactor.incident.ts";
|
||||
import { ReactorDispatcher } from "../../src/worker/reactors/reactor.dispatcher.ts";
|
||||
import { StatusService } from "../../src/service/statusService.ts";
|
||||
import { IncidentService } from "../../src/domain/incidents/incident.service.ts";
|
||||
import { InMemoryMonitorsRepository } from "./InMemoryMonitorsRepository.ts";
|
||||
@@ -142,24 +147,37 @@ export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
||||
};
|
||||
|
||||
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
|
||||
const geoChecksService = { buildGeoCheck: jest.fn() };
|
||||
|
||||
const helper = new QueueHelper(
|
||||
logger,
|
||||
const checkPipeline = new CheckPipeline(
|
||||
monitorsRepo as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
checkService as any,
|
||||
networkService as any,
|
||||
bufferStub as any,
|
||||
new MonitorStatusPolicy(),
|
||||
statusService as any,
|
||||
notificationsService as any,
|
||||
logger
|
||||
);
|
||||
const geoCheckPipeline = new GeoChecksPipeline(maintenanceWindowsRepo as any, geoChecksService as any, bufferStub as any, logger);
|
||||
|
||||
const notificationReactor = new NotificationReactor(notificationsService as any);
|
||||
const incidentReactor = new IncidentReactor(incidentService as any);
|
||||
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
||||
|
||||
const helper = new WorkerHelper(
|
||||
logger,
|
||||
checkService as any,
|
||||
{ getDBSettings: jest.fn() } as any,
|
||||
bufferStub as any,
|
||||
incidentService as any,
|
||||
maintenanceWindowsRepo as any,
|
||||
monitorsRepo as any,
|
||||
{ findAllTeamIds: jest.fn() } as any,
|
||||
createStubMonitorStatsRepo() as any,
|
||||
createStubChecksRepo() as any,
|
||||
incidentsRepo as any,
|
||||
{ buildGeoCheck: jest.fn() } as any,
|
||||
{ deleteByMonitorIdsNotIn: jest.fn() } as any
|
||||
{ deleteByMonitorIdsNotIn: jest.fn() } as any,
|
||||
reactorDispatcher,
|
||||
checkPipeline,
|
||||
geoCheckPipeline
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { CheckPipeline, GeoChecksPipeline } from "../../../src/worker/worker.check-pipeline.ts";
|
||||
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
({
|
||||
id: "m1",
|
||||
teamId: "team",
|
||||
type: "http",
|
||||
interval: 60000,
|
||||
status: "up",
|
||||
geoCheckEnabled: false,
|
||||
geoCheckLocations: [],
|
||||
...overrides,
|
||||
}) as Monitor;
|
||||
|
||||
const activeWindow = () => {
|
||||
const now = Date.now();
|
||||
return { active: true, start: new Date(now - 1000).toISOString(), end: new Date(now + 1000).toISOString(), repeat: 0 };
|
||||
};
|
||||
|
||||
// ── CheckPipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
const createCheckPipeline = (overrides?: Record<string, any>) => {
|
||||
const defaults = {
|
||||
logger: createMockLogger(),
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue({ monitorId: "m1", status: true, code: 200, message: "OK" }) },
|
||||
statusService: {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
|
||||
},
|
||||
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }) },
|
||||
buffer: { addToBuffer: jest.fn() },
|
||||
monitorsRepository: { updateById: jest.fn().mockResolvedValue({}) },
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
|
||||
monitorStatusPolicy: new MonitorStatusPolicy(),
|
||||
...overrides,
|
||||
};
|
||||
const pipeline = new CheckPipeline(
|
||||
defaults.monitorsRepository as any,
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.checkService as any,
|
||||
defaults.networkService as any,
|
||||
defaults.buffer as any,
|
||||
defaults.monitorStatusPolicy as any,
|
||||
defaults.statusService as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
return { pipeline, defaults };
|
||||
};
|
||||
|
||||
describe("CheckPipeline", () => {
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { pipeline } = createCheckPipeline();
|
||||
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
});
|
||||
|
||||
it("skips and sets maintenance status when in maintenance window", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor({ status: "up" }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
|
||||
});
|
||||
|
||||
it("does not re-write status when already in maintenance", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
|
||||
});
|
||||
|
||||
await pipeline.run(makeMonitor({ status: "maintenance" }));
|
||||
|
||||
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when network response is null", async () => {
|
||||
const { pipeline } = createCheckPipeline({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
await expect(pipeline.run(makeMonitor())).rejects.toThrow("No network response");
|
||||
});
|
||||
|
||||
it("returns null and warns when buildCheck returns null", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
checkService: { buildCheck: jest.fn().mockReturnValue(null) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor());
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
|
||||
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buffers the check, updates status, and returns an evaluation", async () => {
|
||||
const status = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const statusChange = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(status) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusChange) },
|
||||
});
|
||||
|
||||
const result = await pipeline.run(makeMonitor());
|
||||
|
||||
expect(defaults.buffer.addToBuffer).toHaveBeenCalledWith({ id: "check-1" });
|
||||
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
monitor: statusChange.monitor,
|
||||
status,
|
||||
check: { id: "check-1" },
|
||||
statusChange,
|
||||
decision: expect.objectContaining({ shouldCreateIncident: true, shouldSendNotification: true, incidentReason: "status_down" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("proceeds with the check when no maintenance window is active", async () => {
|
||||
const { pipeline, defaults } = createCheckPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([{ ...activeWindow(), active: false }]) },
|
||||
});
|
||||
|
||||
await pipeline.run(makeMonitor());
|
||||
|
||||
expect(defaults.networkService.requestStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── GeoChecksPipeline ──────────────────────────────────────────────────────────
|
||||
|
||||
const createGeoPipeline = (overrides?: Record<string, any>) => {
|
||||
const defaults = {
|
||||
logger: createMockLogger(),
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(null) },
|
||||
buffer: { addGeoCheckToBuffer: jest.fn() },
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([]) },
|
||||
...overrides,
|
||||
};
|
||||
const pipeline = new GeoChecksPipeline(
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.geoChecksService as any,
|
||||
defaults.buffer as any,
|
||||
defaults.logger as any
|
||||
);
|
||||
return { pipeline, defaults };
|
||||
};
|
||||
|
||||
const geoMonitor = (overrides?: Partial<Monitor>) =>
|
||||
makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"], ...overrides });
|
||||
|
||||
describe("GeoChecksPipeline", () => {
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { pipeline } = createGeoPipeline();
|
||||
await expect(pipeline.run({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
});
|
||||
|
||||
it("returns null when geoCheckEnabled is false", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline();
|
||||
const result = await pipeline.run(makeMonitor({ geoCheckEnabled: false }));
|
||||
expect(result).toBeNull();
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null when monitor type does not support geo checks", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline();
|
||||
await pipeline.run(geoMonitor({ type: "hardware" }));
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("does not support geo checks") }));
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when geoCheckLocations is empty", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline();
|
||||
await pipeline.run(geoMonitor({ geoCheckLocations: [] }));
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
|
||||
});
|
||||
|
||||
it("warns when geoCheckLocations is undefined", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline();
|
||||
await pipeline.run(geoMonitor({ geoCheckLocations: undefined as any }));
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
|
||||
});
|
||||
|
||||
it("skips when in maintenance window", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline({
|
||||
maintenanceWindowsRepository: { findByMonitorId: jest.fn().mockResolvedValue([activeWindow()]) },
|
||||
});
|
||||
await pipeline.run(geoMonitor());
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when buildGeoCheck returns null", async () => {
|
||||
const { pipeline, defaults } = createGeoPipeline();
|
||||
await pipeline.run(geoMonitor());
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
|
||||
});
|
||||
|
||||
it("adds geo check to buffer on success", async () => {
|
||||
const geoCheck = { id: "gc-1", monitorId: "m1" };
|
||||
const { pipeline, defaults } = createGeoPipeline({
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
|
||||
});
|
||||
await pipeline.run(geoMonitor());
|
||||
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
|
||||
});
|
||||
|
||||
it("propagates errors (the job glue handles isolation)", async () => {
|
||||
const { pipeline } = createGeoPipeline({
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
|
||||
});
|
||||
await expect(pipeline.run(geoMonitor())).rejects.toThrow("api timeout");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { MonitorStatusPolicy } from "../../../src/worker/worker.monitor-status-policy.ts";
|
||||
import type { StatusChangeResult } from "../../../src/types/network.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeStatusChange = (overrides: Partial<StatusChangeResult> & { status?: string }): StatusChangeResult => {
|
||||
const { status = "up", ...rest } = overrides;
|
||||
return {
|
||||
monitor: { id: "m1", status } as any,
|
||||
statusChanged: false,
|
||||
prevStatus: "up",
|
||||
code: 200,
|
||||
timestamp: 0,
|
||||
...rest,
|
||||
} as StatusChangeResult;
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MonitorStatusPolicy", () => {
|
||||
const policy = new MonitorStatusPolicy();
|
||||
|
||||
it("does nothing when statusChanged is false", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: false, prevStatus: "up" }));
|
||||
expect(decision).toEqual({
|
||||
shouldCreateIncident: false,
|
||||
shouldResolveIncident: false,
|
||||
shouldSendNotification: false,
|
||||
incidentReason: null,
|
||||
notificationReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor goes down", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "down", statusChanged: true, prevStatus: "up", code: 500 }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "status_down",
|
||||
notificationReason: "status_change",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor is breached", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "breached", statusChanged: true, prevStatus: "up" }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "threshold_breach",
|
||||
notificationReason: "threshold_breach",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves incident when monitor recovers from down", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "down" }));
|
||||
expect(decision).toMatchObject({
|
||||
shouldResolveIncident: true,
|
||||
shouldSendNotification: true,
|
||||
shouldCreateIncident: false,
|
||||
notificationReason: "status_change",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves incident when monitor recovers from breached", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "up", statusChanged: true, prevStatus: "breached" }));
|
||||
expect(decision).toMatchObject({ shouldResolveIncident: true, shouldSendNotification: true });
|
||||
});
|
||||
|
||||
it("does not create or resolve for unhandled status transitions", () => {
|
||||
const decision = policy.evaluate(makeStatusChange({ status: "paused", statusChanged: true, prevStatus: "up" }));
|
||||
expect(decision).toMatchObject({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { ReactorDispatcher } from "../../../src/worker/reactors/reactor.dispatcher.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
const evaluation = { monitor: { id: "m1" } } as any;
|
||||
|
||||
const makeReactor = (name: string, blocking: boolean, impl?: () => Promise<void>) => ({
|
||||
name,
|
||||
blocking,
|
||||
react: jest.fn(impl ?? (() => Promise.resolve())),
|
||||
});
|
||||
|
||||
describe("ReactorDispatcher", () => {
|
||||
it("runs every reactor with the evaluation", async () => {
|
||||
const a = makeReactor("a", true);
|
||||
const b = makeReactor("b", false);
|
||||
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [a, b] as any);
|
||||
|
||||
await dispatcher.dispatch(evaluation);
|
||||
|
||||
expect(a.react).toHaveBeenCalledWith(evaluation);
|
||||
expect(b.react).toHaveBeenCalledWith(evaluation);
|
||||
});
|
||||
|
||||
it("awaits blocking reactors before resolving", async () => {
|
||||
let done = false;
|
||||
const blocking = makeReactor(
|
||||
"incident",
|
||||
true,
|
||||
() =>
|
||||
new Promise((r) =>
|
||||
setTimeout(() => {
|
||||
done = true;
|
||||
r();
|
||||
}, 10)
|
||||
)
|
||||
);
|
||||
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [blocking] as any);
|
||||
|
||||
await dispatcher.dispatch(evaluation);
|
||||
|
||||
expect(done).toBe(true);
|
||||
});
|
||||
|
||||
it("does not await non-blocking reactors", async () => {
|
||||
let done = false;
|
||||
const nonBlocking = makeReactor(
|
||||
"notification",
|
||||
false,
|
||||
() =>
|
||||
new Promise((r) =>
|
||||
setTimeout(() => {
|
||||
done = true;
|
||||
r();
|
||||
}, 20)
|
||||
)
|
||||
);
|
||||
const dispatcher = new ReactorDispatcher(createMockLogger() as any, [nonBlocking] as any);
|
||||
|
||||
await dispatcher.dispatch(evaluation);
|
||||
|
||||
expect(done).toBe(false); // resolved before the slow fire-and-forget reactor finished
|
||||
});
|
||||
|
||||
it("isolates a throwing blocking reactor and still runs later reactors", async () => {
|
||||
const logger = createMockLogger();
|
||||
const bad = makeReactor("incident", true, () => Promise.reject(new Error("db error")));
|
||||
const good = makeReactor("after", true);
|
||||
const dispatcher = new ReactorDispatcher(logger as any, [bad, good] as any);
|
||||
|
||||
await expect(dispatcher.dispatch(evaluation)).resolves.toBeUndefined();
|
||||
|
||||
expect(good.react).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
|
||||
});
|
||||
|
||||
it("isolates a rejecting non-blocking reactor (fire-and-forget)", async () => {
|
||||
const logger = createMockLogger();
|
||||
const bad = makeReactor("notification", false, () => Promise.reject(new Error("smtp down")));
|
||||
const dispatcher = new ReactorDispatcher(logger as any, [bad] as any);
|
||||
|
||||
await dispatcher.dispatch(evaluation);
|
||||
await new Promise((r) => setTimeout(r, 10)); // let the detached catch run
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { QueueHelper } from "../../../src/worker/worker.helper.ts";
|
||||
import { WorkerHelper } from "../../../src/worker/worker.helper.ts";
|
||||
import type { Monitor } from "../../../src/domain/monitors/monitor.types.ts";
|
||||
import { createMockLogger } from "../../helpers/createMockLogger.ts";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// WorkerHelper is now thin glue: it wires the scheduler's job functions to the
|
||||
// CheckPipeline / GeoChecksPipeline (acquire→record→decide) and the
|
||||
// ReactorDispatcher (react), and owns the cleanup jobs. The pipeline and
|
||||
// dispatcher internals are tested in checkPipeline.test.ts / reactorDispatcher.test.ts;
|
||||
// here we test only the glue + cleanup jobs.
|
||||
|
||||
const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
const maintenanceWindowsRepository = {
|
||||
findByMonitorId: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const monitorsRepository = {
|
||||
updateById: jest.fn().mockResolvedValue({}),
|
||||
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
|
||||
@@ -29,55 +32,48 @@ const createHelper = (overrides?: Record<string, unknown>) => {
|
||||
const geoChecksRepository = {
|
||||
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const statusServiceMock = {
|
||||
updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }),
|
||||
};
|
||||
const settingsServiceMock = {
|
||||
const settingsService = {
|
||||
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
|
||||
};
|
||||
const geoChecksServiceMock = {
|
||||
buildGeoCheck: jest.fn().mockResolvedValue(null),
|
||||
const checkService = {
|
||||
deleteOlderThan: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
const checkPipeline = { run: jest.fn().mockResolvedValue(null) };
|
||||
const geoCheckPipeline = { run: jest.fn().mockResolvedValue(null) };
|
||||
const reactorDispatcher = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const defaults = {
|
||||
logger: createMockLogger(),
|
||||
networkService: { requestStatus: jest.fn() },
|
||||
statusService: statusServiceMock,
|
||||
notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) },
|
||||
checkService: { buildCheck: jest.fn().mockReturnValue({ id: "check-1" }), deleteOlderThan: jest.fn().mockResolvedValue(0) },
|
||||
settingsService: settingsServiceMock,
|
||||
buffer: { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn() },
|
||||
incidentService: { handleIncident: jest.fn().mockResolvedValue(undefined) },
|
||||
maintenanceWindowsRepository,
|
||||
checkService,
|
||||
settingsService,
|
||||
monitorsRepository,
|
||||
teamsRepository,
|
||||
monitorStatsRepository,
|
||||
checksRepository,
|
||||
incidentsRepository,
|
||||
geoChecksService: geoChecksServiceMock,
|
||||
geoChecksRepository,
|
||||
reactorDispatcher,
|
||||
checkPipeline,
|
||||
geoCheckPipeline,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const helper = new QueueHelper(
|
||||
const helper = new WorkerHelper(
|
||||
defaults.logger as any,
|
||||
defaults.networkService as any,
|
||||
defaults.statusService as any,
|
||||
defaults.notificationsService as any,
|
||||
defaults.checkService as any,
|
||||
defaults.settingsService as any,
|
||||
defaults.buffer as any,
|
||||
defaults.incidentService as any,
|
||||
defaults.maintenanceWindowsRepository as any,
|
||||
defaults.monitorsRepository as any,
|
||||
defaults.teamsRepository as any,
|
||||
defaults.monitorStatsRepository as any,
|
||||
defaults.checksRepository as any,
|
||||
defaults.incidentsRepository as any,
|
||||
defaults.geoChecksService as any,
|
||||
defaults.geoChecksRepository as any
|
||||
defaults.geoChecksRepository as any,
|
||||
defaults.reactorDispatcher as any,
|
||||
defaults.checkPipeline as any,
|
||||
defaults.geoCheckPipeline as any
|
||||
);
|
||||
return { helper, defaults, maintenanceWindowsRepository };
|
||||
return { helper, defaults };
|
||||
};
|
||||
|
||||
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
@@ -94,7 +90,7 @@ const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("QueueHelper", () => {
|
||||
describe("WorkerHelper", () => {
|
||||
describe("serviceName", () => {
|
||||
it("returns JobQueueHelper", () => {
|
||||
const { helper } = createHelper();
|
||||
@@ -102,278 +98,63 @@ describe("QueueHelper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── getHeartbeatJob ──────────────────────────────────────────────────────
|
||||
// ── getHeartbeatJob (glue) ────────────────────────────────────────────────
|
||||
|
||||
describe("getHeartbeatJob", () => {
|
||||
it("throws when monitor id is missing", async () => {
|
||||
const { helper } = createHelper();
|
||||
const job = helper.getHeartbeatJob();
|
||||
await expect(job({} as Monitor)).rejects.toThrow("No monitor id");
|
||||
it("runs the pipeline and dispatches the evaluation to reactors", async () => {
|
||||
const evaluation = { monitor: { id: "m1" }, decision: {} };
|
||||
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(evaluation) } });
|
||||
|
||||
await helper.getHeartbeatJob()(makeMonitor());
|
||||
|
||||
expect(defaults.checkPipeline.run).toHaveBeenCalled();
|
||||
expect(defaults.reactorDispatcher.dispatch).toHaveBeenCalledWith(evaluation);
|
||||
});
|
||||
|
||||
it("skips execution and sets maintenance status when in maintenance window", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
|
||||
const job = helper.getHeartbeatJob();
|
||||
it("does not dispatch when the pipeline returns null (skipped)", async () => {
|
||||
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockResolvedValue(null) } });
|
||||
|
||||
await job(makeMonitor({ status: "up" }));
|
||||
await helper.getHeartbeatJob()(makeMonitor());
|
||||
|
||||
expect(defaults.networkService.requestStatus).not.toHaveBeenCalled();
|
||||
expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" });
|
||||
expect(defaults.reactorDispatcher.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips monitor status update when already in maintenance", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
|
||||
const job = helper.getHeartbeatJob();
|
||||
it("logs and rethrows when the pipeline throws", async () => {
|
||||
const { helper, defaults } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue(new Error("boom")) } });
|
||||
|
||||
await job(makeMonitor({ status: "maintenance" }));
|
||||
|
||||
expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled();
|
||||
await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toThrow("boom");
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "boom" }));
|
||||
});
|
||||
|
||||
it("throws when network response is null", async () => {
|
||||
const { helper } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
it("rethrows non-Error failures", async () => {
|
||||
const { helper } = createHelper({ checkPipeline: { run: jest.fn().mockRejectedValue("unexpected") } });
|
||||
|
||||
await expect(job(makeMonitor())).rejects.toThrow("No network response");
|
||||
});
|
||||
|
||||
it("returns early and warns when buildCheck returns null", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
checkService: { buildCheck: jest.fn().mockReturnValue(null), deleteOlderThan: jest.fn() },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No check could be built") }));
|
||||
expect(defaults.statusService.updateMonitorStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes full pipeline: network → check → status → incident", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
|
||||
expect(defaults.buffer.addToBuffer).toHaveBeenCalled();
|
||||
expect(defaults.statusService.updateMonitorStatus).toHaveBeenCalled();
|
||||
expect(defaults.incidentService.handleIncident).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends notifications when decision says shouldSendNotification", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
|
||||
expect(defaults.notificationsService.handleNotifications).toHaveBeenCalledWith(
|
||||
statusResult.monitor,
|
||||
networkResponse,
|
||||
expect.objectContaining({ shouldSendNotification: true, shouldCreateIncident: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("logs error when notifications fail (fire-and-forget)", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
|
||||
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue(new Error("smtp down")) };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
|
||||
notificationsService,
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
|
||||
// Wait for the fire-and-forget catch to execute
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("smtp down") }));
|
||||
});
|
||||
|
||||
it("logs error when notifications fail with non-Error (fire-and-forget)", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: false, code: 500, message: "Error" };
|
||||
const statusResult = { monitor: { id: "m1", status: "down" }, statusChanged: true, prevStatus: "up", code: 500 };
|
||||
const notificationsService = { handleNotifications: jest.fn().mockRejectedValue("string error") };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
|
||||
notificationsService,
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
|
||||
});
|
||||
|
||||
it("logs warning when incident handling fails (fire-and-forget)", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
|
||||
const incidentService = { handleIncident: jest.fn().mockRejectedValue(new Error("db error")) };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
incidentService,
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db error") }));
|
||||
});
|
||||
|
||||
it("logs warning when incident handling fails with non-Error", async () => {
|
||||
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
|
||||
const incidentService = { handleIncident: jest.fn().mockRejectedValue(42) };
|
||||
const { helper, defaults } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
incidentService,
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await job(makeMonitor());
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Unknown error") }));
|
||||
});
|
||||
|
||||
it("logs and rethrows on unexpected error with non-Error", async () => {
|
||||
const { helper } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockRejectedValue("unexpected") },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
|
||||
await expect(job(makeMonitor())).rejects.toBe("unexpected");
|
||||
await expect(helper.getHeartbeatJob()(makeMonitor())).rejects.toBe("unexpected");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getHeartbeatGeoJob ───────────────────────────────────────────────────
|
||||
// ── getHeartbeatGeoJob (glue) ─────────────────────────────────────────────
|
||||
|
||||
describe("getHeartbeatGeoJob", () => {
|
||||
it("throws nothing when monitor id is missing (logs error)", async () => {
|
||||
it("runs the geo pipeline", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job({} as Monitor);
|
||||
await helper.getHeartbeatGeoJob()(makeMonitor());
|
||||
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" }));
|
||||
expect(defaults.geoCheckPipeline.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when geoCheckEnabled is false", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: false }));
|
||||
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when monitor type does not support geo checks", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "hardware", geoCheckLocations: ["us-east"] }));
|
||||
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("does not support geo checks") })
|
||||
);
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns early when geoCheckLocations is empty", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: [] }));
|
||||
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
|
||||
});
|
||||
|
||||
it("returns early when geoCheckLocations is undefined", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: undefined as any }));
|
||||
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check locations") }));
|
||||
});
|
||||
|
||||
it("skips when in maintenance window", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true);
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
|
||||
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("maintenance window") }));
|
||||
expect(defaults.geoChecksService.buildGeoCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when buildGeoCheck returns null", async () => {
|
||||
const { helper, defaults } = createHelper();
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
|
||||
|
||||
expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("No geo check could be built") }));
|
||||
});
|
||||
|
||||
it("adds geo check to buffer on success", async () => {
|
||||
const geoCheck = { id: "gc-1", monitorId: "m1" };
|
||||
const { helper, defaults } = createHelper({
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockResolvedValue(geoCheck) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
|
||||
|
||||
expect(defaults.buffer.addGeoCheckToBuffer).toHaveBeenCalledWith(geoCheck);
|
||||
expect(defaults.logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Geo check job executed") }));
|
||||
});
|
||||
|
||||
it("logs error on unexpected failure without rethrowing", async () => {
|
||||
const { helper, defaults } = createHelper({
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue(new Error("api timeout")) },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
|
||||
it("catches and logs a pipeline error without rethrowing", async () => {
|
||||
const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue(new Error("api timeout")) } });
|
||||
|
||||
await expect(helper.getHeartbeatGeoJob()(makeMonitor())).resolves.toBeUndefined();
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "api timeout" }));
|
||||
});
|
||||
|
||||
it("logs 'Unknown error' for non-Error exceptions", async () => {
|
||||
const { helper, defaults } = createHelper({
|
||||
geoChecksService: { buildGeoCheck: jest.fn().mockRejectedValue("string error") },
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatGeoJob();
|
||||
it("logs 'Unknown error' for non-Error failures", async () => {
|
||||
const { helper, defaults } = createHelper({ geoCheckPipeline: { run: jest.fn().mockRejectedValue("string error") } });
|
||||
|
||||
await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] }));
|
||||
await helper.getHeartbeatGeoJob()(makeMonitor());
|
||||
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
@@ -400,7 +181,6 @@ describe("QueueHelper", () => {
|
||||
expect(defaults.checksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.incidentsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
expect(defaults.geoChecksRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
|
||||
// Logs info for each deleted count > 0
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("2 orphaned monitors") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("3 orphaned monitor stats") }));
|
||||
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("4 orphaned checks") }));
|
||||
@@ -413,7 +193,6 @@ describe("QueueHelper", () => {
|
||||
const job = helper.getCleanupOrphanedJob();
|
||||
await job();
|
||||
|
||||
// Only start and completion logs, no deletion logs
|
||||
const infoMessages = (defaults.logger.info as jest.Mock).mock.calls.map((c: any) => c[0].message);
|
||||
expect(infoMessages).toContain("Starting cleanup of orphaned data");
|
||||
expect(infoMessages).toContain("Cleanup of orphaned data completed");
|
||||
@@ -483,236 +262,4 @@ describe("QueueHelper", () => {
|
||||
expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
// ── isInMaintenanceWindow ────────────────────────────────────────────────
|
||||
|
||||
describe("isInMaintenanceWindow", () => {
|
||||
it("returns true when an active window spans now", async () => {
|
||||
const now = new Date();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when repeat interval advances window into current time", async () => {
|
||||
const now = Date.now();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 7200000).toISOString(),
|
||||
end: new Date(now - 6600000).toISOString(),
|
||||
repeat: 3600000,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no active windows exist", async () => {
|
||||
const { helper } = createHelper();
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when window is inactive", async () => {
|
||||
const now = new Date();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: false,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when active window is in the past without repeat", async () => {
|
||||
const now = Date.now();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 5000).toISOString(),
|
||||
end: new Date(now - 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when repeat advances past current time without match", async () => {
|
||||
const now = Date.now();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
// Window was 2 hours ago, lasts 10 minutes, repeats every hour
|
||||
// First repeat: -1h to -50min (past), second repeat: now to +10min... actually this would match
|
||||
// Use a repeat that skips over now
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 3600000 * 3 - 300000).toISOString(), // 3h5min ago
|
||||
end: new Date(now - 3600000 * 3).toISOString(), // 3h ago, 5min window
|
||||
repeat: 3600000, // 1 hour repeat
|
||||
},
|
||||
]);
|
||||
// Advances: -2h5m to -2h, -1h5m to -1h, -5m to 0 — that last one might match if now is within 5min
|
||||
// Let's use a different setup that clearly doesn't match
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now - 1800000 - 60000).toISOString(), // 31 min ago
|
||||
end: new Date(now - 1800000).toISOString(), // 30 min ago, 1 min window
|
||||
repeat: 3600000, // 1 hour repeat — next window would be in 29 min
|
||||
},
|
||||
]);
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("preserves previous true result via accumulator", async () => {
|
||||
const now = new Date();
|
||||
const { helper, maintenanceWindowsRepository } = createHelper();
|
||||
maintenanceWindowsRepository.findByMonitorId.mockResolvedValue([
|
||||
{
|
||||
active: true,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
{
|
||||
active: false,
|
||||
start: new Date(now.getTime() - 1000).toISOString(),
|
||||
end: new Date(now.getTime() + 1000).toISOString(),
|
||||
repeat: 0,
|
||||
},
|
||||
]);
|
||||
// Second window is inactive but first already matched — accumulator should preserve true
|
||||
await expect(helper.isInMaintenanceWindow("m1", "team")).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── evaluateMonitorAction (tested indirectly via getHeartbeatJob) ─────────
|
||||
|
||||
describe("evaluateMonitorAction", () => {
|
||||
const runJobAndGetDecision = async (statusResult: Record<string, unknown>) => {
|
||||
const networkResponse = { monitorId: "m1", status: true, code: 200, message: "OK" };
|
||||
const incidentService = { handleIncident: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(undefined) };
|
||||
const { helper } = createHelper({
|
||||
networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) },
|
||||
statusService: { updateMonitorStatus: jest.fn().mockResolvedValue(statusResult) },
|
||||
incidentService,
|
||||
notificationsService,
|
||||
});
|
||||
jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false);
|
||||
const job = helper.getHeartbeatJob();
|
||||
await job(makeMonitor());
|
||||
return { incidentService, notificationsService };
|
||||
};
|
||||
|
||||
it("does nothing when statusChanged is false", async () => {
|
||||
const { incidentService, notificationsService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "up" },
|
||||
statusChanged: false,
|
||||
prevStatus: "up",
|
||||
code: 200,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor goes down", async () => {
|
||||
const { incidentService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "down" },
|
||||
statusChanged: true,
|
||||
prevStatus: "up",
|
||||
code: 500,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
shouldCreateIncident: true,
|
||||
shouldSendNotification: true,
|
||||
incidentReason: "status_down",
|
||||
notificationReason: "status_change",
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("creates incident and notifies when monitor is breached", async () => {
|
||||
const { incidentService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "breached" },
|
||||
statusChanged: true,
|
||||
prevStatus: "up",
|
||||
code: 200,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
shouldCreateIncident: true,
|
||||
incidentReason: "threshold_breach",
|
||||
notificationReason: "threshold_breach",
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves incident when monitor recovers from down", async () => {
|
||||
const { incidentService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "up" },
|
||||
statusChanged: true,
|
||||
prevStatus: "down",
|
||||
code: 200,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ shouldResolveIncident: true, shouldSendNotification: true }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves incident when monitor recovers from breached", async () => {
|
||||
const { incidentService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "up" },
|
||||
statusChanged: true,
|
||||
prevStatus: "breached",
|
||||
code: 200,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ shouldResolveIncident: true }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create or resolve for unhandled status transitions", async () => {
|
||||
const { incidentService } = await runJobAndGetDecision({
|
||||
monitor: { id: "m1", status: "paused" },
|
||||
statusChanged: true,
|
||||
prevStatus: "up",
|
||||
code: 200,
|
||||
});
|
||||
expect(incidentService.handleIncident).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({ shouldCreateIncident: false, shouldResolveIncident: false, shouldSendNotification: false }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user