cm_whatsapp_bot_v1/apps/bot/src/scheduler/fire-reminder.test.ts
yiekheng c9a7e6f089 feat(bot): cross-account parallel + same-account serial fan-out
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>
2026-05-10 14:44:23 +08:00

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