The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
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<typeof makePolicy>[];
|
|
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("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);
|
|
});
|
|
});
|