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, }), ]); }); });