Files
Craig Lawson 08f104e283 Address review on the egress recovery job (#3937)
- The egress job runs through WorkerHelper.getEgressRecoveryJob(), like
  the cleanup jobs, instead of a direct service call from the queue
- Toggling egressCheckEnabled resets the egress state and removes any
  pending recovery job
- Probes resolve and pass the proxy, so http targets honour the global
  proxy as monitors do
- PingProvider keeps IPv6 addresses intact instead of truncating at the
  first colon
- Egress targets are parsed by one function shared by the validator and
  the probe; malformed addresses such as unbalanced brackets are rejected
- Notification id pattern drops the /i flag, which produced an invalid
  OpenAPI pattern
- EgressState findSingleton reads before it writes, so a read no longer
  bumps updatedAt
- "disabled" is a distinct assessment result and is cached like any other
- Concurrent failures share an in-flight assessment for as long as it
  runs, then reuse the settled result for 5 s
- The recovery job row is only removed if nobody rescheduled it since
  this run claimed it, so a concurrent new episode keeps its job
- Deleting a notification removes it from egressNotifications

🔧
2026-09-15 08:42:09 +01:00

211 lines
9.5 KiB
TypeScript

import { describe, expect, it, jest } from "@jest/globals";
import { WorkerHelper } from "../../../src/worker/worker.helper.ts";
import { createMockLogger } from "../../helpers/createMockLogger.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
//
// WorkerHelper is now thin glue: it wires the geo job to the GeoChecksPipeline and
// owns the cleanup jobs. The pipeline internals are tested in checkPipeline.test.ts;
// here we test only the glue + cleanup jobs.
const createHelper = (overrides?: Record<string, unknown>) => {
const monitorsRepository = {
updateById: jest.fn().mockResolvedValue({}),
findAllMonitorIds: jest.fn().mockResolvedValue(["m1"]),
deleteByTeamIdsNotIn: jest.fn().mockResolvedValue(0),
};
const jobsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const teamsRepository = {
findAllTeamIds: jest.fn().mockResolvedValue(["team"]),
};
const monitorStatsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const checksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const incidentsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const geoChecksRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const dockerLogsRepository = {
deleteByMonitorIdsNotIn: jest.fn().mockResolvedValue(0),
};
const settingsService = {
getDBSettings: jest.fn().mockResolvedValue({ checkTTL: 30 }),
};
const checkService = {
deleteOlderThan: jest.fn().mockResolvedValue(0),
};
const egressService = {
checkRecovery: jest.fn().mockResolvedValue(undefined),
};
const defaults = {
logger: createMockLogger(),
checkService,
settingsService,
monitorsRepository,
jobsRepository,
teamsRepository,
monitorStatsRepository,
checksRepository,
incidentsRepository,
geoChecksRepository,
dockerLogsRepository,
egressService,
...overrides,
};
const helper = new WorkerHelper(
defaults.logger as any,
defaults.checkService as any,
defaults.settingsService as any,
defaults.monitorsRepository as any,
defaults.jobsRepository as any,
defaults.teamsRepository as any,
defaults.monitorStatsRepository as any,
defaults.checksRepository as any,
defaults.incidentsRepository as any,
defaults.geoChecksRepository as any,
defaults.dockerLogsRepository as any,
defaults.egressService as any
);
return { helper, defaults };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("WorkerHelper", () => {
// ── 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);
(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(7);
(defaults.jobsRepository.deleteByMonitorIdsNotIn as jest.Mock).mockResolvedValue(6);
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"]);
expect(defaults.dockerLogsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
expect(defaults.jobsRepository.deleteByMonitorIdsNotIn).toHaveBeenCalledWith(["m1"]);
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") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("7 orphaned docker logs") }));
expect(defaults.logger.info).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("6 orphaned jobs") }));
});
it("skips info logs when no orphaned data is found", async () => {
const { helper, defaults } = createHelper();
const job = helper.getCleanupOrphanedJob();
await job();
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 }));
});
});
describe("getEgressRecoveryJob", () => {
it("returns a function that hands the claimed job to the egress service", async () => {
const { helper, defaults } = createHelper();
const job = { id: "egress", type: "egress", refId: null, nextScheduledAt: 1000 } as any;
await helper.getEgressRecoveryJob()(job);
expect(defaults.egressService.checkRecovery).toHaveBeenCalledWith(job);
});
it("lets the egress service's error propagate so the queue records the failure", async () => {
const { helper, defaults } = createHelper();
(defaults.egressService.checkRecovery as jest.Mock).mockRejectedValue(new Error("db down"));
await expect(helper.getEgressRecoveryJob()({ id: "egress" } as any)).rejects.toThrow("db down");
});
});
});