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; const createMonitorsRepo = () => ({ findById: jest.fn(), }) as unknown as jest.Mocked; const createUsersRepo = () => ({ findById: jest.fn(), }) as unknown as jest.Mocked; const createMessageBuilder = () => ({ buildThresholdBreachMessage: jest.fn(), }) as unknown as jest.Mocked; const createService = (overrides?: { logger?: ReturnType; incidentsRepository?: ReturnType; monitorsRepository?: ReturnType; usersRepository?: ReturnType; notificationMessageBuilder?: ReturnType; }) => { 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 => ({ 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 => ({ 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 => ({ 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 => ({ 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 })); }); }); });