512 lines
19 KiB
TypeScript
512 lines
19 KiB
TypeScript
import { NotificationJobStatus } from "../../NotificationJobs/NotificationJobs.Interface";
|
|
|
|
// Mock all external dependencies
|
|
jest.mock("../../NotificationJobs/NotificationJobs.Adapter.Mongoose", () => ({
|
|
NotificationJobsAdapterMongoose: jest.fn().mockImplementation(() => ({
|
|
create: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
jest.mock("../PolicyResolver", () => ({
|
|
PolicyResolver: jest.fn().mockImplementation(() => ({
|
|
resolve: jest.fn(),
|
|
resolveAvailableChannels: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
jest.mock("../../PlanSubscriptions/PlanSubscriptons", () => ({
|
|
__esModule: true,
|
|
default: {
|
|
checkFeature: jest.fn(),
|
|
},
|
|
}));
|
|
|
|
import { NotificationJobService } from "../NotificationJobService";
|
|
import { ResolvedPolicy } from "../PolicyResolver";
|
|
|
|
describe("NotificationJobService.createJob", () => {
|
|
let service: NotificationJobService;
|
|
let mockJobCreate: jest.Mock;
|
|
let mockResolverResolve: jest.Mock;
|
|
let mockResolveAvailableChannels: jest.Mock;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jest.useRealTimers();
|
|
|
|
service = new NotificationJobService();
|
|
|
|
mockJobCreate = (service as any).jobAdapter.create as jest.Mock;
|
|
mockResolverResolve = (service as any).policyResolver.resolve as jest.Mock;
|
|
mockResolveAvailableChannels = (service as any).policyResolver.resolveAvailableChannels as jest.Mock;
|
|
});
|
|
|
|
it("creates a job with resolved channels", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["whatsapp", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({
|
|
id: "job-1",
|
|
status: NotificationJobStatus.PENDING,
|
|
});
|
|
|
|
const result = await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
});
|
|
|
|
expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1");
|
|
expect(mockJobCreate).toHaveBeenCalledTimes(2); // one per channel
|
|
expect(result).toHaveLength(2);
|
|
});
|
|
|
|
it("creates reminder jobs for each enabled rule and resolved channel in addition to immediate jobs", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
reminderRules: [
|
|
{ offset: 2, enabled: true },
|
|
{ offset: 5, enabled: true },
|
|
],
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({
|
|
id: "job-1",
|
|
status: NotificationJobStatus.PENDING,
|
|
});
|
|
|
|
const result = await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
appointmentStart: new Date("2026-07-21T14:30:00.000Z"),
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
const immediateJobs = createCalls.filter((job) => job.type === "creation");
|
|
const reminderJobs = createCalls.filter((job) => job.type === "reminder");
|
|
|
|
expect(mockJobCreate).toHaveBeenCalledTimes(6);
|
|
expect(result).toHaveLength(6);
|
|
expect(immediateJobs).toHaveLength(2);
|
|
expect(reminderJobs).toHaveLength(4);
|
|
expect(immediateJobs.map((job) => job.channel).sort()).toEqual(["email", "system"]);
|
|
expect(immediateJobs.map((job) => job.scheduledAt.toISOString())).toEqual([
|
|
"2026-07-21T14:00:00.000Z",
|
|
"2026-07-21T14:00:00.000Z",
|
|
]);
|
|
expect(reminderJobs.map((job) => job.channel).sort()).toEqual([
|
|
"email",
|
|
"email",
|
|
"system",
|
|
"system",
|
|
]);
|
|
expect(reminderJobs.map((job) => job.scheduledAt.toISOString()).sort()).toEqual([
|
|
"2026-07-21T14:25:00.000Z",
|
|
"2026-07-21T14:25:00.000Z",
|
|
"2026-07-21T14:28:00.000Z",
|
|
"2026-07-21T14:28:00.000Z",
|
|
]);
|
|
});
|
|
|
|
it("passes reminder snapshot payload to scheduled reminder jobs", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["whatsapp", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
reminderRules: [{ offset: 30, enabled: true }],
|
|
};
|
|
const payload = { message: "Creation body" };
|
|
const reminderPayload = {
|
|
email: "client@example.com",
|
|
phoneNumber: "5491112345678",
|
|
companyOwnerId: "owner-1",
|
|
emailMessage: "Reminder email body",
|
|
wapMessage: "Reminder WAP body",
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
|
|
payload,
|
|
reminderPayload,
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(createCalls.filter((job) => job.type === "creation")).toEqual([
|
|
expect.objectContaining({ channel: "whatsapp", payload }),
|
|
expect.objectContaining({ channel: "email", payload }),
|
|
]);
|
|
expect(createCalls.filter((job) => job.type === "reminder")).toEqual([
|
|
expect.objectContaining({ channel: "whatsapp", payload: reminderPayload }),
|
|
expect.objectContaining({ channel: "email", payload: reminderPayload }),
|
|
]);
|
|
});
|
|
|
|
it("skips reminder jobs scheduled in the past", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:29:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
reminderRules: [
|
|
{ offset: 2, enabled: true },
|
|
{ offset: 5, enabled: true },
|
|
],
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
appointmentStart: new Date("2026-07-21T14:30:00.000Z"),
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
|
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
|
});
|
|
|
|
it("skips reminder jobs adjusted by quiet hours after appointment start", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T20:00:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
quietHours: { from: "22:00", to: "07:00" },
|
|
reminderRules: [{ offset: 15, enabled: true }],
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
appointmentStart: new Date("2026-07-21T23:30:00.000Z"),
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(createCalls.filter((job) => job.type === "creation")).toHaveLength(2);
|
|
expect(createCalls.filter((job) => job.type === "reminder")).toHaveLength(0);
|
|
});
|
|
|
|
it("creates no jobs when resolved channels are empty", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: [],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
|
|
const result = await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
});
|
|
|
|
expect(mockJobCreate).not.toHaveBeenCalled();
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("creates mandatory creation jobs from available payload destinations without resolving reminder policy", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
mockResolveAvailableChannels.mockResolvedValue(["email", "whatsapp"]);
|
|
const payload = {
|
|
email: "client@example.com",
|
|
phoneNumber: "5491112345678",
|
|
companyOwnerId: "owner-1",
|
|
emailMessage: "Alta email",
|
|
wapMessage: "Alta WAP",
|
|
};
|
|
|
|
const result = await service.createMandatoryCreationJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
payload,
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(mockResolverResolve).not.toHaveBeenCalled();
|
|
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", [
|
|
"email",
|
|
"whatsapp",
|
|
]);
|
|
expect(result).toHaveLength(2);
|
|
expect(createCalls).toEqual([
|
|
expect.objectContaining({
|
|
channel: "email",
|
|
type: "creation",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
payload,
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "whatsapp",
|
|
type: "creation",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
payload,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("does not create mandatory creation jobs for channels missing destination data", async () => {
|
|
mockResolveAvailableChannels.mockResolvedValue([]);
|
|
|
|
await service.createMandatoryCreationJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
payload: {
|
|
companyOwnerId: "owner-1",
|
|
emailMessage: "Alta email",
|
|
wapMessage: "Alta WAP",
|
|
},
|
|
});
|
|
|
|
expect(mockResolverResolve).not.toHaveBeenCalled();
|
|
expect(mockResolveAvailableChannels).toHaveBeenCalledWith("company-1", []);
|
|
expect(mockJobCreate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not create mandatory creation jobs for unavailable channels", async () => {
|
|
mockResolveAvailableChannels.mockResolvedValue(["email"]);
|
|
const payload = {
|
|
email: "client@example.com",
|
|
phoneNumber: "5491112345678",
|
|
companyOwnerId: "owner-1",
|
|
};
|
|
|
|
const result = await service.createMandatoryCreationJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
payload,
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(result).toHaveLength(1);
|
|
expect(createCalls).toEqual([
|
|
expect.objectContaining({ channel: "email", type: "creation", payload }),
|
|
]);
|
|
});
|
|
|
|
it("keeps reminder jobs policy-aware when resolved channels are empty", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: [],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
reminderRules: [{ offset: 30, enabled: true }],
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
|
|
const result = await service.createReminderJobs({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "reminder",
|
|
appointmentStart: new Date("2026-07-21T15:00:00.000Z"),
|
|
reminderPayload: { email: "client@example.com" },
|
|
});
|
|
|
|
expect(mockResolverResolve).toHaveBeenCalledWith("company-1", "client-1");
|
|
expect(mockJobCreate).not.toHaveBeenCalled();
|
|
expect(result).toHaveLength(0);
|
|
});
|
|
|
|
it("adjusts scheduledAt for quiet hours crossing midnight", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
quietHours: { from: "22:00", to: "07:00" },
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
// scheduledAt at 23:30 UTC — should be adjusted to 07:00 next day UTC
|
|
const scheduledAt = new Date("2026-07-21T23:30:00.000Z");
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
scheduledAt,
|
|
});
|
|
|
|
const createCall = mockJobCreate.mock.calls[0][0];
|
|
// 23:30 is inside quiet hours (22:00-07:00), adjusted to 07:00 next day
|
|
expect(createCall.scheduledAt.getUTCHours()).toBe(7);
|
|
expect(createCall.scheduledAt.getUTCDate()).toBe(22); // next day
|
|
});
|
|
|
|
it("adjusts early morning quiet hours crossing midnight to the same day", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
quietHours: { from: "22:00", to: "07:00" },
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
scheduledAt: new Date("2026-07-21T05:00:00.000Z"),
|
|
});
|
|
|
|
const createCall = mockJobCreate.mock.calls[0][0];
|
|
expect(createCall.scheduledAt.toISOString()).toBe("2026-07-21T07:00:00.000Z");
|
|
});
|
|
|
|
it("does not adjust scheduledAt when outside quiet hours", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
quietHours: { from: "22:00", to: "07:00" },
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
// scheduledAt at 14:00 UTC — outside quiet hours (22:00-07:00)
|
|
const scheduledAt = new Date("2026-07-21T14:00:00.000Z");
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "creation",
|
|
scheduledAt,
|
|
});
|
|
|
|
const createCall = mockJobCreate.mock.calls[0][0];
|
|
// 14:00 is outside quiet hours, should remain unchanged
|
|
expect(createCall.scheduledAt.getUTCHours()).toBe(14);
|
|
expect(createCall.scheduledAt.getUTCDate()).toBe(21);
|
|
});
|
|
|
|
it("passes appointmentId and type to job creation", async () => {
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
const scheduledAt = new Date("2026-07-21T15:00:00.000Z");
|
|
|
|
await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-42",
|
|
type: "reminder",
|
|
scheduledAt,
|
|
});
|
|
|
|
const createCall = mockJobCreate.mock.calls[0][0];
|
|
expect(createCall.appointmentId).toBe("appt-42");
|
|
expect(createCall.type).toBe("reminder");
|
|
expect(createCall.companyId).toBe("company-1");
|
|
expect(createCall.clientId).toBe("client-1");
|
|
});
|
|
|
|
it("creates immediate jobs without reminder offsets", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
reminderRules: [{ offset: 30, enabled: true }],
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
const result = await service.createImmediateJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "reminder",
|
|
appointmentStart: new Date("2026-07-21T16:00:00.000Z"),
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(result).toHaveLength(2);
|
|
expect(mockJobCreate).toHaveBeenCalledTimes(2);
|
|
expect(createCalls).toEqual([
|
|
expect.objectContaining({
|
|
channel: "system",
|
|
type: "reminder",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "email",
|
|
type: "reminder",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it("creates immediate cancellation jobs with snapshot payload for each resolved channel", async () => {
|
|
jest.useFakeTimers().setSystemTime(new Date("2026-07-21T14:00:00.000Z"));
|
|
const resolvedPolicy: ResolvedPolicy = {
|
|
channels: ["system", "email"],
|
|
timezone: "America/Argentina/Buenos_Aires",
|
|
};
|
|
const payload = {
|
|
email: "client@example.com",
|
|
phoneNumber: "5491112345678",
|
|
userId: "user-1",
|
|
companyOwnerId: "owner-1",
|
|
subject: "Turno cancelado en Barber Shop",
|
|
message: "Tu turno fue cancelado.",
|
|
};
|
|
mockResolverResolve.mockResolvedValue(resolvedPolicy);
|
|
mockJobCreate.mockResolvedValue({ id: "job-1" });
|
|
|
|
const result = await service.createJob({
|
|
companyId: "company-1",
|
|
clientId: "client-1",
|
|
appointmentId: "appt-1",
|
|
type: "cancellation",
|
|
scheduledAt: new Date(),
|
|
payload,
|
|
});
|
|
|
|
const createCalls = mockJobCreate.mock.calls.map((call) => call[0]);
|
|
expect(result).toHaveLength(2);
|
|
expect(createCalls).toEqual([
|
|
expect.objectContaining({
|
|
appointmentId: "appt-1",
|
|
channel: "system",
|
|
type: "cancellation",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
payload,
|
|
}),
|
|
expect.objectContaining({
|
|
appointmentId: "appt-1",
|
|
channel: "email",
|
|
type: "cancellation",
|
|
scheduledAt: new Date("2026-07-21T14:00:00.000Z"),
|
|
payload,
|
|
}),
|
|
]);
|
|
});
|
|
});
|