Files
Kordant/web/src/server/jobs/worker.test.ts
Michael Freno eb8e57c674 feat: implement background job system with queue, worker, scheduler, and handlers
- Add job queue abstraction (InMemoryQueue and Redis/BullMQ adapter)
- Add polling worker with retry logic and exponential backoff
- Add 6 job handlers: darkwatch.scan, voiceprint.batch, hometitle.scan,
  removebrokers.process, reports.generate, notifications.send
- Add cron-based scheduler with tier-appropriate frequencies
  (Basic/Plus/Premium)
- Add tRPC scheduler router for admin (runJobNow, getJobStatus, etc.)
- Add entry point with graceful shutdown support
- Achieve 100% test pass rate for new job system
2026-05-25 17:16:21 -04:00

171 lines
5.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { InMemoryQueue, setQueue, resetQueue } from "./queue";
import { processJob, startWorker, stopWorker } from "./worker";
import { setHandlers } from "./handlers";
import type { Job } from "./queue";
describe("worker", () => {
let queue: InMemoryQueue;
beforeEach(() => {
queue = new InMemoryQueue();
setQueue(queue);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
resetQueue();
});
describe("processJob", () => {
it("dispatches darkwatch.scan to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "darkwatch.scan": handler });
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
await processJob(job);
expect(handler).toHaveBeenCalledWith({ userId: "u1", subscriptionId: "s1" });
});
it("dispatches hometitle.scan to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "hometitle.scan": handler });
const job = await queue.enqueue("hometitle.scan", { userId: "u1", subscriptionId: "s1" });
await processJob(job);
expect(handler).toHaveBeenCalledWith({ userId: "u1", subscriptionId: "s1" });
});
it("dispatches reports.generate to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "reports.generate": handler });
const job = await queue.enqueue("reports.generate", { userId: "u1", reportType: "MONTHLY_PLUS" });
await processJob(job);
expect(handler).toHaveBeenCalledWith({ userId: "u1", reportType: "MONTHLY_PLUS" });
});
it("dispatches notifications.send to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "notifications.send": handler });
const job = await queue.enqueue("notifications.send", { userId: "u1", channel: "email" });
await processJob(job);
expect(handler).toHaveBeenCalledWith({ userId: "u1", channel: "email" });
});
it("dispatches removebrokers.process to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "removebrokers.process": handler });
const job = await queue.enqueue("removebrokers.process", {});
await processJob(job);
expect(handler).toHaveBeenCalledWith({});
});
it("dispatches voiceprint.batch to correct handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "voiceprint.batch": handler });
const job = await queue.enqueue("voiceprint.batch", { userId: "u1" });
await processJob(job);
expect(handler).toHaveBeenCalledWith({ userId: "u1" });
});
it("marks job as completed on success", async () => {
setHandlers({ "darkwatch.scan": vi.fn().mockResolvedValue(undefined) });
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
await processJob(job);
const updated = await queue.getJob(job.id);
expect(updated?.status).toBe("completed");
});
it("schedules retry on failure with exponential backoff", async () => {
const handler = vi.fn().mockRejectedValue(new Error("Scan failed"));
setHandlers({ "darkwatch.scan": handler });
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
await processJob(job);
// After first retry, job should be pending with attempt 1
const updated = await queue.getJob(job.id);
expect(updated?.attempts).toBe(1);
expect(updated?.status).toBe("pending");
// Retry delay should be 60s for first retry
expect(handler).toHaveBeenCalledTimes(1);
});
it("marks as failed after exhausting retries", async () => {
const handler = vi.fn().mockRejectedValue(new Error("Final failure"));
setHandlers({ "darkwatch.scan": handler });
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" }, { maxAttempts: 2 });
// First attempt - retries (schedules retry)
await processJob(job);
expect(handler).toHaveBeenCalledTimes(1);
expect(job.attempts).toBe(1);
// Advance past the retry delay so the retry is available
vi.advanceTimersByTime(60001);
// Second attempt - dequeue and process
const retried = await queue.dequeue();
expect(retried).not.toBeNull();
if (retried) {
await processJob(retried);
}
// After max attempts, job should be failed
const failed = await queue.getJob(job.id);
expect(failed?.status).toBe("failed");
expect(failed?.error).toBe("Final failure");
});
});
describe("startWorker / stopWorker", () => {
it("polls queue and processes jobs", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "darkwatch.scan": handler });
await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
startWorker({ pollInterval: 50 });
await vi.advanceTimersByTimeAsync(100);
expect(handler).toHaveBeenCalled();
await stopWorker();
});
it("stops polling after stopWorker", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
setHandlers({ "darkwatch.scan": handler });
startWorker({ pollInterval: 50 });
await stopWorker();
await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
await vi.advanceTimersByTimeAsync(200);
expect(handler).not.toHaveBeenCalled();
});
});
});