- 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
171 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|