@tsed/bullmq
Version:
BullMQ integration for Ts.ED
483 lines (416 loc) • 13.1 kB
text/typescript
import "./BullMQModule.js";
import {catchAsyncError} from "@tsed/core";
import {logger} from "@tsed/di";
import {PlatformTest} from "@tsed/platform-http/testing";
import {Queue, Worker} from "bullmq";
import {anything, instance, mock, verify, when} from "ts-mockito";
import {beforeEach} from "vitest";
import {BullMQModule} from "./BullMQModule.js";
import {type BullMQConfig} from "./config/config.js";
import {JobMethods} from "./contracts/index.js";
import {FallbackJobController, JobController} from "./decorators/index.js";
import {InjectQueue} from "./decorators/InjectQueue.js";
import {InjectWorker} from "./decorators/InjectWorker.js";
import {JobDispatcher} from "./dispatchers/index.js";
const queueConstructorSpy = vi.fn();
const workerConstructorSpy = vi.fn();
vi.spyOn(logger(), "error");
vi.mock("bullmq", () => {
return {
Queue: class {
constructor(...args: any[]) {
queueConstructorSpy(...args);
}
close() {}
},
Worker: class {
constructor(...args: any[]) {
workerConstructorSpy(...args);
}
close() {}
}
};
});
@JobController("cron", "default", {
repeat: {
pattern: "* * * * *"
}
})
class CustomCronJob implements JobMethods {
@InjectQueue("default")
queue: Queue;
@InjectWorker("default")
worker: Worker;
handle() {}
}
@JobController("regular", "default")
class RegularJob {
handle() {}
}
describe("BullMQModule", () => {
let dispatcher: JobDispatcher;
beforeEach(() => {
dispatcher = mock(JobDispatcher);
when(dispatcher.dispatch(CustomCronJob)).thenResolve();
});
beforeEach(() => {
queueConstructorSpy.mockClear();
workerConstructorSpy.mockClear();
});
afterEach(PlatformTest.reset);
describe("configuration", () => {
describe("merges config correctly", () => {
beforeEach(() =>
PlatformTest.create({
bullmq: {
queues: ["default", "special"],
connection: {
connectionName: "defaultConnectionName"
},
defaultQueueOptions: {
defaultJobOptions: {
delay: 100
},
blockingConnection: true
},
queueOptions: {
special: {
connection: {
connectionName: "specialConnectionName"
},
defaultJobOptions: {
attempts: 9
}
}
},
defaultWorkerOptions: {
connection: {
connectTimeout: 123
},
concurrency: 50
},
workerOptions: {
special: {
concurrency: 1,
lockDuration: 2
}
}
},
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
})
);
it("queue", () => {
expect(queueConstructorSpy).toHaveBeenCalledTimes(2);
expect(queueConstructorSpy).toHaveBeenNthCalledWith(1, "default", {
connection: {
connectionName: "defaultConnectionName"
},
defaultJobOptions: {
delay: 100
},
blockingConnection: true
});
expect(queueConstructorSpy).toHaveBeenNthCalledWith(2, "special", {
connection: {
connectionName: "specialConnectionName"
},
defaultJobOptions: {
attempts: 9,
delay: 100
},
blockingConnection: true
});
});
it("worker", () => {
expect(workerConstructorSpy).toHaveBeenCalledTimes(2);
expect(workerConstructorSpy).toHaveBeenNthCalledWith(1, "default", expect.any(Function), {
connection: {
connectTimeout: 123
},
concurrency: 50
});
expect(workerConstructorSpy).toHaveBeenNthCalledWith(2, "special", expect.any(Function), {
connection: {
connectTimeout: 123
},
concurrency: 1,
lockDuration: 2
});
});
});
describe("discover queues from decorators", () => {
beforeEach(() =>
PlatformTest.create({
bullmq: {
queues: ["special"],
connection: {
connectionName: "defaultConnectionName"
},
defaultQueueOptions: {
defaultJobOptions: {
delay: 100
},
blockingConnection: true
},
queueOptions: {
special: {
connection: {
connectionName: "specialConnectionName"
},
defaultJobOptions: {
attempts: 9
}
}
},
defaultWorkerOptions: {
connection: {
connectTimeout: 123
},
concurrency: 50
},
workerOptions: {
special: {
concurrency: 1,
lockDuration: 2
}
}
},
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
})
);
it("queue", () => {
expect(queueConstructorSpy).toHaveBeenCalledTimes(2);
expect(queueConstructorSpy).toHaveBeenNthCalledWith(1, "default", {
connection: {
connectionName: "defaultConnectionName"
},
defaultJobOptions: {
delay: 100
},
blockingConnection: true
});
expect(queueConstructorSpy).toHaveBeenNthCalledWith(2, "special", {
connection: {
connectionName: "specialConnectionName"
},
defaultJobOptions: {
attempts: 9,
delay: 100
},
blockingConnection: true
});
});
it("worker", () => {
expect(workerConstructorSpy).toHaveBeenCalledTimes(2);
expect(workerConstructorSpy).toHaveBeenNthCalledWith(1, "default", expect.any(Function), {
connection: {
connectTimeout: 123
},
concurrency: 50
});
expect(workerConstructorSpy).toHaveBeenNthCalledWith(2, "special", expect.any(Function), {
connection: {
connectTimeout: 123
},
concurrency: 1,
lockDuration: 2
});
});
});
describe("disableWorker", () => {
const config = {
queues: ["default", "foo", "bar"],
connection: {},
disableWorker: true
} as BullMQConfig;
beforeEach(() =>
PlatformTest.create({
bullmq: config,
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
})
);
it("should not create any workers", () => {
expect(workerConstructorSpy).toHaveBeenCalledTimes(0);
});
});
describe("without", () => {
beforeEach(() =>
PlatformTest.create({
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
})
);
it("skips initialization", async () => {
expect(queueConstructorSpy).not.toHaveBeenCalled();
verify(dispatcher.dispatch(anything())).never();
});
});
});
describe("functionality", () => {
const config = {
queues: ["default", "foo", "bar"],
connection: {},
workerQueues: ["default", "foo"]
} as BullMQConfig;
beforeEach(() =>
PlatformTest.create({
bullmq: config,
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
})
);
describe("cronjobs", () => {
it("should dispatch cron jobs automatically", () => {
verify(dispatcher.dispatch(CustomCronJob)).once();
});
});
describe("queues", () => {
it("should get default", () => {
const instance = PlatformTest.get<Queue>("bullmq.queue.default");
expect(instance).toBeInstanceOf(Queue);
});
it.each(config.queues!)("should register queue(%s)", (queue) => {
const instance = PlatformTest.get<Queue>(`bullmq.queue.${queue}`);
expect(instance).toBeInstanceOf(Queue);
});
});
describe("workers", () => {
it("should get default", () => {
const instance = PlatformTest.get<Worker>("bullmq.worker.default");
expect(instance).toBeInstanceOf(Worker);
});
it.each(config.workerQueues!)("should register worker(%s)", (queue) => {
const instance = PlatformTest.get<Worker>(`bullmq.worker.${queue}`);
expect(instance).toBeInstanceOf(Worker);
});
it("should not register unspecified worker queue", () => {
expect(PlatformTest.get("bullmq.worker.bar")).toBeUndefined();
});
it("should run worker and execute processor", async () => {
const bullMQModule = PlatformTest.get<BullMQModule>(BullMQModule);
const worker = PlatformTest.get<JobMethods>("bullmq.job.default.regular");
const job = {
name: "regular",
queueName: "default",
data: {test: "test"}
};
vi.spyOn(worker, "handle").mockResolvedValueOnce(undefined as never);
await (bullMQModule as any).onProcess(job);
expect(worker.handle).toHaveBeenCalledWith({test: "test"}, job);
});
it("should log warning when the worker doesn't exists", async () => {
const bullMQModule = PlatformTest.get<BullMQModule>(BullMQModule);
vi.spyOn(PlatformTest.injector.logger, "warn");
const job = {
name: "regular",
queueName: "toto",
data: {test: "test"}
};
await (bullMQModule as any).onProcess(job);
expect(PlatformTest.injector.logger.warn).toHaveBeenCalledWith({
event: "BULLMQ_JOB_NOT_FOUND",
message: "Job regular toto not found"
});
});
it("should run worker, execute processor and handle error", async () => {
vi.spyOn(logger(), "error").mockReturnThis();
logger().level = "error";
const bullMQModule = PlatformTest.get<BullMQModule>(BullMQModule);
const worker = PlatformTest.get<JobMethods>("bullmq.job.default.regular");
const job = {
name: "regular",
queueName: "default",
data: {test: "test"},
attemptsMade: 1
};
vi.spyOn(worker, "handle").mockRejectedValue(new Error("error") as never);
const error = await catchAsyncError(() => (bullMQModule as any).onProcess(job));
expect(worker.handle).toHaveBeenCalledWith({test: "test"}, job);
expect(logger().error).toHaveBeenCalledWith({
attempt: 1,
name: "regular",
queue: "default",
logType: "bullmq",
duration: expect.any(Number),
event: "BULLMQ_JOB_ERROR",
message: "error",
reqId: expect.any(String),
stack: expect.any(String),
time: expect.any(Object)
});
expect(error?.message).toEqual("error");
});
});
});
describe("with fallback controller", () => {
beforeEach(async () => {
@FallbackJobController("foo")
class FooFallbackController {
handle() {}
}
@FallbackJobController()
class FallbackController {
handle() {}
}
await PlatformTest.create({
bullmq: {
queues: ["default", "foo"],
connection: {}
},
imports: [
{
token: JobDispatcher,
use: instance(dispatcher)
}
]
});
});
it("should run queue specific fallback job controller", async () => {
const bullMQModule = PlatformTest.get<BullMQModule>(BullMQModule);
const worker = PlatformTest.get<JobMethods>("bullmq.fallback-job.foo");
const job = {
name: "unknown-name",
queueName: "foo",
data: {test: "test"}
};
vi.spyOn(worker, "handle").mockResolvedValueOnce(undefined as never);
await (bullMQModule as any).onProcess(job);
expect(worker.handle).toHaveBeenCalledWith({test: "test"}, job);
});
it("should run overall fallback job controller", async () => {
const bullMQModule = PlatformTest.get<BullMQModule>(BullMQModule);
const worker = PlatformTest.get<JobMethods>("bullmq.fallback-job");
const job = {
name: "unknown-name",
queueName: "default",
data: {test: "123"}
};
vi.spyOn(worker, "handle").mockResolvedValueOnce(undefined as never);
await (bullMQModule as any).onProcess(job);
expect(worker.handle).toHaveBeenCalledWith({test: "123"}, job);
});
});
});