feat: implement comprehensive notification engine with policies, preferences, and automated dispatching services
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import axios from "axios";
|
||||
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
|
||||
|
||||
jest.mock("axios");
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe("ChannelDispatchers", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.API_URL = "https://api.example.com/base";
|
||||
process.env.API_KEY = "system-token";
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { success: true },
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {},
|
||||
config: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the job type in the email notification payload", async () => {
|
||||
await dispatchEmail({
|
||||
appointmentId: "appointment-1",
|
||||
systemToken: "system-token",
|
||||
type: "creation",
|
||||
email: "client@example.com",
|
||||
subject: "Appointment created",
|
||||
message: "Creation message",
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
"https://api.example.com/notifications/send-email",
|
||||
{
|
||||
systemToken: "system-token",
|
||||
email: "client@example.com",
|
||||
subject: "Appointment created",
|
||||
message: "Creation message",
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the job type in the WhatsApp system notification payload", async () => {
|
||||
await dispatchWhatsApp({
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
type: "creation",
|
||||
systemToken: "system-token",
|
||||
phoneNumber: "5491112345678",
|
||||
message: "Creation message",
|
||||
companyOwnerId: "owner-1",
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
"https://api.example.com/notifications/send-wap",
|
||||
{
|
||||
systemToken: "system-token",
|
||||
phoneNumber: "5491112345678",
|
||||
message: "Creation message",
|
||||
companyId: "company-1",
|
||||
sessionUser: "owner-1",
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { dispatchEmail, dispatchWhatsApp } from "../ChannelDispatchers.js";
|
||||
import { buildPendingJobQuery, calculateNextRetryAt, JobProcessor } from "../JobProcessor.js";
|
||||
import { resolveNotificationContent } from "../NotificationContentResolver.js";
|
||||
|
||||
jest.mock("../ChannelDispatchers.js", () => ({
|
||||
dispatchWhatsApp: jest.fn(),
|
||||
dispatchEmail: jest.fn(),
|
||||
dispatchSystem: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../NotificationContentResolver.js", () => ({
|
||||
resolveNotificationContent: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("JobProcessor", () => {
|
||||
describe("calculateNextRetryAt", () => {
|
||||
it("calculates exponential backoff for attempt 1", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
const result = calculateNextRetryAt(1, now);
|
||||
|
||||
const minExpected = now.getTime() + 5000;
|
||||
const maxExpected = now.getTime() + 5000 + 3000;
|
||||
|
||||
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
|
||||
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
|
||||
});
|
||||
|
||||
it("calculates exponential backoff for attempt 2", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
const result = calculateNextRetryAt(2, now);
|
||||
|
||||
const minExpected = now.getTime() + 10000;
|
||||
const maxExpected = now.getTime() + 10000 + 3000;
|
||||
|
||||
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
|
||||
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
|
||||
});
|
||||
|
||||
it("calculates exponential backoff for attempt 3", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
const result = calculateNextRetryAt(3, now);
|
||||
|
||||
const minExpected = now.getTime() + 20000;
|
||||
const maxExpected = now.getTime() + 20000 + 3000;
|
||||
|
||||
expect(result.getTime()).toBeGreaterThanOrEqual(minExpected);
|
||||
expect(result.getTime()).toBeLessThanOrEqual(maxExpected);
|
||||
});
|
||||
|
||||
it("returns a Date in the future", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
const result = calculateNextRetryAt(1, now);
|
||||
expect(result.getTime()).toBeGreaterThan(now.getTime());
|
||||
});
|
||||
|
||||
it("uses base delay for attempt 0 as fallback", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
const result = calculateNextRetryAt(1, now);
|
||||
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backoff math correctness", () => {
|
||||
it("exponential growth doubles each attempt", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
|
||||
const r1 = calculateNextRetryAt(1, now).getTime() - now.getTime();
|
||||
const r2 = calculateNextRetryAt(2, now).getTime() - now.getTime();
|
||||
const r3 = calculateNextRetryAt(3, now).getTime() - now.getTime();
|
||||
|
||||
// Without jitter: 5000, 10000, 20000
|
||||
// With jitter up to 3000ms, the growth pattern should still hold
|
||||
// Min possible: 5000, 10000, 20000
|
||||
expect(r1).toBeGreaterThanOrEqual(5000);
|
||||
expect(r2).toBeGreaterThanOrEqual(10000);
|
||||
expect(r3).toBeGreaterThanOrEqual(20000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPendingJobQuery", () => {
|
||||
it("only selects due jobs whose retry window is missing, null, or due", () => {
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
|
||||
expect(buildPendingJobQuery(now)).toEqual({
|
||||
status: "pending",
|
||||
scheduledAt: { $lte: now },
|
||||
$or: [
|
||||
{ nextRetryAt: { $exists: false } },
|
||||
{ nextRetryAt: null },
|
||||
{ nextRetryAt: { $lte: now } },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WhatsApp dispatch", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(resolveNotificationContent as jest.Mock).mockResolvedValue({
|
||||
subject: "Appointment reminder",
|
||||
message: "Reminder message",
|
||||
phoneNumber: "5491112345678",
|
||||
companyOwnerId: "owner-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the job appointmentId and type to the WhatsApp dispatcher", async () => {
|
||||
const exec = jest.fn().mockResolvedValue({});
|
||||
const updateOne = jest.fn(() => ({ exec }));
|
||||
const recordSent = jest.fn();
|
||||
|
||||
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
|
||||
|
||||
await (JobProcessor.prototype as any).processJob.call(
|
||||
{
|
||||
jobModel: { notificationJobList: { updateOne } },
|
||||
throttle: { canSend: jest.fn(() => true), recordSent },
|
||||
handleFailure: jest.fn(),
|
||||
},
|
||||
{
|
||||
_id: "job-1",
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
channel: "whatsapp",
|
||||
type: "creation",
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
"company-1"
|
||||
);
|
||||
|
||||
expect(dispatchWhatsApp).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
type: "creation",
|
||||
systemToken: `${process.env.API_KEY}`,
|
||||
phoneNumber: "5491112345678",
|
||||
message: "Reminder message",
|
||||
companyOwnerId: "owner-1",
|
||||
});
|
||||
expect(recordSent).toHaveBeenCalledWith("company-1");
|
||||
});
|
||||
|
||||
it("preserves cancellation WhatsApp job types", async () => {
|
||||
const exec = jest.fn().mockResolvedValue({});
|
||||
const updateOne = jest.fn(() => ({ exec }));
|
||||
|
||||
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
|
||||
|
||||
await (JobProcessor.prototype as any).processJob.call(
|
||||
{
|
||||
jobModel: { notificationJobList: { updateOne } },
|
||||
throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() },
|
||||
handleFailure: jest.fn(),
|
||||
},
|
||||
{
|
||||
_id: "job-1",
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
channel: "whatsapp",
|
||||
type: "cancellation",
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
"company-1"
|
||||
);
|
||||
|
||||
expect(dispatchWhatsApp).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
type: "cancellation",
|
||||
systemToken: `${process.env.API_KEY}`,
|
||||
phoneNumber: "5491112345678",
|
||||
message: "Reminder message",
|
||||
companyOwnerId: "owner-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record a WhatsApp send when dispatch fails", async () => {
|
||||
const exec = jest.fn().mockResolvedValue({});
|
||||
const updateOne = jest.fn(() => ({ exec }));
|
||||
const recordSent = jest.fn();
|
||||
|
||||
(dispatchWhatsApp as jest.Mock).mockResolvedValue({ success: false, error: "provider failed" });
|
||||
|
||||
await (JobProcessor.prototype as any).processJob.call(
|
||||
{
|
||||
jobModel: { notificationJobList: { updateOne } },
|
||||
throttle: { canSend: jest.fn(() => true), recordSent },
|
||||
handleFailure: jest.fn(),
|
||||
},
|
||||
{
|
||||
_id: "job-1",
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
channel: "whatsapp",
|
||||
type: "reminder",
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
"company-1"
|
||||
);
|
||||
|
||||
expect(recordSent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Email dispatch", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(resolveNotificationContent as jest.Mock).mockResolvedValue({
|
||||
email: "client@example.com",
|
||||
subject: "Appointment created",
|
||||
message: "Creation message",
|
||||
});
|
||||
(dispatchEmail as jest.Mock).mockResolvedValue({ success: true, providerResponse: "{}" });
|
||||
});
|
||||
|
||||
it("passes creation job type to the email dispatcher", async () => {
|
||||
const exec = jest.fn().mockResolvedValue({});
|
||||
const updateOne = jest.fn(() => ({ exec }));
|
||||
|
||||
await (JobProcessor.prototype as any).processJob.call(
|
||||
{
|
||||
jobModel: { notificationJobList: { updateOne } },
|
||||
throttle: { canSend: jest.fn(() => true), recordSent: jest.fn() },
|
||||
handleFailure: jest.fn(),
|
||||
},
|
||||
{
|
||||
_id: "job-1",
|
||||
companyId: "company-1",
|
||||
appointmentId: "appointment-1",
|
||||
channel: "email",
|
||||
type: "creation",
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
"company-1"
|
||||
);
|
||||
|
||||
expect(dispatchEmail).toHaveBeenCalledWith({
|
||||
appointmentId: "appointment-1",
|
||||
systemToken: `${process.env.API_KEY}`,
|
||||
type: "creation",
|
||||
email: "client@example.com",
|
||||
subject: "Appointment created",
|
||||
message: "Creation message",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import mongoose from "mongoose";
|
||||
import { buildNotificationText, resolveNotificationContent } from "../NotificationContentResolver.js";
|
||||
|
||||
describe("NotificationContentResolver", () => {
|
||||
it("builds appointment creation subject and message as Alta de turno", () => {
|
||||
const result = buildNotificationText({
|
||||
type: "creation",
|
||||
appointment: {
|
||||
_id: "appointment-1",
|
||||
companyId: "company-1",
|
||||
start: new Date("2026-07-22T15:30:00.000Z"),
|
||||
},
|
||||
service: { _id: "service-1", name: "Corte" },
|
||||
company: { _id: "company-1", name: "Barber Shop" },
|
||||
});
|
||||
|
||||
expect(result.subject).toContain("Alta de turno");
|
||||
expect(result.subject).toContain("Barber Shop");
|
||||
expect(result.message).toContain("Corte");
|
||||
expect(result.message).toContain("confirmado");
|
||||
});
|
||||
|
||||
it("builds meaningful appointment reminder subject and message", () => {
|
||||
const result = buildNotificationText({
|
||||
type: "reminder",
|
||||
appointment: {
|
||||
_id: "appointment-1",
|
||||
companyId: "company-1",
|
||||
start: new Date("2026-07-22T15:30:00.000Z"),
|
||||
},
|
||||
service: { _id: "service-1", name: "Corte" },
|
||||
company: { _id: "company-1", name: "Barber Shop" },
|
||||
});
|
||||
|
||||
expect(result.subject).toContain("Recordatorio");
|
||||
expect(result.subject).toContain("Barber Shop");
|
||||
expect(result.message).toContain("Corte");
|
||||
expect(result.message).toContain("Barber Shop");
|
||||
expect(result.subject.trim()).not.toBe("");
|
||||
expect(result.message.trim()).not.toBe("");
|
||||
});
|
||||
|
||||
it("uses cancellation snapshot payload without requiring appointment lookup", async () => {
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "deleted-appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "email",
|
||||
type: "cancellation",
|
||||
payload: {
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Turno cancelado en Barber Shop",
|
||||
message: "Tu turno para Corte fue cancelado.",
|
||||
emailSubject: "Email cancellation subject",
|
||||
emailMessage: "Email cancellation body.",
|
||||
wapMessage: "WAP cancellation body.",
|
||||
systemSubject: "System cancellation subject",
|
||||
systemMessage: "System cancellation body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Email cancellation subject",
|
||||
message: "Email cancellation body.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses creation snapshot payload content for email jobs", async () => {
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "email",
|
||||
type: "creation",
|
||||
payload: {
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Turno reservado en Barber Shop",
|
||||
message: "Generic creation body.",
|
||||
emailSubject: "Email Alta subject",
|
||||
emailMessage: "Email Alta body.",
|
||||
wapMessage: "WAP Alta body.",
|
||||
systemSubject: "System Alta subject",
|
||||
systemMessage: "System Alta body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Email Alta subject",
|
||||
message: "Email Alta body.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses creation snapshot payload content for WhatsApp jobs", async () => {
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "whatsapp",
|
||||
type: "creation",
|
||||
payload: {
|
||||
phoneNumber: "5491112345678",
|
||||
subject: "Generic subject",
|
||||
message: "Generic message.",
|
||||
emailMessage: "Email Alta body.",
|
||||
wapMessage: "WAP Alta body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result.message).toBe("WAP Alta body.");
|
||||
});
|
||||
|
||||
it("uses reminder payload content without requiring appointment lookup", async () => {
|
||||
const appointmentLookup = jest.spyOn(mongoose.models.Appointment, "findOne");
|
||||
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "whatsapp",
|
||||
type: "reminder",
|
||||
payload: {
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Generic reminder subject",
|
||||
message: "Generic reminder body.",
|
||||
emailSubject: "Email reminder subject",
|
||||
emailMessage: "Email reminder body.",
|
||||
wapMessage: "WAP reminder body.",
|
||||
systemSubject: "System reminder subject",
|
||||
systemMessage: "System reminder body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(appointmentLookup).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
email: "client@example.com",
|
||||
phoneNumber: "5491112345678",
|
||||
userId: "user-1",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Generic reminder subject",
|
||||
message: "WAP reminder body.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses WhatsApp cancellation payload content for WhatsApp jobs", async () => {
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "deleted-appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "whatsapp",
|
||||
type: "cancellation",
|
||||
payload: {
|
||||
phoneNumber: "5491112345678",
|
||||
companyOwnerId: "owner-1",
|
||||
subject: "Generic subject",
|
||||
message: "Generic message.",
|
||||
emailMessage: "Email cancellation body.",
|
||||
wapMessage: "WAP cancellation body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result.message).toBe("WAP cancellation body.");
|
||||
});
|
||||
|
||||
it("uses system cancellation payload content for system jobs", async () => {
|
||||
const result = await resolveNotificationContent({
|
||||
appointmentId: "deleted-appointment-1",
|
||||
clientId: "client-1",
|
||||
companyId: "company-1",
|
||||
channel: "system",
|
||||
type: "cancellation",
|
||||
payload: {
|
||||
userId: "user-1",
|
||||
subject: "Generic subject",
|
||||
message: "Generic message.",
|
||||
systemSubject: "System cancellation subject",
|
||||
systemMessage: "System cancellation body.",
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(result.subject).toBe("System cancellation subject");
|
||||
expect(result.message).toBe("System cancellation body.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
describe("WhatsAppThrottle", () => {
|
||||
let WhatsAppThrottle: typeof import("../WhatsAppThrottle.js").WhatsAppThrottleClass;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("../WhatsAppThrottle.js");
|
||||
WhatsAppThrottle = mod.WhatsAppThrottleClass;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows first send for a company immediately", () => {
|
||||
const throttle = new WhatsAppThrottle(8000);
|
||||
expect(throttle.canSend("company-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks send within minimum interval", () => {
|
||||
const throttle = new WhatsAppThrottle(8000);
|
||||
throttle.recordSent("company-1");
|
||||
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2026-07-21T12:00:03.000Z")); // 3s later
|
||||
|
||||
expect(throttle.canSend("company-1")).toBe(false);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("allows send after maximum possible interval (base + jitter)", () => {
|
||||
const throttle = new WhatsAppThrottle(8000);
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
throttle.recordSent("company-1");
|
||||
|
||||
// After 16s (max = 8s base + 8s jitter), should always be allowed
|
||||
jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z"));
|
||||
|
||||
expect(throttle.canSend("company-1")).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("tracks companies independently", () => {
|
||||
const throttle = new WhatsAppThrottle(8000);
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
throttle.recordSent("company-1");
|
||||
|
||||
// company-2 should still be allowed
|
||||
expect(throttle.canSend("company-2")).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("applies jitter to minimum interval", () => {
|
||||
const throttle = new WhatsAppThrottle(8000);
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
throttle.recordSent("company-1");
|
||||
|
||||
// At exactly 8s (base interval), it should still be blocked due to jitter
|
||||
jest.setSystemTime(new Date("2026-07-21T12:00:08.000Z"));
|
||||
expect(throttle.canSend("company-1")).toBe(false);
|
||||
|
||||
// At 16s (max interval = base + jitter), it should be allowed
|
||||
jest.setSystemTime(new Date("2026-07-21T12:00:16.000Z"));
|
||||
expect(throttle.canSend("company-1")).toBe(true);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses default interval of 8000ms when not specified", () => {
|
||||
const throttle = new WhatsAppThrottle();
|
||||
const now = new Date("2026-07-21T12:00:00.000Z");
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now);
|
||||
|
||||
throttle.recordSent("company-1");
|
||||
|
||||
// 7s should be blocked
|
||||
jest.setSystemTime(new Date("2026-07-21T12:00:07.000Z"));
|
||||
expect(throttle.canSend("company-1")).toBe(false);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user