UNPKG

actionhero

Version:

The reusable, scalable, and quick node.js API server for stateless and stateful applications

606 lines (509 loc) 18.7 kB
import { api, Process, Task, utils, config, task, specHelper, } from "../../../src/index"; import { sleep } from "../../../src/modules/utils/sleep"; const actionhero = new Process(); let taskOutput: any[] = []; const queue = "testQueue"; describe("Core: Tasks", () => { beforeAll(async () => { await actionhero.start(); api.resque.multiWorker.options.minTaskProcessors = 1; api.resque.multiWorker.options.maxTaskProcessors = 1; api.resque.multiWorker.options.connection!.redis!.setMaxListeners(100); class RegularTask extends Task { constructor() { super(); this.name = "regular"; this.description = "task: regular"; this.queue = queue; this.frequency = 0; } run(params: Record<string, any>) { taskOutput.push(params.word); return params.word; } } class PeriodicTask extends Task { constructor() { super(); this.name = "periodicTask"; this.description = "task: periodicTask"; this.queue = queue; this.frequency = 100; } async run() { await sleep(10); taskOutput.push("periodicTask"); return "periodicTask"; } } class SlowTask extends Task { constructor() { super(); this.name = "slowTask"; this.description = "task: slowTask"; this.queue = queue; this.frequency = 0; } async run() { await utils.sleep(5000); taskOutput.push("slowTask"); return "slowTask"; } } class TaskWithInputs extends Task { constructor() { super(); this.name = "taskWithInputs"; this.description = "task: taskWithInputs"; this.queue = queue; this.frequency = 0; this.inputs = { a: { required: true, default: 1 }, b: { required: true, default: () => { return 2; }, }, c: { required: true, validator: (p: unknown) => { if (p !== 3) { throw new Error("nope"); } }, }, d: { required: true, validator: (p: unknown) => { if (p !== 4) { return false; } }, }, }; } async run() { taskOutput.push("taskWithInputs"); return "taskWithInputs"; } } api.tasks.tasks.regularTask = new RegularTask(); api.tasks.tasks.periodicTask = new PeriodicTask(); api.tasks.tasks.slowTask = new SlowTask(); api.tasks.tasks.taskWithInputs = new TaskWithInputs(); api.tasks.jobs.regularTask = api.tasks.jobWrapper("regularTask"); api.tasks.jobs.periodicTask = api.tasks.jobWrapper("periodicTask"); api.tasks.jobs.slowTask = api.tasks.jobWrapper("slowTask"); api.tasks.jobs.taskWithInputs = api.tasks.jobWrapper("taskWithInputs"); }); afterAll(async () => { delete api.tasks.tasks.regularTask; delete api.tasks.tasks.periodicTask; delete api.tasks.tasks.slowTask; delete api.tasks.tasks.taskWithInputs; delete api.tasks.jobs.regularTask; delete api.tasks.jobs.periodicTask; delete api.tasks.jobs.slowTask; delete api.tasks.jobs.taskWithInputs; config.tasks!.queues = []; api.resque.multiWorker.options.minTaskProcessors = 0; api.resque.multiWorker.options.maxTaskProcessors = 0; await actionhero.stop(); }); beforeEach(async () => { taskOutput = []; await api.resque.queue.connection.redis.flushdb(); }); afterEach(async () => { await api.resque.stopScheduler(); await api.resque.stopMultiWorker(); }); test("validates tasks", () => { if (api.tasks.tasks.regularTask.validate) { api.tasks.tasks.regularTask.validate(); } }); test("a bad task definition causes an exception", () => { class BadTask extends Task { constructor() { super(); // this.name = 'noName' this.description = "no name"; this.queue = queue; this.frequency = 0; } async run() {} } const task = new BadTask(); try { if (task.validate) task.validate(); throw new Error("should not get here"); } catch (error) { expect(error.toString()).toMatch(/name is required for this task/); } }); test("setup worked", () => { expect( Object.keys(api.tasks.tasks).filter((k) => k !== "test-task"), // test-task might be in scope from integration test ).toHaveLength(4 + 1); }); test("all queues should start empty", async () => { const length = await api.resque.queue.length("default"); expect(length).toEqual(0); }); test("can run a task manually", async () => { const response = await specHelper.runTask("regularTask", { word: "theWord", }); expect(response).toEqual("theWord"); expect(taskOutput[0]).toEqual("theWord"); }); test("can run a task fully", async () => { const response = await specHelper.runFullTask("regularTask", { word: "theWord", }); expect(response).toEqual("theWord"); expect(taskOutput[0]).toEqual("theWord"); }); test("it can detect that a task was enqueued to run now", async () => { await task.enqueue("regularTask", { word: "testing" }); const found = await specHelper.findEnqueuedTasks("regularTask"); expect(found.length).toEqual(1); expect(found[0].args[0].word).toEqual("testing"); expect(found[0].timestamp).toBeNull(); }); test("it can detect that a task was enqueued to run in the future", async () => { await task.enqueueIn(1000, "regularTask", { word: "testing" }); const found = await specHelper.findEnqueuedTasks("regularTask"); expect(found.length).toEqual(1); expect(found[0].args[0].word).toEqual("testing"); expect(found[0].timestamp).toBeGreaterThan(1); }); test("can call task methods inside the run", async () => { class TaskWithMethod extends Task { constructor() { super(); this.name = "taskWithMethod"; this.description = "task with additional methods to execute in run"; this.queue = queue; } async stepOne() { await utils.sleep(100); taskOutput.push("one"); } stepTwo() { taskOutput.push("two"); } async run() { await this.stepOne(); this.stepTwo(); taskOutput.push("tree"); } } api.tasks.tasks.taskWithMethod = new TaskWithMethod(); api.tasks.jobs.taskWithMethod = api.tasks.jobWrapper("taskWithMethod"); await specHelper.runFullTask("taskWithMethod", {}); expect(taskOutput).toHaveLength(3); expect(taskOutput[0]).toEqual("one"); expect(taskOutput[1]).toEqual("two"); expect(taskOutput[2]).toEqual("tree"); }); test("no delayed tasks should be scheduled", async () => { const timestamps = await api.resque.queue.scheduledAt( queue, "periodicTask", ); expect(timestamps).toHaveLength(0); }); test("all periodic tasks can be enqueued at boot", async () => { await task.enqueueAllRecurrentTasks(); const length = await api.resque.queue.length(queue); expect(length).toEqual(1); }); test("re-enquing a recurring task will not throw", async () => { let length = await api.resque.queue.length(queue); expect(length).toEqual(0); // does not throw await Promise.all([ task.enqueueAllRecurrentTasks(), task.enqueueAllRecurrentTasks(), task.enqueueAllRecurrentTasks(), ]); length = await api.resque.queue.length(queue); expect(length).toEqual(1); }); test("if we get in a situation where 2 instances of a periodic task end at the same time, only one instance will be enqueued", async () => { let length = await api.resque.queue.length(queue); expect(length).toEqual(0); let timestamps = await task.timestamps(); expect(timestamps).toHaveLength(0); // also tests that jobLock does what we want await Promise.all([ specHelper.runFullTask("periodicTask", {}), specHelper.runFullTask("periodicTask", {}), specHelper.runFullTask("periodicTask", {}), specHelper.runFullTask("periodicTask", {}), specHelper.runFullTask("periodicTask", {}), ]); expect(taskOutput.length).toBe(1); timestamps = await task.timestamps(); expect(timestamps).toHaveLength(1); const { tasks } = await task.delayedAt(timestamps[0]); expect(tasks).toHaveLength(1); expect(tasks[0].class).toEqual("periodicTask"); }); test("re-enqueuing a periodic task should not enqueue it again", async () => { const tryOne = await task.enqueue("periodicTask", {}); const tryTwo = await task.enqueue("periodicTask", {}); const length = await api.resque.queue.length(queue); expect(tryOne).toEqual(true); expect(tryTwo).toEqual(false); expect(length).toEqual(1); }); test("can add a normal job", async () => { await task.enqueue("regularTask", { word: "first" }); const length = await api.resque.queue.length(queue); expect(length).toEqual(1); }); test("can add a delayed job", async () => { const time = new Date().getTime() + 1000; await task.enqueueAt(time, "regularTask", { word: "first" }); const timestamps = await api.resque.queue.scheduledAt( queue, "regularTask", [{ word: "first" }], ); expect(timestamps).toHaveLength(1); const completeTime = Math.floor(time / 1000); expect(Number(timestamps[0])).toBeGreaterThanOrEqual(completeTime); expect(Number(timestamps[0])).toBeLessThan(completeTime + 2); }); test("can see enqueued timestamps & see jobs within those timestamps (single + batch)", async () => { const time = new Date().getTime() + 1000; const roundedTime = Math.round(time / 1000) * 1000; await task.enqueueAt(time, "regularTask", { word: "first" }); const timestamps = await task.timestamps(); expect(timestamps).toHaveLength(1); expect(timestamps[0]).toEqual(roundedTime); const { tasks } = await task.delayedAt(roundedTime); expect(tasks).toHaveLength(1); expect(tasks[0].class).toEqual("regularTask"); const allTasks = await task.allDelayed(); expect(Object.keys(allTasks)).toHaveLength(1); expect(Object.keys(allTasks)[0]).toEqual(String(roundedTime)); expect(allTasks[roundedTime][0].class).toEqual("regularTask"); }); test("I can remove an enqueued job", async () => { await task.enqueue("regularTask", { word: "first" }); const length = await api.resque.queue.length(queue); expect(length).toEqual(1); const count = await task.del(queue, "regularTask", { word: "first" }); expect(count).toEqual(1); const lengthAgain = await api.resque.queue.length(queue); expect(lengthAgain).toEqual(0); }); test("I can remove enqueued jobs by name", async () => { await task.enqueue("regularTask", { word: "first" }); const length = await api.resque.queue.length(queue); expect(length).toEqual(1); await task.delByFunction(queue, "regularTask"); const lengthAgain = await api.resque.queue.length(queue); expect(lengthAgain).toEqual(0); }); test("I can remove a delayed job", async () => { await task.enqueueIn(1000, "regularTask", { word: "first" }); const timestamps = await api.resque.queue.scheduledAt( queue, "regularTask", [{ word: "first" }], ); expect(timestamps).toHaveLength(1); const timestampsDeleted = await task.delDelayed(queue, "regularTask", { word: "first", }); expect(timestampsDeleted).toHaveLength(1); expect(timestampsDeleted).toEqual(timestamps); const timestampsDeletedAgain = await task.delDelayed(queue, "regularTask", { word: "first", }); expect(timestampsDeletedAgain).toHaveLength(0); }); test("I can remove and stop a recurring task", async () => { // enqueue the delayed job 2x, one in each type of queue await task.enqueue("periodicTask"); await task.enqueueIn(1000, "periodicTask"); const count = await task.stopRecurrentTask("periodicTask"); expect(count).toEqual(2); }); describe("input validation", () => { test("tasks which provide input can be enqueued", async () => { await expect(task.enqueue("taskWithInputs", {})).rejects.toThrow( /input for task/, ); await task.enqueue("taskWithInputs", { a: 1, b: 2, c: 3, d: 4 }); // does not throw }); test("tasks which provide input can be enqueuedAt", async () => { await expect(task.enqueueIn(1, "taskWithInputs", {})).rejects.toThrow( /input for task/, ); await task.enqueueIn(1, "taskWithInputs", { a: 1, b: 2, c: 3, d: 4 }); // does not throw }); test("tasks which provide input can be enqueuedIn", async () => { await expect(task.enqueueAt(1, "taskWithInputs", {})).rejects.toThrow( /input for task/, ); await task.enqueueAt(1, "taskWithInputs", { a: 1, b: 2, c: 3, d: 4 }); // does not throw }); test("defaults can be provided (via literal)", async () => { await task.enqueue("taskWithInputs", { b: 2, c: 3, d: 4 }); const enqueuedTask = await specHelper.findEnqueuedTasks("taskWithInputs"); expect(enqueuedTask[0].args[0]).toEqual({ a: 1, b: 2, c: 3, d: 4 }); }); test("defaults can be provided (via function)", async () => { await task.enqueue("taskWithInputs", { a: 1, c: 3, d: 4 }); const enqueuedTask = await specHelper.findEnqueuedTasks("taskWithInputs"); expect(enqueuedTask[0].args[0]).toEqual({ a: 1, b: 2, c: 3, d: 4 }); }); test("validation will fail with input that does not match the validation method (via throw)", async () => { await expect( task.enqueue("taskWithInputs", { a: 1, b: 2, c: -1, d: 4 }), ).rejects.toThrow(/nope/); }); test("validation will fail with input that does not match the validation method (via false)", async () => { await expect( task.enqueue("taskWithInputs", { a: 1, b: 2, c: 3, d: -1 }), ).rejects.toThrow(/-1 is not a valid value for d in task taskWithInputs/); }); }); describe("middleware", () => { describe("enqueue modification", () => { beforeAll(async () => { const middleware = { name: "test-middleware", priority: 1000, global: false, preEnqueue: () => { throw new Error("You cannot enqueue me!"); }, }; task.addMiddleware(middleware); api.tasks.tasks.middlewareTask = { name: "middlewareTask", description: "middlewaretask", queue: "default", frequency: 0, middleware: ["test-middleware"], run: (params, worker) => { throw new Error("Should never get here"); }, }; api.tasks.jobs.middlewareTask = api.tasks.jobWrapper("middlewareTask"); }); afterAll(async () => { api.tasks.globalMiddleware = []; delete api.tasks.jobs.middlewareTask; }); test("can modify the behavior of enqueue with middleware.preEnqueue", async () => { try { await task.enqueue("middlewareTask", {}); } catch (error) { expect(error.toString()).toEqual("Error: You cannot enqueue me!"); } }); }); describe("Pre and Post processing", () => { beforeAll(() => { const middleware = { name: "test-middleware", priority: 1000, global: false, preProcessor: function () { const params = this.args[0]; if (params.stop === true) { return false; } if (params.throw === true) { throw new Error("thown!"); } params.test = true; if (!this.worker.result) { this.worker.result = {}; } this.worker.result.pre = true; return true; }, postProcessor: function () { this.worker.result.post = true; return true; }, }; task.addMiddleware(middleware); api.tasks.tasks.middlewareTask = { name: "middlewareTask", description: "middlewaretask", queue: "default", frequency: 0, middleware: ["test-middleware"], run: function (params, worker) { expect(params.test).toEqual(true); const result = worker.result; result.run = true; return result; }, }; api.tasks.jobs.middlewareTask = api.tasks.jobWrapper("middlewareTask"); }); afterAll(() => { api.tasks.globalMiddleware = []; delete api.tasks.jobs.middlewareTask; }); test("can modify parameters before a task and modify result after task completion", async () => { const result = await specHelper.runFullTask("middlewareTask", { foo: "bar", }); expect(result.run).toEqual(true); expect(result.pre).toEqual(true); expect(result.post).toEqual(true); }); test("can prevent the running of a task with error", async () => { try { await specHelper.runFullTask("middlewareTask", { throw: true }); } catch (error) { expect(error.toString()).toEqual("Error: thown!"); } }); test("can prevent the running of a task with return value", async () => { const result = await specHelper.runFullTask("middlewareTask", { stop: true, }); expect(result).toBeUndefined(); }); }); }); describe("details view in a working system", () => { test("can use api.tasks.details to learn about the system", async () => { config.tasks!.queues = ["*"]; await task.enqueue("slowTask", { a: 1 }); api.resque.multiWorker.start(); await utils.sleep(2000); const details = await task.details(); expect(Object.keys(details.queues)).toEqual(["testQueue"]); expect(details.queues.testQueue).toHaveLength(0); expect(Object.keys(details.workers)).toHaveLength(1); const workerName = Object.keys(details.workers)[0]; expect(details.workers[workerName].queue).toEqual("testQueue"); expect(details.workers[workerName].payload.args).toEqual([{ a: 1 }]); expect(details.workers[workerName].payload.class).toEqual("slowTask"); await api.resque.multiWorker.stop(); }, 10000); }); });