farmcontrol-scheduler/src/scheduler/__tests__/schedulermanager.test.js
Tom Butcher f9284ed1f2
Some checks failed
farmcontrol/farmcontrol-scheduler/pipeline/head There was a failure building this commit
Initial Commit
2026-09-06 23:06:27 +01:00

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();
});
});
});