Replaces the single-threaded, 1.5s-sleep-per-part loop with a concurrency model that: * Wraps inner work in PerKeyMutex(accountId) so two reminders on the SAME account take turns (running them concurrently would double the effective send rate and risk a WhatsApp ban). Different accounts run in parallel. * Bumps pg-boss localConcurrency to BOT_FIRE_CONCURRENCY (default 8), so up to 8 different-account reminders can fire simultaneously. * Bulk-loads groups + media in 2 queries (drops ~3000 round-trips to ~3 for a 1000-group run) and pre-creates run_target rows so the Activity tab shows progress mid-run. * Pre-uploads each unique media via MediaUploadCache (one generateWAMessageContent call per mediaId, then relayMessage to every group). For 1000 groups × 5 MB image, this turns 5 GB of upload into 5 MB. * Runs BOT_GROUP_CONCURRENCY (default 3) groups in parallel within one account; parts within a group stay serial so chat order is preserved. * Gates every send on a per-account TokenBucket (BOT_MAX_SEND_PER_MINUTE, default 40). * Replaces the rigid 1.5s inter-part sleep with 200..499 ms jitter. Adds a unit test verifying accountMutex.run is called keyed by accountId for active reminders, and skipped for inactive / missing. Window enforcement, paused/resume, and ETA preview are deferred to later phases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// Mock the per-key mutex module BEFORE importing fire-reminder so the
|
|
// runtime sees our spy when it dereferences `accountMutex.run`.
|
|
vi.mock("./per-key-mutex.js", () => {
|
|
return {
|
|
PerKeyMutex: class {},
|
|
accountMutex: {
|
|
run: vi.fn(async (_key: string, fn: () => Promise<unknown>) => fn()),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Stub everything fire-reminder pulls in so the import succeeds without
|
|
// actually starting a Baileys session, hitting the DB, or talking to
|
|
// pg-boss.
|
|
const getReminderMock = vi.fn();
|
|
vi.mock("../reminders/crud.js", () => ({
|
|
getReminderWithDetails: (...args: unknown[]) => getReminderMock(...args),
|
|
}));
|
|
vi.mock("../db.js", () => ({
|
|
db: {
|
|
insert: () => ({ values: () => ({ returning: async () => [{ id: "run-1" }] }) }),
|
|
update: () => ({ set: () => ({ where: async () => undefined }) }),
|
|
query: {
|
|
whatsappGroups: { findMany: async () => [] },
|
|
mediaFiles: { findMany: async () => [] },
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("../whatsapp/session-manager.js", () => ({
|
|
sessionManager: { getSession: () => null },
|
|
}));
|
|
vi.mock("../ipc/notify.js", () => ({ pgNotifyWeb: vi.fn(async () => undefined) }));
|
|
vi.mock("../audit.js", () => ({ writeAuditLog: vi.fn(async () => undefined) }));
|
|
vi.mock("./pgboss-client.js", () => ({ getBoss: () => ({}) }));
|
|
vi.mock("./reminder-jobs.js", () => ({ scheduleReminderFire: vi.fn() }));
|
|
|
|
import { fireReminder } from "./fire-reminder.js";
|
|
import { accountMutex } from "./per-key-mutex.js";
|
|
|
|
describe("fireReminder", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(accountMutex.run).mockClear();
|
|
getReminderMock.mockReset();
|
|
});
|
|
|
|
it("acquires accountMutex keyed by accountId for active reminders", async () => {
|
|
getReminderMock.mockResolvedValue({
|
|
id: "r-1",
|
|
accountId: "acct-A",
|
|
status: "active",
|
|
targets: [],
|
|
messages: [],
|
|
createdBy: "op-1",
|
|
scheduleKind: "one_off",
|
|
rrule: null,
|
|
timezone: "Asia/Kuala_Lumpur",
|
|
name: "Test",
|
|
});
|
|
|
|
await fireReminder({ reminderId: "r-1" });
|
|
|
|
expect(accountMutex.run).toHaveBeenCalledTimes(1);
|
|
expect(accountMutex.run).toHaveBeenCalledWith("acct-A", expect.any(Function));
|
|
});
|
|
|
|
it("does NOT acquire the mutex when the reminder is inactive", async () => {
|
|
getReminderMock.mockResolvedValue({
|
|
id: "r-1",
|
|
accountId: "acct-A",
|
|
status: "ended",
|
|
targets: [],
|
|
messages: [],
|
|
createdBy: "op-1",
|
|
scheduleKind: "one_off",
|
|
rrule: null,
|
|
timezone: "Asia/Kuala_Lumpur",
|
|
name: "Test",
|
|
});
|
|
|
|
await fireReminder({ reminderId: "r-1" });
|
|
|
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT acquire the mutex when the reminder row is missing", async () => {
|
|
getReminderMock.mockResolvedValue(undefined);
|
|
|
|
await fireReminder({ reminderId: "r-missing" });
|
|
|
|
expect(accountMutex.run).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses different mutex keys for different accounts (cross-account isolation)", async () => {
|
|
getReminderMock.mockResolvedValueOnce({
|
|
id: "r-A",
|
|
accountId: "acct-A",
|
|
status: "active",
|
|
targets: [],
|
|
messages: [],
|
|
createdBy: "op-1",
|
|
scheduleKind: "one_off",
|
|
rrule: null,
|
|
timezone: "Asia/Kuala_Lumpur",
|
|
name: "A",
|
|
});
|
|
getReminderMock.mockResolvedValueOnce({
|
|
id: "r-B",
|
|
accountId: "acct-B",
|
|
status: "active",
|
|
targets: [],
|
|
messages: [],
|
|
createdBy: "op-1",
|
|
scheduleKind: "one_off",
|
|
rrule: null,
|
|
timezone: "Asia/Kuala_Lumpur",
|
|
name: "B",
|
|
});
|
|
|
|
await fireReminder({ reminderId: "r-A" });
|
|
await fireReminder({ reminderId: "r-B" });
|
|
|
|
const calls = vi.mocked(accountMutex.run).mock.calls;
|
|
expect(calls[0]?.[0]).toBe("acct-A");
|
|
expect(calls[1]?.[0]).toBe("acct-B");
|
|
});
|
|
});
|