Files
Checkmate/server/test/helpers/heartbeatTestHarness.ts

171 lines
6.3 KiB
TypeScript

import { jest } from "@jest/globals";
import { MonitorStatusPolicy } from "../../src/worker/worker.monitor-status-policy.ts";
import { CheckProducer } from "../../src/worker/worker.check-producer.ts";
import { CheckEvaluator } from "../../src/worker/worker.check-evaluator.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";
import { InMemoryIncidentsRepository } from "./InMemoryIncidentsRepository.ts";
import { createMockLogger } from "./createMockLogger.ts";
import type { Monitor } from "../../src/domain/monitors/monitor.types.ts";
import type { MonitorStatusResponse } from "../../src/types/network.ts";
import type { Check } from "../../src/domain/checks/check.type.ts";
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
let checkCounter = 0;
export const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "up",
// Pre-fill window so threshold evaluation runs immediately.
// Without this, the warm-up path sets monitor.status directly
// from each check, bypassing the threshold logic.
statusWindow: [true, true, true, true, true],
statusWindowSize: 5,
statusWindowThreshold: 60,
recentChecks: [],
notifications: [],
...overrides,
}) as Monitor;
export const makeStatusResponse = (status: boolean, code: number): MonitorStatusResponse => ({
monitorId: "mon-1",
teamId: "team-1",
type: "http",
status,
code,
message: status ? "OK" : "Service Unavailable",
responseTime: status ? 150 : 0,
});
export const makeCheck = (status: boolean, code: number): Check => {
const now = new Date().toISOString();
return {
id: `check-${++checkCounter}`,
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status,
statusCode: code,
responseTime: status ? 150 : 0,
message: status ? "OK" : "Service Unavailable",
createdAt: now,
updatedAt: now,
};
};
const createStubMonitorStatsRepo = () => ({
findByMonitorId: jest.fn().mockRejectedValue(new Error("no stats")),
create: jest.fn().mockResolvedValue({}),
updateByMonitorId: jest.fn().mockResolvedValue({}),
deleteByMonitorId: jest.fn(),
deleteByMonitorIds: jest.fn(),
deleteByMonitorIdsNotIn: jest.fn(),
});
export interface HeartbeatTestHarness {
monitorsRepo: InMemoryMonitorsRepository;
incidentsRepo: InMemoryIncidentsRepository;
statusService: StatusService;
incidentService: IncidentService;
notificationsService: { handleNotifications: jest.Mock };
networkService: { requestStatus: jest.Mock };
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
messageBuilder: { extractThresholdBreaches: jest.Mock };
heartbeatJob: (monitor: Monitor) => Promise<void>;
setNextResponse: (status: boolean, code: number) => void;
setNextResponseFull: (response: MonitorStatusResponse) => void;
}
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
checkCounter = 0;
const monitorsRepo = new InMemoryMonitorsRepository();
const incidentsRepo = new InMemoryIncidentsRepository();
const logger = createMockLogger() as any;
const bufferStub = { addToBuffer: jest.fn(), addGeoCheckToBuffer: jest.fn(), scheduleNextFlush: jest.fn() };
const statusService = new StatusService(logger, monitorsRepo as any, createStubMonitorStatsRepo() as any);
const messageBuilder = { extractThresholdBreaches: jest.fn().mockReturnValue([]) };
const incidentService = new IncidentService(logger, incidentsRepo, monitorsRepo as any, { findById: jest.fn() } as any, messageBuilder as any);
const notificationsService = { handleNotifications: jest.fn().mockResolvedValue(true) };
let nextResponse: MonitorStatusResponse | null = null;
let nextStatus = true;
let nextCode = 200;
const networkService = {
requestStatus: jest.fn().mockImplementation(() => {
if (nextResponse) {
return Promise.resolve(nextResponse);
}
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
}),
};
const checkService = {
toCheck: jest.fn().mockImplementation((response: MonitorStatusResponse) => {
return makeCheck(response.status, response.code);
}),
};
const setNextResponse = (status: boolean, code: number) => {
nextResponse = null;
nextStatus = status;
nextCode = code;
};
const setNextResponseFull = (response: MonitorStatusResponse) => {
nextResponse = response;
};
const maintenanceWindowsRepo = { findByMonitorId: jest.fn().mockResolvedValue([]) };
const checkProducer = new CheckProducer(
monitorsRepo as any,
maintenanceWindowsRepo as any,
checkService as any,
networkService as any,
bufferStub as any,
logger
);
const checkEvaluator = new CheckEvaluator(statusService as any, new MonitorStatusPolicy());
const notificationReactor = new NotificationReactor(notificationsService as any);
const incidentReactor = new IncidentReactor(incidentService as any);
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
// Mirror the production check→evaluate flow: read the monitor fresh each cycle (so the
// accumulated statusWindow is visible), produce a check, then evaluate against that monitor.
const heartbeatJob = async (seedMonitor: Monitor) => {
const monitor = await monitorsRepo.findByIdLean(seedMonitor.id);
if (!monitor) return;
const result = await checkProducer.produce(monitor);
if (!result) return; // skipped (e.g. maintenance window)
const evaluation = await checkEvaluator.evaluate(result.status, result.check, monitor);
await reactorDispatcher.dispatch(evaluation);
};
return {
monitorsRepo,
incidentsRepo,
statusService,
incidentService,
notificationsService,
networkService,
bufferStub,
maintenanceWindowsRepo,
messageBuilder,
heartbeatJob,
setNextResponse,
setNextResponseFull,
};
}