import { RenewalsService } from "./renewals.service"; /** * The renewal sweep's half of the unified notification log. * * `RenewalNotice` only records that a policy WAS notified — it has no way to * say a send failed or that a customer had no address. Those rows exist only * in `email_notification_log`, so they are what these tests pin down. */ const POLICY_ID = "policy-1"; const CUSTOMER_ID = "cust-1"; function makePolicy(email: string | null) { return { id: POLICY_ID, policyNumber: "700442181", policyTo: new Date("2026-09-01T00:00:00.000Z"), netPremium: null, policyFee: null, total: null, currency: "MXN", coveragesJson: null, customer: { id: CUSTOMER_ID, name: "ACME SA DE CV", nameMissing: false, email, phone: null, mobile: null, addressLine1: null, addressLine2: null, city: null, state: null, zipCode: null, country: null, }, policyType: { name: "AUTO" }, insuranceProvider: { name: "GMX" }, vehicles: [], renewalNotices: [], }; } function build(overrides: { policies?: ReturnType[]; sendImpl?: () => Promise<{ messageId: string; response: string }>; }) { const policies = overrides.policies ?? [makePolicy("cliente@example.com")]; const record = jest.fn().mockResolvedValue(undefined); const send = overrides.sendImpl ?? jest.fn().mockResolvedValue({ messageId: "ses-1", response: "{}" }); const prisma = { // Only generation 1 has a candidate; the other two cadences return none, // so a sweep produces exactly one outcome to assert on. policy: { findMany: jest .fn() .mockResolvedValueOnce(policies) .mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(policies[0]), }, renewalNotice: { upsert: jest.fn().mockResolvedValue({}) }, scheduledJobState: { upsert: jest.fn().mockResolvedValue({}), updateMany: jest.fn().mockResolvedValue({ count: 1 }), findUniqueOrThrow: jest.fn().mockResolvedValue({ lastSuccessfulAt: null }), update: jest.fn().mockResolvedValue({}), }, }; // `register` is a no-op here: these tests drive the sweep directly, so no // cron job is ever installed. const schedule = { register: jest.fn().mockResolvedValue(undefined) }; const service = new RenewalsService( prisma as never, { available: true, send } as never, { log: jest.fn() } as never, { record } as never, schedule as never, ); return { service, record, send, prisma }; } describe("renewal notices write the shared notification log", () => { it("records a SENT row tagged RENEWAL_NOTICE / POLICIES", async () => { const { service, record, prisma } = build({}); await service.sweep("user-1"); expect(record).toHaveBeenCalledTimes(1); const row = record.mock.calls[0][0]; expect(row).toMatchObject({ notificationType: "RENEWAL_NOTICE", servicio: "POLICIES", status: "SENT", customerId: CUSTOMER_ID, customerEmail: "cliente@example.com", providerMessageId: "ses-1", debug: false, }); // `level` carries the aviso generation, not an alert colour. expect(row.level).toBe(1); expect(row.subject).toContain("700442181"); expect(row.bodySnapshot).toContain("ACME SA DE CV"); // The gating row is still written — the log does not replace it. expect(prisma.renewalNotice.upsert).toHaveBeenCalledTimes(1); }); it("records a FAILED row and no gating row when the send throws", async () => { const { service, record, prisma } = build({ sendImpl: jest.fn().mockRejectedValue(new Error("SES rejected")), }); const result = await service.sweep("user-1"); expect(result.sent).toBe(0); expect(result.failed).toBe(1); expect(record).toHaveBeenCalledTimes(1); expect(record.mock.calls[0][0]).toMatchObject({ status: "FAILED", error: "SES rejected", notificationType: "RENEWAL_NOTICE", }); // Nothing was delivered, so nothing may gate tomorrow's retry. expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); }); it("records SKIPPED_NO_EMAIL for a candidate with no address", async () => { const { service, record, send, prisma } = build({ policies: [makePolicy(" ")], }); const result = await service.sweep("user-1"); expect(result.skipped).toBe(1); expect(send).not.toHaveBeenCalled(); expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); expect(record.mock.calls[0][0]).toMatchObject({ status: "SKIPPED_NO_EMAIL", customerEmail: "", }); }); it("diverts a debug sweep and leaves the notice pending", async () => { const { service, record, send, prisma } = build({}); const result = await service.sweep("user-1", { debug: true }); expect(result.sent).toBe(1); expect(result.debug).toBe(true); // The customer's own address is never contacted. expect(jest.mocked(send).mock.calls[0][0]).toMatchObject({ to: "rmancinas@freakma.net", xTracking: "debug", }); expect(record.mock.calls[0][0]).toMatchObject({ status: "SENT", customerEmail: "rmancinas@freakma.net", debug: true, }); // The letter is still owed, so nothing may gate it: no RenewalNotice row, // and `lastSuccessfulAt` must not advance past the days we only tested. expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; expect(release.data.lastSuccessfulAt).toBeUndefined(); }); it("sends one notice on demand in debug without marking it sent", async () => { const { service, send, prisma } = build({}); const result = await service.sendOne(POLICY_ID, 1, "user-1", { debug: true, }); expect(result.debug).toBe(true); expect(result.to).toBe("rmancinas@freakma.net"); expect(send).toHaveBeenCalledTimes(1); expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); }); it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => { const manual = build({}); await manual.service.sweep("user-1"); expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima"); const automatic = build({}); await automatic.service.scheduledSweep(); const body = automatic.record.mock.calls[0][0].bodySnapshot; // The snapshot has to match the mail that actually went out, or the // office reads a letter the customer never received. expect(body).not.toContain("Prima"); expect(body).toContain("700442181"); }); it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => { const { service, prisma, send } = build({}); const result = await service.sweep("user-1", { providerId: "gmx-id" }); expect(result.sent).toBe(1); expect(result.providerId).toBe("gmx-id"); expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({ insuranceProviderId: "gmx-id", }); expect(send).toHaveBeenCalledTimes(1); // Only one carrier was mailed, so the days this run covered are still owed // to every other carrier: advancing `lastSuccessfulAt` would move them out // of tomorrow's window and they would never be sent. const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; expect(release.data.lastSuccessfulAt).toBeUndefined(); }); it("advances the catch-up window on a clean unfiltered sweep", async () => { const { service, prisma } = build({}); await service.sweep("user-1"); expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty( "insuranceProviderId", ); const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date); }); it("does not fail a delivered notice when the log write throws", async () => { const { service, record } = build({}); record.mockRejectedValue(new Error("log table gone")); const result = await service.sweep("user-1"); // The mail went out and the gating row was written; a lost audit row must // not report that as a failure, which would re-send tomorrow. expect(result.sent).toBe(1); expect(result.failed).toBe(0); }); });