- 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
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { InMemoryQueue } from "./queue";
|
|
|
|
describe("InMemoryQueue", () => {
|
|
let queue: InMemoryQueue;
|
|
|
|
beforeEach(() => {
|
|
queue = new InMemoryQueue();
|
|
});
|
|
|
|
it("enqueues a job with correct payload", async () => {
|
|
const job = await queue.enqueue("darkwatch.scan", {
|
|
userId: "user-1",
|
|
subscriptionId: "sub-1",
|
|
});
|
|
|
|
expect(job.type).toBe("darkwatch.scan");
|
|
expect(job.payload).toEqual({ userId: "user-1", subscriptionId: "sub-1" });
|
|
expect(job.status).toBe("pending");
|
|
expect(job.attempts).toBe(0);
|
|
expect(job.maxAttempts).toBe(3);
|
|
expect(job.id).toBeDefined();
|
|
});
|
|
|
|
it("dequeues pending jobs in order", async () => {
|
|
await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
|
await queue.enqueue("hometitle.scan", { userId: "u2", subscriptionId: "s2" });
|
|
|
|
const job1 = await queue.dequeue();
|
|
expect(job1?.type).toBe("darkwatch.scan");
|
|
expect(job1?.status).toBe("running");
|
|
|
|
const job2 = await queue.dequeue();
|
|
expect(job2?.type).toBe("hometitle.scan");
|
|
expect(job2?.status).toBe("running");
|
|
|
|
const job3 = await queue.dequeue();
|
|
expect(job3).toBeNull();
|
|
});
|
|
|
|
it("only dequeues pending jobs", async () => {
|
|
const job = await queue.enqueue("notifications.send", { userId: "u1", channel: "email" });
|
|
await queue.markComplete(job.id);
|
|
|
|
const result = await queue.dequeue();
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("marks job as completed", async () => {
|
|
const job = await queue.enqueue("reports.generate", { userId: "u1", reportType: "MONTHLY_PLUS" });
|
|
await queue.markComplete(job.id);
|
|
|
|
const fetched = await queue.getJob(job.id);
|
|
expect(fetched?.status).toBe("completed");
|
|
});
|
|
|
|
it("marks job as failed with error", async () => {
|
|
const job = await queue.enqueue("voiceprint.batch", {});
|
|
await queue.markFailed(job.id, "Test error");
|
|
|
|
const fetched = await queue.getJob(job.id);
|
|
expect(fetched?.status).toBe("failed");
|
|
expect(fetched?.error).toBe("Test error");
|
|
});
|
|
|
|
it("returns null for non-existent job", async () => {
|
|
const job = await queue.getJob("non-existent");
|
|
expect(job).toBeNull();
|
|
});
|
|
|
|
it("filters jobs by status", async () => {
|
|
const j1 = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
|
const j2 = await queue.enqueue("hometitle.scan", { userId: "u2", subscriptionId: "s2" });
|
|
|
|
await queue.markComplete(j1.id);
|
|
|
|
const pending = await queue.getJobs("pending");
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0].id).toBe(j2.id);
|
|
|
|
const completed = await queue.getJobs("completed");
|
|
expect(completed).toHaveLength(1);
|
|
expect(completed[0].id).toBe(j1.id);
|
|
});
|
|
|
|
it("supports delayed enqueue", async () => {
|
|
await queue.enqueue("notifications.send", { userId: "u1", channel: "email" }, { delay: 50 });
|
|
|
|
const immediate = await queue.dequeue();
|
|
expect(immediate).toBeNull();
|
|
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
|
|
const delayed = await queue.dequeue();
|
|
expect(delayed?.type).toBe("notifications.send");
|
|
});
|
|
|
|
it("supports custom maxAttempts", async () => {
|
|
const job = await queue.enqueue("removebrokers.process", {}, { maxAttempts: 5 });
|
|
expect(job.maxAttempts).toBe(5);
|
|
});
|
|
});
|