177 lines
7.5 KiB
TypeScript
177 lines
7.5 KiB
TypeScript
import { jest } from "@jest/globals";
|
|
import { WorkerPipeline } from "../../src/worker/worker.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 { CheckService } from "../../src/domain/checks/check.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.type.ts";
|
|
import type { Check } from "../../src/domain/checks/check.type.ts";
|
|
import type { MonitorActionDecision } from "../../src/worker/worker.interface.ts";
|
|
import type { MonitorStatusResponse } from "../../src/types/network.ts";
|
|
import type { MaintenanceWindow } from "../../src/domain/maintenance-windows/maintenance-window.type.ts";
|
|
import type { EgressStatus } from "../../src/domain/egress/egress.type.ts";
|
|
|
|
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,
|
|
});
|
|
|
|
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 };
|
|
providerRegistry: { probe: jest.Mock };
|
|
bufferStub: { addToBuffer: jest.Mock; addGeoCheckToBuffer: jest.Mock; scheduleNextFlush: jest.Mock };
|
|
maintenanceWindowsRepo: { findByMonitorId: jest.Mock };
|
|
messageBuilder: { buildThresholdBreachMessage: jest.Mock };
|
|
egressService: { assessAfterFailure: jest.Mock };
|
|
heartbeatJob: (monitor: Monitor) => Promise<void>;
|
|
setNextResponse: (status: boolean, code: number) => void;
|
|
setNextResponseFull: (response: MonitorStatusResponse) => void;
|
|
setEgressStatus: (status: EgressStatus | null) => void;
|
|
}
|
|
|
|
export function createHeartbeatTestHarness(): HeartbeatTestHarness {
|
|
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 = { buildThresholdBreachMessage: 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 providerRegistry = {
|
|
probe: jest.fn().mockImplementation(() => {
|
|
if (nextResponse) {
|
|
return Promise.resolve(nextResponse);
|
|
}
|
|
return Promise.resolve(makeStatusResponse(nextStatus, nextCode));
|
|
}),
|
|
};
|
|
// The real mapper, so the stored check carries the hardware/docker payload the evaluator reads.
|
|
// Only toCheck is reached; the repositories are never touched.
|
|
const checkService = new CheckService(monitorsRepo as any, logger, {} as any);
|
|
|
|
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 proxyResolver = { resolve: jest.fn().mockResolvedValue(undefined) };
|
|
const dockerLogsService = { buildDockerLogs: jest.fn().mockResolvedValue([]) };
|
|
// Null mirrors the egress check being disabled, so the existing heartbeat suites run unchanged.
|
|
let nextEgressStatus: EgressStatus | null = null;
|
|
const egressService = { assessAfterFailure: jest.fn().mockImplementation(() => Promise.resolve(nextEgressStatus)) };
|
|
const setEgressStatus = (status: EgressStatus | null) => {
|
|
nextEgressStatus = status;
|
|
};
|
|
|
|
// The real NotificationsService ignores decisions with no transition; the stub is reached only for the ones it
|
|
// would act on, so tests can assert on handleNotifications calls as "notifications sent".
|
|
const guardedNotificationsService = {
|
|
handleNotifications: (monitor: Monitor, check: Check, decision: MonitorActionDecision) =>
|
|
decision.transition !== null ? notificationsService.handleNotifications(monitor, check, decision) : Promise.resolve(false),
|
|
};
|
|
const notificationReactor = new NotificationReactor(guardedNotificationsService as any);
|
|
const incidentReactor = new IncidentReactor(incidentService as any);
|
|
const reactorDispatcher = new ReactorDispatcher(logger, [notificationReactor, incidentReactor]);
|
|
|
|
// The harness drives stages 1 and 3 directly, so the stage 2 repositories are never reached.
|
|
const pipeline = new WorkerPipeline({
|
|
logger,
|
|
monitorsRepository: monitorsRepo as any,
|
|
maintenanceWindowsRepository: maintenanceWindowsRepo as any,
|
|
checksRepository: { findUnevaluatedByMonitorId: jest.fn() } as any,
|
|
jobsRepository: { upsertEvaluate: jest.fn(), pullEvaluated: jest.fn() } as any,
|
|
checkService: checkService as any,
|
|
providerRegistry: providerRegistry as any,
|
|
proxyResolver: proxyResolver as any,
|
|
bufferService: bufferStub as any,
|
|
dockerLogsService: dockerLogsService as any,
|
|
egressService: egressService as any,
|
|
statusService,
|
|
dispatcher: reactorDispatcher,
|
|
});
|
|
|
|
// 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 pipeline.produce(monitor);
|
|
if (!result) return; // skipped (e.g. maintenance window)
|
|
const evaluation = await pipeline.evaluateCheck(result.check, monitor);
|
|
await reactorDispatcher.dispatch(evaluation);
|
|
};
|
|
|
|
return {
|
|
monitorsRepo,
|
|
incidentsRepo,
|
|
statusService,
|
|
incidentService,
|
|
notificationsService,
|
|
providerRegistry,
|
|
bufferStub,
|
|
maintenanceWindowsRepo,
|
|
messageBuilder,
|
|
egressService,
|
|
heartbeatJob,
|
|
setNextResponse,
|
|
setNextResponseFull,
|
|
setEgressStatus,
|
|
};
|
|
}
|