172 lines
7.1 KiB
JavaScript
172 lines
7.1 KiB
JavaScript
import JobQueue from "@/services/infrastructure/JobQueue.js";
|
|
|
|
// Mocks
|
|
jest.mock("super-simple-scheduler", () => {
|
|
return function Scheduler() {
|
|
return {
|
|
start: jest.fn(),
|
|
stop: jest.fn().mockResolvedValue(true),
|
|
addTemplate: jest.fn(),
|
|
addJob: jest.fn().mockResolvedValue(true),
|
|
pauseJob: jest.fn().mockResolvedValue(true),
|
|
resumeJob: jest.fn().mockResolvedValue(true),
|
|
updateJob: jest.fn().mockResolvedValue(true),
|
|
removeJob: jest.fn(),
|
|
getJobs: jest.fn().mockResolvedValue([]),
|
|
flushJobs: jest.fn().mockResolvedValue(true),
|
|
};
|
|
};
|
|
});
|
|
|
|
jest.mock("@/db/models/index.js", () => ({
|
|
Monitor: {
|
|
find: jest.fn().mockResolvedValue([]),
|
|
exists: jest.fn().mockResolvedValue(true),
|
|
},
|
|
}));
|
|
import Scheduler from "super-simple-scheduler";
|
|
import { Monitor } from "@/db/models/index.js";
|
|
|
|
const mockJobGenerator = () => ({
|
|
generateJob: jest.fn(() => ({ handler: jest.fn() })),
|
|
generateCleanupJob: jest.fn(() => ({ handler: jest.fn() })),
|
|
// Provide stats aggregation job to satisfy JobQueue.init expectations
|
|
generateStatsAggregationJob: jest.fn(() => jest.fn().mockResolvedValue(undefined)),
|
|
});
|
|
|
|
describe("JobQueue", () => {
|
|
beforeEach(() => {
|
|
// Reset singleton between tests
|
|
// @ts-ignore
|
|
JobQueue["instance"] = null;
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
it("creates singleton and initializes templates and cleanup job", async () => {
|
|
const jq = await JobQueue.create(mockJobGenerator());
|
|
expect(jq).toBeInstanceOf(JobQueue);
|
|
expect(Scheduler().addTemplate).toBeDefined();
|
|
});
|
|
|
|
it("add/pause/resume/update/delete/flush/shutdown return true on scheduler success", async () => {
|
|
const jq = await JobQueue.create(mockJobGenerator());
|
|
const monitor = { _id: "m1", interval: 1000, status: "up", url: "http://x" };
|
|
await expect(jq.addJob(monitor)).resolves.toBe(true);
|
|
await expect(jq.pauseJob(monitor)).resolves.toBe(true);
|
|
await expect(jq.resumeJob(monitor)).resolves.toBe(true);
|
|
await expect(jq.updateJob(monitor)).resolves.toBe(true);
|
|
await expect(jq.deleteJob(monitor)).resolves.toBe(true);
|
|
await expect(jq.flush()).resolves.toBe(true);
|
|
await expect(jq.shutdown()).resolves.toBe(true);
|
|
});
|
|
|
|
it("getJobs maps lastRunTook only when run finished and not locked", async () => {
|
|
const fakeJobs = [
|
|
{ id: "a", lockedAt: null, lastRunAt: 100, lastFinishedAt: 160 }, // took 60
|
|
{ id: "b", lockedAt: 1, lastRunAt: 100, lastFinishedAt: 120 }, // locked => null
|
|
{ id: "c", lockedAt: null, lastRunAt: null, lastFinishedAt: 120 }, // missing => null
|
|
];
|
|
const jq = await JobQueue.create(mockJobGenerator());
|
|
// Patch scheduler's getJobs to return our fake jobs
|
|
// @ts-ignore
|
|
jq["scheduler"].getJobs = jest.fn().mockResolvedValue(fakeJobs);
|
|
const jobs = await jq.getJobs();
|
|
expect(jobs?.find((j) => j.id === "a")?.lastRunTook).toBe(60);
|
|
expect(jobs?.find((j) => j.id === "b")?.lastRunTook).toBeNull();
|
|
expect(jobs?.find((j) => j.id === "c")?.lastRunTook).toBeNull();
|
|
});
|
|
|
|
it("getMetrics aggregates counters and flags failing/active jobs", async () => {
|
|
const jobs = [
|
|
{ id: "1", data: { url: "u1", type: "http" }, runCount: 2, failCount: 1, lastFailedAt: 200, lastRunAt: 100, lastFailReason: "e1", lockedAt: null },
|
|
{ id: "2", data: { url: "u2", type: "ping" }, runCount: 0, failCount: 0, lastFailedAt: null, lastRunAt: null, lastFailReason: null, lockedAt: 1 },
|
|
];
|
|
const jq = await JobQueue.create(mockJobGenerator());
|
|
// @ts-ignore
|
|
jq["scheduler"].getJobs = jest.fn().mockResolvedValue(jobs);
|
|
const metrics = await jq.getMetrics();
|
|
expect(metrics).toMatchObject({ jobs: 2, totalRuns: 2, totalFailures: 1, activeJobs: 1, failingJobs: 1 });
|
|
expect(metrics?.jobsWithFailures[0]).toMatchObject({ monitorId: "1", failReason: "e1" });
|
|
});
|
|
|
|
it("init schedules addJob for existing monitors and skips missing ones", async () => {
|
|
jest.useFakeTimers();
|
|
const originalRandom = Math.random;
|
|
// Force deterministic offsets of 1000ms
|
|
Math.random = jest.fn().mockReturnValue(0);
|
|
const monitors = [
|
|
{ _id: "a", interval: 1000, status: "up" },
|
|
{ _id: "b", interval: 1000, status: "up" },
|
|
];
|
|
Monitor.find.mockResolvedValueOnce(monitors);
|
|
// exists: only 'a' exists
|
|
Monitor.exists.mockImplementation(async ({ _id }) => _id === "a");
|
|
const gen = mockJobGenerator();
|
|
const jq = await JobQueue.create(gen);
|
|
const addSpy = jest.spyOn(jq, "addJob").mockResolvedValue(true);
|
|
// Advance timers to trigger setTimeouts (1000ms)
|
|
jest.advanceTimersByTime(1100);
|
|
// Allow async callback to resolve
|
|
// @ts-ignore
|
|
if (jest.runOnlyPendingTimersAsync) {
|
|
// modern timers
|
|
// @ts-ignore
|
|
await jest.runOnlyPendingTimersAsync();
|
|
}
|
|
await Promise.resolve();
|
|
expect(addSpy).toHaveBeenCalledTimes(1);
|
|
expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ _id: "a" }));
|
|
// restore
|
|
Math.random = originalRandom;
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
it("returns false/null on scheduler errors across methods", async () => {
|
|
const jq = await JobQueue.create(mockJobGenerator());
|
|
// addJob failure
|
|
// @ts-ignore
|
|
jq["scheduler"].addJob = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.addJob({ _id: "m1", interval: 1, status: "up" })).resolves.toBe(false);
|
|
// pause/resume/update failures
|
|
// @ts-ignore
|
|
jq["scheduler"].pauseJob = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.pauseJob({ _id: "m1" })).resolves.toBe(false);
|
|
// @ts-ignore
|
|
jq["scheduler"].resumeJob = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.resumeJob({ _id: "m1" })).resolves.toBe(false);
|
|
// @ts-ignore
|
|
jq["scheduler"].updateJob = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.updateJob({ _id: "m1", interval: 1 })).resolves.toBe(false);
|
|
// delete failure
|
|
// @ts-ignore
|
|
jq["scheduler"].removeJob = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.deleteJob({ _id: "m1" })).resolves.toBe(false);
|
|
// getJobs failure
|
|
// @ts-ignore
|
|
jq["scheduler"].getJobs = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.getJobs()).resolves.toBeNull();
|
|
// getMetrics failure
|
|
// @ts-ignore
|
|
jq["scheduler"].getJobs = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.getMetrics()).resolves.toBeNull();
|
|
// flush/shutdown failure
|
|
// @ts-ignore
|
|
jq["scheduler"].flushJobs = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.flush()).resolves.toBe(false);
|
|
// @ts-ignore
|
|
jq["scheduler"].stop = jest.fn(() => { throw new Error("boom"); });
|
|
await expect(jq.shutdown()).resolves.toBe(false);
|
|
});
|
|
|
|
it("init returns false when scheduler throws during setup", async () => {
|
|
const jq = new JobQueue();
|
|
// Force addTemplate to throw
|
|
// @ts-ignore
|
|
jq["scheduler"].addTemplate = jest.fn(() => { throw new Error("setup fail"); });
|
|
// attach generator to avoid undefined access
|
|
// @ts-ignore
|
|
jq["jobGenerator"] = mockJobGenerator();
|
|
await expect(jq.init()).resolves.toBe(false);
|
|
});
|
|
});
|