Files
Checkmate/server/test/unit/services/incidentService.test.ts

455 lines
20 KiB
TypeScript

import { describe, expect, it, jest, beforeEach } from "@jest/globals";
import { IncidentService } from "../../../src/domain/incidents/incident.service.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
import type { IIncidentsRepository } from "../../../src/domain/incidents/incident.repository.interface.ts";
import type { IMonitorsRepository } from "../../../src/domain/monitors/monitor.repository.interface.ts";
import type { IUsersRepository } from "../../../src/domain/users/user.repository.interface.ts";
import type { INotificationMessageBuilder } from "../../../src/domain/notifications/notification.message-builder.ts";
import type { Monitor } from "../../../src/domain/monitors/monitor.type.ts";
import type { Incident } from "../../../src/domain/incidents/incident.type.ts";
import type { MonitorActionDecision } from "../../../src/worker/worker.interface.ts";
import type { Check } from "../../../src/domain/checks/check.type.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
const createIncidentsRepo = () =>
({
findActiveByMonitorId: jest.fn(),
findActiveByIncidentId: jest.fn(),
create: jest.fn(),
updateById: jest.fn(),
findById: jest.fn(),
findByTeamId: jest.fn(),
countByTeamId: jest.fn(),
findSummaryByTeamId: jest.fn(),
}) as unknown as jest.Mocked<IIncidentsRepository>;
const createMonitorsRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IMonitorsRepository>;
const createUsersRepo = () =>
({
findById: jest.fn(),
}) as unknown as jest.Mocked<IUsersRepository>;
const createMessageBuilder = () =>
({
buildThresholdBreachMessage: jest.fn(),
}) as unknown as jest.Mocked<INotificationMessageBuilder>;
const createService = (overrides?: {
logger?: ReturnType<typeof createMockLogger>;
incidentsRepository?: ReturnType<typeof createIncidentsRepo>;
monitorsRepository?: ReturnType<typeof createMonitorsRepo>;
usersRepository?: ReturnType<typeof createUsersRepo>;
notificationMessageBuilder?: ReturnType<typeof createMessageBuilder>;
}) => {
const logger = overrides?.logger ?? createMockLogger();
const incidentsRepository = overrides?.incidentsRepository ?? createIncidentsRepo();
const monitorsRepository = overrides?.monitorsRepository ?? createMonitorsRepo();
const usersRepository = overrides?.usersRepository ?? createUsersRepo();
const notificationMessageBuilder = overrides?.notificationMessageBuilder ?? createMessageBuilder();
const service = new IncidentService(logger as any, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
return { service, logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder };
};
const makeMonitor = (overrides?: Partial<Monitor>): Monitor =>
({
id: "mon-1",
teamId: "team-1",
name: "Test Monitor",
type: "http",
url: "https://example.com",
status: "down",
...overrides,
}) as Monitor;
const makeIncident = (overrides?: Partial<Incident>): Incident =>
({
id: "inc-1",
monitorId: "mon-1",
teamId: "team-1",
startTime: "1700000000000",
status: true,
statusCode: 500,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Incident;
const makeCheck = (overrides?: Partial<Check>): Check =>
({
id: "check-1",
metadata: { monitorId: "mon-1", teamId: "team-1", type: "http" },
status: true,
statusCode: 200,
responseTime: 100,
message: "OK",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
}) as Check;
const makeDecision = (overrides?: Partial<MonitorActionDecision>): MonitorActionDecision => ({
transition: null,
...overrides,
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe("IncidentService", () => {
// ── handleIncident ───────────────────────────────────────────────────────
describe("handleIncident", () => {
it("returns null when neither create nor resolve is requested", async () => {
const { service } = createService();
const result = await service.handleIncident(makeMonitor(), makeDecision(), makeCheck());
expect(result).toBeNull();
});
it("returns the existing active incident on a down transition when one is already open", async () => {
const existing = makeIncident();
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(existing);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
expect(result).toBe(existing);
expect(incidentsRepository.create).not.toHaveBeenCalled();
});
it("creates a new incident on a down transition when none is open", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_down" }), makeCheck({ statusCode: 500 }));
expect(result).toBe(created);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
monitorId: "mon-1",
teamId: "team-1",
status: true,
statusCode: 500,
})
);
expect(notificationMessageBuilder.buildThresholdBreachMessage).not.toHaveBeenCalled();
});
it("uses status code 9999 and builds message for threshold_breach incidents", async () => {
const created = makeIncident();
const { service, incidentsRepository, notificationMessageBuilder } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
(incidentsRepository.create as jest.Mock).mockResolvedValue(created);
(notificationMessageBuilder.buildThresholdBreachMessage as jest.Mock).mockReturnValue("CPU: 95% (threshold: 80%)");
const monitor = makeMonitor({ type: "hardware" });
const check = makeCheck({ metadata: { monitorId: "mon-1", teamId: "team-1", type: "hardware" }, cpu: { usage_percent: 0.95 } });
const thresholdBreaches = { cpu: true, memory: false, disk: false, temp: false };
const decision = makeDecision({ transition: "threshold_breach", thresholdBreaches });
await service.handleIncident(monitor, decision, check);
expect(notificationMessageBuilder.buildThresholdBreachMessage).toHaveBeenCalledWith(monitor, check, thresholdBreaches);
expect(incidentsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: 9999,
message: "CPU: 95% (threshold: 80%)",
})
);
});
it("resolves the active incident on an up transition", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_up" }), makeCheck());
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("resolves the active incident on a threshold_resolved transition", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false, endTime: "123", resolutionType: "automatic" });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "threshold_resolved" }), makeCheck());
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({ status: false, resolutionType: "automatic" })
);
});
it("returns null on an up transition when no incident is open", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByMonitorId as jest.Mock).mockResolvedValue(null);
const result = await service.handleIncident(makeMonitor(), makeDecision({ transition: "status_up" }), makeCheck());
expect(result).toBeNull();
});
});
// ── resolveIncident ──────────────────────────────────────────────────────
describe("resolveIncident", () => {
it("resolves incident with manual resolution", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
const result = await service.resolveIncident("inc-1", "user-1", "team-1", "Fixed it", "user@test.com");
expect(result).toBe(resolved);
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolutionType: "manual",
status: false,
resolvedBy: "user-1",
resolvedByEmail: "user@test.com",
comment: "Fixed it",
})
);
});
it("sets resolvedByEmail and comment to null when not provided", async () => {
const active = makeIncident();
const resolved = makeIncident({ status: false });
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(active);
(incidentsRepository.updateById as jest.Mock).mockResolvedValue(resolved);
await service.resolveIncident("inc-1", "user-1", "team-1");
expect(incidentsRepository.updateById).toHaveBeenCalledWith(
"inc-1",
"team-1",
expect.objectContaining({
resolvedByEmail: null,
comment: null,
})
);
});
it("throws when incidentId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("", "user-1", "team-1")).rejects.toThrow("No incident ID in request");
});
it("throws when userId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "", "team-1")).rejects.toThrow("No user ID in request");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.resolveIncident("inc-1", "user-1", "")).rejects.toThrow("No team ID in request");
});
it("throws when incident is not found", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(null);
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident not found");
});
it("throws when incident is already resolved", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockResolvedValue(makeIncident({ status: false }));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("Incident is already resolved");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "resolveIncident", message: "db error" })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findActiveByIncidentId as jest.Mock).mockRejectedValue("string error");
await expect(service.resolveIncident("inc-1", "user-1", "team-1")).rejects.toBe("string error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentsByTeam ───────────────────────────────────────────────────
describe("getIncidentsByTeam", () => {
it("returns incidents and count", async () => {
const incidents = [makeIncident()];
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue(incidents);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(1);
const result = await service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined);
expect(result).toEqual({ incidents, count: 1 });
});
it("defaults page to 0 and rowsPerPage to 20 when nullish", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "desc", "day", null as any, null as any, undefined, undefined, undefined);
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.anything(), 0, 20, "desc", undefined, undefined, undefined);
});
it("passes filter parameters through to repository", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockResolvedValue([]);
(incidentsRepository.countByTeamId as jest.Mock).mockResolvedValue(0);
await service.getIncidentsByTeam("team-1", "asc", "week", 2, 10, true, "mon-1", "manual");
expect(incidentsRepository.findByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), 2, 10, "asc", true, "mon-1", "manual");
expect(incidentsRepository.countByTeamId).toHaveBeenCalledWith("team-1", expect.any(Date), true, "mon-1", "manual");
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentsByTeam("", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentsByTeam" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findByTeamId as jest.Mock).mockRejectedValue(42);
await expect(service.getIncidentsByTeam("team-1", "desc", "day", 0, 20, undefined, undefined, undefined)).rejects.toBe(42);
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentSummary ───────────────────────────────────────────────────
describe("getIncidentSummary", () => {
it("returns summary from repository", async () => {
const summary = { recentIncidents: [], totalIncidents: 0 };
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue(summary);
const result = await service.getIncidentSummary("team-1", 5);
expect(result).toBe(summary);
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 5);
});
it("defaults limit to 10 when not provided", async () => {
const { service, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockResolvedValue({});
await service.getIncidentSummary("team-1");
expect(incidentsRepository.findSummaryByTeamId).toHaveBeenCalledWith("team-1", 10);
});
it("throws when teamId is missing", async () => {
const { service } = createService();
await expect(service.getIncidentSummary("")).rejects.toThrow("No team ID in request");
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue(new Error("db error"));
await expect(service.getIncidentSummary("team-1")).rejects.toThrow("db error");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ service: "incidentService", method: "getIncidentSummary" }));
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findSummaryByTeamId as jest.Mock).mockRejectedValue("boom");
await expect(service.getIncidentSummary("team-1")).rejects.toBe("boom");
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
// ── getIncidentById ──────────────────────────────────────────────────────
describe("getIncidentById", () => {
it("returns incident, monitor, and user when resolvedBy exists", async () => {
const incident = makeIncident({ resolvedBy: "user-1" });
const monitor = makeMonitor();
const user = { id: "user-1", email: "user@test.com" };
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
(usersRepository.findById as jest.Mock).mockResolvedValue(user);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result).toEqual({ incident, monitor, user });
expect(usersRepository.findById).toHaveBeenCalledWith("user-1");
});
it("returns user as null when resolvedBy is not set", async () => {
const incident = makeIncident({ resolvedBy: undefined });
const monitor = makeMonitor();
const { service, incidentsRepository, monitorsRepository, usersRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockResolvedValue(incident);
(monitorsRepository.findById as jest.Mock).mockResolvedValue(monitor);
const result = await service.getIncidentById("inc-1", "team-1");
expect(result.user).toBeNull();
expect(usersRepository.findById).not.toHaveBeenCalled();
});
it("logs error and rethrows on unexpected failure", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(new Error("not found"));
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toThrow("not found");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({ service: "incidentService", method: "getIncidentById", details: { incidentId: "inc-1" } })
);
});
it("logs 'Unknown error' for non-Error exceptions", async () => {
const { service, logger, incidentsRepository } = createService();
(incidentsRepository.findById as jest.Mock).mockRejectedValue(null);
await expect(service.getIncidentById("inc-1", "team-1")).rejects.toBeNull();
expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined }));
});
});
});