From db378120be387b24ccbc6dc91b2ebf31cb7d0865 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Wed, 8 Apr 2026 12:03:26 -0700 Subject: [PATCH] add queue tests --- .../SuperSimpleQueue/SuperSimpleQueue.ts | 10 +- .../unit/services/superSimpleQueue.test.ts | 564 ++++++++++++++++ .../services/superSimpleQueueHelper.test.ts | 621 +++++++++++++++++- 3 files changed, 1162 insertions(+), 33 deletions(-) create mode 100644 server/test/unit/services/superSimpleQueue.test.ts diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts index 542e8e38e..8fe98f4c1 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts @@ -306,8 +306,8 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { if (failCount > 0) { acc.jobsWithFailures.push({ monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, + monitorUrl: job.data?.url || null, + monitorType: job.data?.type || null, failedAt: job.lastFailedAt ?? null, failCount, failReason: job.lastFailReason ?? null, @@ -339,9 +339,9 @@ export class SuperSimpleQueue implements ISuperSimpleQueue { return jobs.map((job) => { return { monitorId: job.id, - monitorUrl: job?.data?.url || null, - monitorType: job?.data?.type || null, - monitorInterval: job?.data?.interval || null, + monitorUrl: job.data?.url || null, + monitorType: job.data?.type || null, + monitorInterval: job.data?.interval || null, active: job.active, lockedAt: job.lockedAt ?? null, runCount: job.runCount ?? 0, diff --git a/server/test/unit/services/superSimpleQueue.test.ts b/server/test/unit/services/superSimpleQueue.test.ts new file mode 100644 index 000000000..574ef6dd0 --- /dev/null +++ b/server/test/unit/services/superSimpleQueue.test.ts @@ -0,0 +1,564 @@ +import { describe, expect, it, jest, beforeEach } from "@jest/globals"; +import { createMockLogger } from "../../helpers/createMockLogger.ts"; +import type { Monitor } from "../../../src/types/monitor.ts"; + +// ── Mock Scheduler ─────────────────────────────────────────────────────────── + +const createScheduler = () => { + const listeners: Record = {}; + return { + on: jest.fn((event: string, cb: Function) => { + listeners[event] = listeners[event] || []; + listeners[event].push(cb); + }), + emit: (event: string, ...args: unknown[]) => { + (listeners[event] || []).forEach((cb) => cb(...args)); + }, + start: jest.fn(), + stop: jest.fn().mockResolvedValue(true), + addTemplate: jest.fn(), + addJob: jest.fn(), + removeJob: jest.fn(), + getJob: jest.fn().mockResolvedValue(null), + getJobs: jest.fn().mockResolvedValue([]), + pauseJob: jest.fn().mockResolvedValue(true), + resumeJob: jest.fn().mockResolvedValue(true), + updateJob: jest.fn(), + flushJobs: jest.fn().mockResolvedValue(true), + }; +}; + +const mockSchedulerInstance = createScheduler(); +const MockScheduler = jest.fn().mockReturnValue(mockSchedulerInstance); + +jest.unstable_mockModule("super-simple-scheduler", () => ({ + default: MockScheduler, +})); + +const { SuperSimpleQueue } = await import("../../../src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.ts"); + +const createQueueHelper = () => ({ + getHeartbeatJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getHeartbeatGeoJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupOrphanedJob: jest.fn().mockReturnValue(() => Promise.resolve()), + getCleanupRetentionJob: jest.fn().mockReturnValue(() => Promise.resolve()), +}); + +const createMonitorsRepo = () => ({ + findAll: jest.fn().mockResolvedValue([]), +}); + +const createQueue = (overrides?: Record) => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + const scheduler = createScheduler(); + + const defaults = { logger, helper, monitorsRepository, scheduler, ...overrides }; + + const queue = new SuperSimpleQueue(defaults.logger as any, defaults.helper as any, defaults.monitorsRepository as any, defaults.scheduler as any); + return { queue, ...defaults }; +}; + +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "mon-1", + teamId: "team-1", + type: "http", + interval: 60000, + isActive: true, + geoCheckEnabled: false, + geoCheckInterval: 300000, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("SuperSimpleQueue", () => { + describe("serviceName", () => { + it("returns JobQueue", () => { + const { queue } = createQueue(); + expect(queue.serviceName).toBe("JobQueue"); + }); + }); + + // ── init ───────────────────────────────────────────────────────────────── + + describe("init", () => { + it("registers listeners, starts scheduler, adds templates and system jobs", async () => { + const { queue, scheduler, helper } = createQueue(); + + const result = await queue.init(); + + expect(result).toBe(true); + expect(scheduler.start).toHaveBeenCalled(); + expect(scheduler.addTemplate).toHaveBeenCalledWith("monitor-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("geo-check-job", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-orphaned", expect.any(Function)); + expect(scheduler.addTemplate).toHaveBeenCalledWith("cleanup-retention-job", expect.any(Function)); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-retention" })); + }); + + it("schedules existing monitors with staggered offsets", async () => { + jest.useFakeTimers(); + const monitors = [makeMonitor({ id: "m1" }), makeMonitor({ id: "m2" })]; + const { queue, scheduler, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(monitors); + + await queue.init(); + jest.runAllTimers(); + + // Each monitor gets addJob called (once for the monitor itself via addJob method) + // The init schedules via setTimeout → addJob, plus the two system jobs + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m1", template: "monitor-job" })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "m2", template: "monitor-job" })); + jest.useRealTimers(); + }); + + it("returns true when findAll returns null", async () => { + const { queue, monitorsRepository, scheduler } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockResolvedValue(null); + + const result = await queue.init(); + + expect(result).toBe(true); + // System jobs should not be added when monitors is null (early return) + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "cleanup-orphaned" })); + }); + + it("returns false and logs error on failure", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue(new Error("db down")); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("db down") })); + }); + + it("logs error with String(error) for non-Error exceptions", async () => { + const { queue, logger, monitorsRepository } = createQueue(); + (monitorsRepository.findAll as jest.Mock).mockRejectedValue("string error"); + + const result = await queue.init(); + + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("string error") })); + }); + }); + + // ── registerListeners ──────────────────────────────────────────────────── + + describe("registerListeners (via init)", () => { + it("logs on scheduler:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:start"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler started" })); + }); + + it("logs on scheduler:stop", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:stop"); + expect(logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler stopped" })); + }); + + it("logs on scheduler:error with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", new Error("boom")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: boom" })); + }); + + it("logs on scheduler:error with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("scheduler:error", "string error"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Scheduler error: string error", stack: undefined })); + }); + + it("logs on job:abort", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:abort", { id: "m1" }, "timeout"); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 aborted: timeout" })); + }); + + it("logs on job:attempt", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:attempt", { id: "m1" }, 2); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 attempt 2" })); + }); + + it("logs on job:complete", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:complete", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 completed successfully" })); + }); + + it("logs on job:exhausted with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, new Error("gave up")); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up" })); + }); + + it("logs on job:exhausted with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:exhausted", { id: "m1" }, "gave up"); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 exhausted all retries: gave up", stack: undefined })); + }); + + it("logs on job:fail with Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, new Error("oops"), 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops" })); + }); + + it("logs on job:fail with non-Error", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:fail", { id: "m1" }, "oops", 1); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 failed on attempt 1: oops", stack: undefined })); + }); + + it("logs on job:start", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.init(); + scheduler.emit("job:start", { id: "m1" }); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "m1 started" })); + }); + }); + + // ── addJob ─────────────────────────────────────────────────────────────── + + describe("addJob", () => { + it("adds monitor job to scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor()); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1", template: "monitor-job", repeat: 60000, active: true })); + }); + + it("adds geo check job when geoCheckEnabled and type supports it", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 })); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job", repeat: 300000 })); + }); + + it("skips geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); + }); + + it("skips geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + await queue.addJob("mon-1", makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.addJob).not.toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo" })); + }); + }); + + // ── deleteJob ──────────────────────────────────────────────────────────── + + describe("deleteJob", () => { + it("removes monitor job", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1"); + }); + + it("removes geo job if it exists", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not remove geo job if it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.deleteJob(makeMonitor()); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + }); + + // ── pauseJob ───────────────────────────────────────────────────────────── + + describe("pauseJob", () => { + it("pauses monitor and removes geo job if it exists", async () => { + const { queue, scheduler, logger } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.pauseJob(makeMonitor()); + + expect(scheduler.pauseJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Paused monitor mon-1" })); + }); + + it("does not remove geo job if it does not exist", async () => { + const { queue, scheduler } = createQueue(); + await queue.pauseJob(makeMonitor()); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("throws when scheduler.pauseJob returns false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.pauseJob as jest.Mock).mockResolvedValue(false); + await expect(queue.pauseJob(makeMonitor())).rejects.toThrow("Failed to pause monitor"); + }); + }); + + // ── resumeJob ──────────────────────────────────────────────────────────── + + describe("resumeJob", () => { + it("resumes monitor and geo job", async () => { + const { queue, scheduler, logger } = createQueue(); + await queue.resumeJob(makeMonitor()); + + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1"); + expect(scheduler.resumeJob).toHaveBeenCalledWith("mon-1-geo"); + expect(logger.debug).toHaveBeenCalledWith(expect.objectContaining({ message: "Resumed monitor mon-1" })); + }); + + it("throws when scheduler.resumeJob returns false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.resumeJob as jest.Mock).mockResolvedValue(false); + await expect(queue.resumeJob(makeMonitor())).rejects.toThrow("Failed to resume monitor"); + }); + }); + + // ── updateJob ──────────────────────────────────────────────────────────── + + describe("updateJob", () => { + it("updates monitor job and syncs geo job", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ interval: 120000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1", expect.objectContaining({ repeat: 120000, data: monitor })); + }); + }); + + // ── syncGeoJob (via updateJob) ─────────────────────────────────────────── + + describe("syncGeoJob (via updateJob)", () => { + it("removes existing geo job when geoCheckEnabled is false", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false, type: "http" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("removes existing geo job when type does not support geo checks", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + await queue.updateJob(makeMonitor({ geoCheckEnabled: true, type: "hardware" })); + expect(scheduler.removeJob).toHaveBeenCalledWith("mon-1-geo"); + }); + + it("does not remove when geo disabled and no existing geo job", async () => { + const { queue, scheduler } = createQueue(); + await queue.updateJob(makeMonitor({ geoCheckEnabled: false })); + expect(scheduler.removeJob).not.toHaveBeenCalledWith("mon-1-geo"); + }); + + it("updates existing geo job when geoCheckEnabled", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJob as jest.Mock).mockResolvedValue({ id: "mon-1-geo" }); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.updateJob).toHaveBeenCalledWith("mon-1-geo", expect.objectContaining({ repeat: 300000 })); + }); + + it("creates new geo job when enabled but no existing job", async () => { + const { queue, scheduler } = createQueue(); + const monitor = makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckInterval: 300000 }); + await queue.updateJob(monitor); + expect(scheduler.addJob).toHaveBeenCalledWith(expect.objectContaining({ id: "mon-1-geo", template: "geo-check-job" })); + }); + }); + + // ── shutdown ────────────────────────────────────────────────────────────── + + describe("shutdown", () => { + it("stops the scheduler", async () => { + const { queue, scheduler } = createQueue(); + await queue.shutdown(); + expect(scheduler.stop).toHaveBeenCalled(); + }); + }); + + // ── getMetrics ─────────────────────────────────────────────────────────── + + describe("getMetrics", () => { + it("returns empty metrics when no jobs", async () => { + const { queue } = createQueue(); + const metrics = await queue.getMetrics(); + expect(metrics).toEqual({ jobs: 0, activeJobs: 0, failingJobs: 0, jobsWithFailures: [], totalRuns: 0, totalFailures: 0 }); + }); + + it("aggregates metrics from jobs", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 2, lastFailedAt: 200, lastRunAt: 100, lockedAt: null, data: { url: "http://a.com", type: "http" } }, + { id: "m2", runCount: 5, failCount: 0, lastFailedAt: 0, lastRunAt: 50, lockedAt: 999, data: { url: "http://b.com", type: "ping" } }, + ]); + const metrics = await queue.getMetrics(); + + expect(metrics.jobs).toBe(2); + expect(metrics.totalRuns).toBe(15); + expect(metrics.totalFailures).toBe(2); + expect(metrics.activeJobs).toBe(1); + expect(metrics.failingJobs).toBe(1); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0]).toEqual(expect.objectContaining({ monitorId: "m1", failCount: 2, monitorUrl: "http://a.com" })); + }); + + it("handles jobs with undefined fields", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", data: null }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobs).toBe(1); + expect(metrics.totalRuns).toBe(0); + }); + + it("handles failing job with null data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 1, failCount: 1, lastFailedAt: 100, lastRunAt: 50, data: null, lastFailReason: "timeout" }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures).toHaveLength(1); + expect(metrics.jobsWithFailures[0].monitorUrl).toBeNull(); + expect(metrics.jobsWithFailures[0].monitorType).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBe("timeout"); + }); + + it("handles failing job with undefined lastFailedAt and lastFailReason", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", runCount: 1, failCount: 1, data: { url: "http://a.com", type: "http" } }]); + const metrics = await queue.getMetrics(); + expect(metrics.jobsWithFailures[0].failedAt).toBeNull(); + expect(metrics.jobsWithFailures[0].failReason).toBeNull(); + }); + + it("does not count as failing when lastFailedAt < lastRunAt", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", runCount: 10, failCount: 1, lastFailedAt: 50, lastRunAt: 100, data: { url: "a" } }, + ]); + const metrics = await queue.getMetrics(); + expect(metrics.failingJobs).toBe(0); + expect(metrics.jobsWithFailures).toHaveLength(1); + }); + }); + + // ── getJobs ────────────────────────────────────────────────────────────── + + describe("getJobs", () => { + it("maps scheduler jobs to summaries", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { + id: "m1", + active: true, + lockedAt: null, + runCount: 5, + failCount: 1, + lastFailReason: "timeout", + lastRunAt: 1000, + lastFinishedAt: 1100, + lastFailedAt: 900, + data: { url: "http://a.com", type: "http", interval: 60000 }, + }, + ]); + const jobs = await queue.getJobs(); + + expect(jobs).toHaveLength(1); + expect(jobs[0]).toEqual({ + monitorId: "m1", + monitorUrl: "http://a.com", + monitorType: "http", + monitorInterval: 60000, + active: true, + lockedAt: null, + runCount: 5, + failCount: 1, + failReason: "timeout", + lastRunAt: 1000, + lastFinishedAt: 1100, + lastRunTook: 100, + lastFailedAt: 900, + }); + }); + + it("returns null lastRunTook when job is locked", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([ + { id: "m1", active: true, lockedAt: 999, runCount: 1, data: { url: "a", type: "http", interval: 60000 } }, + ]); + const jobs = await queue.getJobs(); + expect(jobs[0].lastRunTook).toBeNull(); + }); + + it("handles jobs with missing data", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.getJobs as jest.Mock).mockResolvedValue([{ id: "m1", active: true, data: null }]); + const jobs = await queue.getJobs(); + expect(jobs[0].monitorUrl).toBeNull(); + expect(jobs[0].monitorType).toBeNull(); + expect(jobs[0].monitorInterval).toBeNull(); + }); + }); + + // ── flushQueues ────────────────────────────────────────────────────────── + + describe("flushQueues", () => { + it("stops, flushes, and reinitializes", async () => { + const { queue, scheduler } = createQueue(); + const result = await queue.flushQueues(); + expect(scheduler.stop).toHaveBeenCalled(); + expect(scheduler.flushJobs).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it("returns false when any step fails", async () => { + const { queue, scheduler } = createQueue(); + (scheduler.flushJobs as jest.Mock).mockResolvedValue(false); + const result = await queue.flushQueues(); + expect(result.success).toBe(false); + }); + }); + + // ── obliterate ─────────────────────────────────────────────────────────── + + describe("obliterate", () => { + it("logs not-implemented warning", async () => { + const { queue, logger } = createQueue(); + await queue.obliterate(); + expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "obliterate method not implemented" })); + }); + }); + + // ── static create ──────────────────────────────────────────────────────── + + describe("static create", () => { + it("creates a SuperSimpleQueue instance with a Scheduler and calls init", async () => { + const logger = createMockLogger(); + const helper = createQueueHelper(); + const monitorsRepository = createMonitorsRepo(); + + const instance = await SuperSimpleQueue.create(logger as any, helper as any, monitorsRepository as any); + + expect(MockScheduler).toHaveBeenCalled(); + expect(instance).toBeInstanceOf(SuperSimpleQueue); + // init was called, which calls scheduler.start + expect(mockSchedulerInstance.start).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/test/unit/services/superSimpleQueueHelper.test.ts b/server/test/unit/services/superSimpleQueueHelper.test.ts index 4805dd7e7..4d8ecfbc0 100644 --- a/server/test/unit/services/superSimpleQueueHelper.test.ts +++ b/server/test/unit/services/superSimpleQueueHelper.test.ts @@ -3,6 +3,8 @@ import { SuperSimpleQueueHelper } from "../../../src/service/infrastructure/Supe import type { Monitor } from "../../../src/types/monitor.ts"; import { createMockLogger } from "../../helpers/createMockLogger.ts"; +// ── Helpers ────────────────────────────────────────────────────────────────── + const createHelper = (overrides?: Record) => { const maintenanceWindowsRepository = { findByMonitorId: jest.fn().mockResolvedValue([]), @@ -42,7 +44,7 @@ const createHelper = (overrides?: Record) => { networkService: { requestStatus: jest.fn() }, statusService: statusServiceMock, notificationsService: { handleNotifications: jest.fn().mockResolvedValue(undefined) }, - checkService: { buildCheck: jest.fn().mockReturnValue({}), deleteOlderThan: jest.fn().mockResolvedValue(0) }, + 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) }, @@ -75,47 +77,415 @@ const createHelper = (overrides?: Record) => { defaults.geoChecksService as any, defaults.geoChecksRepository as any ); - return { helper, maintenanceWindowsRepository, defaults }; + return { helper, defaults, maintenanceWindowsRepository }; }; +const makeMonitor = (overrides?: Partial): Monitor => + ({ + id: "m1", + teamId: "team", + type: "http", + interval: 60000, + status: "up", + geoCheckEnabled: false, + geoCheckLocations: [], + ...overrides, + }) as Monitor; + +// ── Tests ──────────────────────────────────────────────────────────────────── + describe("SuperSimpleQueueHelper", () => { - describe("getHeartbeatJob", () => { - it("skips execution when monitor is in maintenance window", async () => { + describe("serviceName", () => { + it("returns JobQueueHelper", () => { const { helper } = createHelper(); - const spy = jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); - const job = helper.getHeartbeatJob(); - await job({ id: "m1", teamId: "team", interval: 60000 } as Monitor); - expect(helper["networkService"].requestStatus).not.toHaveBeenCalled(); - expect(helper["logger"].debug).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining("Monitor m1 is in maintenance window") }) - ); - spy.mockRestore(); + expect(helper.serviceName).toBe("JobQueueHelper"); }); + }); - it("processes monitor status and notifications when active", async () => { - const networkResponse = { monitor: { id: "m1" }, status: true, code: 200, message: "OK" }; - const statusServiceMock = { - updateMonitorStatus: jest.fn().mockResolvedValue({ monitor: { id: "m1", status: "up" }, statusChanged: false, prevStatus: "up", code: 200 }), - }; - const { helper } = createHelper({ - networkService: { requestStatus: jest.fn().mockResolvedValue(networkResponse) }, - statusService: statusServiceMock, - }); - jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(false); - const job = helper.getHeartbeatJob(); - const monitor = { id: "m1", teamId: "team" } as Monitor; - await job(monitor); - expect(helper["networkService"].requestStatus).toHaveBeenCalledWith(monitor); - }); + // ── getHeartbeatJob ────────────────────────────────────────────────────── + 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"); - expect(helper["logger"].warn).toHaveBeenCalled(); + }); + + 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(); + + await job(makeMonitor({ status: "up" })); + + expect(defaults.networkService.requestStatus).not.toHaveBeenCalled(); + expect(defaults.monitorsRepository.updateById).toHaveBeenCalledWith("m1", "team", { status: "maintenance" }); + }); + + it("skips monitor status update when already in maintenance", async () => { + const { helper, defaults } = createHelper(); + jest.spyOn(helper, "isInMaintenanceWindow").mockResolvedValue(true); + const job = helper.getHeartbeatJob(); + + await job(makeMonitor({ status: "maintenance" })); + + expect(defaults.monitorsRepository.updateById).not.toHaveBeenCalled(); + }); + + 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(); + + 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"); }); }); + // ── getHeartbeatGeoJob ─────────────────────────────────────────────────── + + describe("getHeartbeatGeoJob", () => { + it("throws nothing when monitor id is missing (logs error)", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getHeartbeatGeoJob(); + + await job({} as Monitor); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "No monitor id" })); + }); + + 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"] })); + + 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(); + + await job(makeMonitor({ geoCheckEnabled: true, type: "http", geoCheckLocations: ["us-east"] })); + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupOrphanedJob ──────────────────────────────────────────────── + + describe("getCleanupOrphanedJob", () => { + it("cleans up orphaned data across all repositories", async () => { + const { helper, defaults } = createHelper(); + (defaults.monitorsRepository.deleteByTeamIdsNotIn as jest.Mock).mockResolvedValue(2); + (defaults.monitorStatsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(3); + (defaults.checksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(4); + (defaults.incidentsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(1); + (defaults.geoChecksRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(5); + + const job = helper.getCleanupOrphanedJob(); + await job(); + + expect(defaults.teamsRepository.findAllTeamIds).toHaveBeenCalled(); + expect(defaults.monitorsRepository.deleteByTeamIdsNotIn).toHaveBeenCalledWith(["team"]); + expect(defaults.monitorsRepository.findAllMonitorIds).toHaveBeenCalled(); + expect(defaults.monitorStatsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]); + 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") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("1 orphaned incidents") })); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("5 orphaned geo checks") })); + }); + + it("skips info logs when no orphaned data is found", async () => { + const { helper, defaults } = createHelper(); + 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"); + expect(infoMessages).not.toContain(expect.stringContaining("orphaned monitors")); + }); + + it("logs warning and rethrows on error", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(new Error("db down")); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toThrow("db down"); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "db down" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper(); + (defaults.teamsRepository.findAllTeamIds as jest.Mock).mockRejectedValue(null); + const job = helper.getCleanupOrphanedJob(); + + await expect(job()).rejects.toBeNull(); + expect(defaults.logger.warn).toHaveBeenCalledWith(expect.objectContaining({ message: "Unknown error", stack: undefined })); + }); + }); + + // ── getCleanupRetentionJob ─────────────────────────────────────────────── + + describe("getCleanupRetentionJob", () => { + it("deletes checks older than TTL cutoff", async () => { + const { helper, defaults } = createHelper(); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).toHaveBeenCalledWith(expect.any(Date)); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Deleted") })); + }); + + it("skips cleanup when checkTTL is sentinel value (366)", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 366 }) }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + expect(defaults.checkService.deleteOlderThan).not.toHaveBeenCalled(); + expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("unlimited") })); + }); + + it("logs error on failure without rethrowing", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue(new Error("db error")) }, + }); + const job = helper.getCleanupRetentionJob(); + + await job(); // Should not throw + + expect(defaults.logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: "db error" })); + }); + + it("logs 'Unknown error' for non-Error exceptions", async () => { + const { helper, defaults } = createHelper({ + settingsService: { getDBSettings: jest.fn().mockRejectedValue("boom") }, + }); + const job = helper.getCleanupRetentionJob(); + await job(); + + 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(); @@ -149,5 +519,200 @@ describe("SuperSimpleQueueHelper", () => { 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) => { + 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() + ); + }); }); });