Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
521 lines
15 KiB
JavaScript
521 lines
15 KiB
JavaScript
import { jest } from "@jest/globals";
|
|
|
|
const workerInstances = [];
|
|
|
|
jest.unstable_mockModule("worker_threads", () => ({
|
|
Worker: class Worker {
|
|
constructor(filename) {
|
|
this.filename = filename;
|
|
this.postMessage = jest.fn();
|
|
this.terminate = jest.fn().mockResolvedValue(undefined);
|
|
this.listeners = {};
|
|
workerInstances.push(this);
|
|
}
|
|
|
|
on(event, handler) {
|
|
this.listeners[event] = handler;
|
|
}
|
|
},
|
|
}));
|
|
|
|
jest.unstable_mockModule("log4js", () => ({
|
|
default: {
|
|
getLogger: () => ({
|
|
level: "info",
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
warn: jest.fn(),
|
|
trace: jest.fn(),
|
|
info: jest.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
jest.unstable_mockModule("../../config.js", () => ({
|
|
loadConfig: jest.fn(() => ({
|
|
server: {
|
|
logLevel: "info",
|
|
},
|
|
})),
|
|
}));
|
|
|
|
const onSchedulerEvent = jest.fn().mockResolvedValue(undefined);
|
|
|
|
const invoiceMongooseModel = {
|
|
scheduledProperties: [
|
|
{
|
|
name: "dueAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
{
|
|
name: "remindAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
],
|
|
onSchedulerEvent,
|
|
};
|
|
|
|
const invoiceModelEntry = {
|
|
type: "invoice",
|
|
label: "Invoice",
|
|
model: invoiceMongooseModel,
|
|
};
|
|
|
|
const printerModelEntry = {
|
|
type: "printer",
|
|
label: "Printer",
|
|
model: {},
|
|
};
|
|
|
|
jest.unstable_mockModule("../../database/schemas/models.js", () => ({
|
|
models: {
|
|
INV: invoiceModelEntry,
|
|
PRN: printerModelEntry,
|
|
},
|
|
}));
|
|
|
|
jest.unstable_mockModule("../../database/database.js", () => ({
|
|
listObjects: jest.fn(async () => []),
|
|
getObject: jest.fn(async ({ id }) => ({ _id: id })),
|
|
}));
|
|
|
|
jest.unstable_mockModule("../../database/utils.js", () => ({
|
|
getFilter: jest.fn(async (filter) => filter),
|
|
}));
|
|
|
|
const updateManagerInstances = [];
|
|
|
|
jest.unstable_mockModule("../../updates/updatemanager.js", () => ({
|
|
UpdateManager: class UpdateManager {
|
|
constructor(socketClient) {
|
|
this.socketClient = socketClient;
|
|
this.subscribeToObjectNew = jest.fn().mockResolvedValue(undefined);
|
|
this.subscribeToObjectDelete = jest.fn().mockResolvedValue(undefined);
|
|
this.subscribeToAllObjectUpdates = jest.fn().mockResolvedValue(undefined);
|
|
this.removeAllListeners = jest.fn().mockResolvedValue(undefined);
|
|
updateManagerInstances.push(this);
|
|
}
|
|
},
|
|
}));
|
|
|
|
const { SchedulerManager } = await import("../schedulermanager.js");
|
|
const { Scheduler } = await import("../scheduler.js");
|
|
const { listObjects, getObject } = await import("../../database/database.js");
|
|
const { getFilter } = await import("../../database/utils.js");
|
|
|
|
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const createManager = () =>
|
|
new SchedulerManager(
|
|
{ name: "redis" },
|
|
{ name: "mongo" },
|
|
{ name: "nats" },
|
|
);
|
|
|
|
const dueAt = new Date("2026-09-06T12:00:00.000Z");
|
|
|
|
describe("SchedulerManager", () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
workerInstances.length = 0;
|
|
updateManagerInstances.length = 0;
|
|
onSchedulerEvent.mockClear();
|
|
listObjects.mockResolvedValue([]);
|
|
getObject.mockImplementation(async ({ id }) => ({ _id: id }));
|
|
});
|
|
|
|
describe("start", () => {
|
|
it("should start the worker, discover scheduled properties, subscribe, and seed schedulers", async () => {
|
|
const object = { _id: "inv-1", dueAt, remindAt: null };
|
|
listObjects.mockResolvedValue([object]);
|
|
|
|
const manager = createManager();
|
|
await manager.start();
|
|
|
|
expect(workerInstances).toHaveLength(1);
|
|
expect(manager.scheduledProperties).toEqual([
|
|
{
|
|
model: invoiceModelEntry,
|
|
property: "dueAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
{
|
|
model: invoiceModelEntry,
|
|
property: "remindAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
]);
|
|
|
|
const updateManager = manager.updateManager;
|
|
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledTimes(1);
|
|
expect(updateManager.subscribeToObjectDelete).toHaveBeenCalledTimes(1);
|
|
expect(updateManager.subscribeToAllObjectUpdates).toHaveBeenCalledTimes(1);
|
|
expect(updateManager.subscribeToObjectNew).toHaveBeenCalledWith(
|
|
"invoice",
|
|
{ state: "due|sent|acknowledged" },
|
|
);
|
|
|
|
expect(getFilter).toHaveBeenCalledWith(
|
|
{ state: "due|sent|acknowledged", dueAt: "TODAY.." },
|
|
["*"],
|
|
true,
|
|
invoiceMongooseModel,
|
|
);
|
|
expect(manager.schedulers).toHaveLength(1);
|
|
expect(manager.schedulers[0]).toMatchObject({
|
|
objectType: "invoice",
|
|
object,
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
});
|
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
|
type: "add",
|
|
scheduler: manager.schedulers[0].toPayload(),
|
|
});
|
|
});
|
|
|
|
it("should skip objects that are missing a scheduled property value", async () => {
|
|
listObjects.mockResolvedValue([{ _id: "inv-1" }]);
|
|
|
|
const manager = createManager();
|
|
await manager.start();
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("handleWorkerMessage", () => {
|
|
it("should fire a known scheduler and remove it afterwards", async () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.addScheduler(scheduler);
|
|
|
|
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
|
await flushPromises();
|
|
|
|
expect(onSchedulerEvent).toHaveBeenCalledWith(
|
|
{ _id: "inv-1" },
|
|
"dueAt",
|
|
);
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
|
type: "remove",
|
|
id: "scheduler-1",
|
|
});
|
|
});
|
|
|
|
it("should ignore unknown schedulers and non-due messages", async () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
|
|
manager.handleWorkerMessage({ type: "due", id: "missing" });
|
|
manager.handleWorkerMessage({ type: "add", id: "scheduler-1" });
|
|
await flushPromises();
|
|
|
|
expect(onSchedulerEvent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should still remove a scheduler when firing fails", async () => {
|
|
onSchedulerEvent.mockRejectedValueOnce(new Error("boom"));
|
|
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.addScheduler(scheduler);
|
|
|
|
manager.handleWorkerMessage({ type: "due", id: "scheduler-1" });
|
|
await flushPromises();
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("upsertScheduler", () => {
|
|
it("should no-op when the object has no id", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
|
|
manager.upsertScheduler({
|
|
objectType: "invoice",
|
|
object: {},
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
});
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should remove an existing scheduler when the value is cleared", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.addScheduler(scheduler);
|
|
workerInstances[0].postMessage.mockClear();
|
|
|
|
manager.upsertScheduler({
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: null,
|
|
});
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
|
type: "remove",
|
|
id: "scheduler-1",
|
|
});
|
|
});
|
|
|
|
it("should update an existing scheduler without notifying the worker when the time is unchanged", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1", name: "old" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.addScheduler(scheduler);
|
|
workerInstances[0].postMessage.mockClear();
|
|
|
|
const nextObject = { _id: "inv-1", name: "new" };
|
|
manager.upsertScheduler({
|
|
objectType: "invoice",
|
|
object: nextObject,
|
|
property: "dueAt",
|
|
value: dueAt.toISOString(),
|
|
});
|
|
|
|
expect(manager.schedulers).toHaveLength(1);
|
|
expect(manager.schedulers[0].object).toBe(nextObject);
|
|
expect(workerInstances[0].postMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should notify the worker when an existing scheduler time changes", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.addScheduler(scheduler);
|
|
workerInstances[0].postMessage.mockClear();
|
|
|
|
const nextValue = new Date("2026-09-08T12:00:00.000Z");
|
|
manager.upsertScheduler({
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: nextValue,
|
|
});
|
|
|
|
expect(manager.schedulers[0].value).toEqual(nextValue);
|
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
|
type: "add",
|
|
scheduler: manager.schedulers[0].toPayload(),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("findScheduler and removeSchedulersForObject", () => {
|
|
it("should match schedulers by type, property, and stringified object id", () => {
|
|
const manager = createManager();
|
|
const scheduler = new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: 123 },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
});
|
|
manager.schedulers.push(scheduler);
|
|
|
|
expect(manager.findScheduler("invoice", "123", "dueAt")).toBe(scheduler);
|
|
expect(manager.findScheduler("invoice", "123", "remindAt")).toBeUndefined();
|
|
});
|
|
|
|
it("should remove every scheduler for an object and ignore missing ids", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
manager.addScheduler(
|
|
new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
}),
|
|
);
|
|
manager.addScheduler(
|
|
new Scheduler({
|
|
id: "scheduler-2",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "remindAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
}),
|
|
);
|
|
manager.addScheduler(
|
|
new Scheduler({
|
|
id: "scheduler-3",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-2" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
}),
|
|
);
|
|
|
|
manager.removeSchedulersForObject("invoice", {});
|
|
expect(manager.schedulers).toHaveLength(3);
|
|
|
|
manager.removeSchedulersForObject("invoice", { _id: "inv-1" });
|
|
expect(manager.schedulers.map((scheduler) => scheduler.id)).toEqual([
|
|
"scheduler-3",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("handleObjectEvent", () => {
|
|
it("should sync schedulers from objectNew and objectUpdate events", async () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
manager.scheduledProperties = [
|
|
{
|
|
model: invoiceModelEntry,
|
|
property: "dueAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
];
|
|
getObject.mockResolvedValue({ _id: "inv-1", dueAt });
|
|
|
|
manager.handleObjectEvent("objectNew", {
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
filter: { state: "due|sent|acknowledged" },
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(getObject).toHaveBeenCalledWith({
|
|
model: invoiceMongooseModel,
|
|
id: "inv-1",
|
|
});
|
|
expect(manager.schedulers).toHaveLength(1);
|
|
|
|
getObject.mockResolvedValue({
|
|
_id: "inv-1",
|
|
dueAt: new Date("2026-09-09T12:00:00.000Z"),
|
|
});
|
|
manager.handleObjectEvent("objectUpdate", {
|
|
objectType: "invoice",
|
|
_id: "inv-1",
|
|
object: { dueAt: new Date("2026-09-09T12:00:00.000Z") },
|
|
filter: { state: "due|sent|acknowledged" },
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(manager.schedulers[0].value).toEqual(
|
|
new Date("2026-09-09T12:00:00.000Z"),
|
|
);
|
|
});
|
|
|
|
it("should ignore events whose filter does not match a scheduled property", async () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
manager.scheduledProperties = [
|
|
{
|
|
model: invoiceModelEntry,
|
|
property: "dueAt",
|
|
filter: { state: "due|sent|acknowledged" },
|
|
type: "dateTime",
|
|
},
|
|
];
|
|
|
|
manager.handleObjectEvent("objectNew", {
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
filter: { state: "draft" },
|
|
});
|
|
await flushPromises();
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
});
|
|
|
|
it("should remove schedulers on objectDelete", () => {
|
|
const manager = createManager();
|
|
manager.startWorker();
|
|
manager.addScheduler(
|
|
new Scheduler({
|
|
id: "scheduler-1",
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
property: "dueAt",
|
|
value: dueAt,
|
|
model: invoiceMongooseModel,
|
|
}),
|
|
);
|
|
|
|
manager.handleObjectEvent("objectDelete", {
|
|
objectType: "invoice",
|
|
object: { _id: "inv-1" },
|
|
});
|
|
|
|
expect(manager.schedulers).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("stop", () => {
|
|
it("should remove listeners and terminate the worker", async () => {
|
|
const manager = createManager();
|
|
await manager.start();
|
|
manager.updateSubscriptions.add("invoice:{}");
|
|
|
|
await manager.stop();
|
|
|
|
expect(manager.updateManager.removeAllListeners).toHaveBeenCalled();
|
|
expect(manager.updateSubscriptions.size).toBe(0);
|
|
expect(workerInstances[0].postMessage).toHaveBeenCalledWith({
|
|
type: "stop",
|
|
});
|
|
expect(workerInstances[0].terminate).toHaveBeenCalled();
|
|
expect(manager.worker).toBeNull();
|
|
});
|
|
});
|
|
});
|