import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/src/lib/prisma", () => ({ prisma: { marketingNode: { findMany: vi.fn() }, business: { findUnique: vi.fn() }, marketingConsent: { findMany: vi.fn() }, courseBooking: { findMany: vi.fn() }, marketingSend: { findMany: vi.fn(), create: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn(), }, discountCode: { create: vi.fn(), findUnique: vi.fn() }, }, })); vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() })); import { Prisma } from "@/src/generated/prisma/client"; import { prisma } from "@/src/lib/prisma"; import { sendMail } from "@/src/lib/mail/resend"; import { runMarketing } from "@/src/modules/marketing/lib/run-marketing"; import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config"; const TODAY = "2026-05-12"; const birthdayTrigger = { id: "trigger-1", businessId: "biz-1", kind: "birthday", config: {}, activatedAt: new Date("2026-01-01T10:00:00Z"), emailNodeId: "mail-1", }; function setup({ withReward = true } = {}) { vi.mocked(prisma.marketingNode.findMany).mockImplementation(((args: { where: { kind: unknown }; }) => Promise.resolve( args.where.kind === "email" ? [ { id: "mail-1", config: withReward ? EMAIL_TEMPLATES.birthday : { ...EMAIL_TEMPLATES.birthday, reward: null }, }, ] : [birthdayTrigger] )) as never); vi.mocked(prisma.business.findUnique).mockResolvedValue({ id: "biz-1", name: "Pilates Loft", email: "hallo@loft.de", slug: "loft", isPublic: true, branding: null, } as never); vi.mocked(prisma.marketingConsent.findMany).mockResolvedValue([ { user: { id: "user-1", email: "anna@example.com", name: "Anna Schmidt", birthday: new Date("1990-05-12"), }, }, ] as never); vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([]); vi.mocked(prisma.marketingSend.findMany).mockResolvedValue([]); vi.mocked(prisma.marketingSend.create).mockResolvedValue({ id: "send-1", discountCodeId: null, } as never); vi.mocked(prisma.marketingSend.update).mockResolvedValue({} as never); vi.mocked(prisma.discountCode.create).mockImplementation(((args: { data: { code: string; expiresAt: Date }; }) => Promise.resolve({ id: "code-1", code: args.data.code, expiresAt: args.data.expiresAt, })) as never); vi.mocked(sendMail).mockResolvedValue({ success: true, messageId: "m1" } as never); } async function run(options: Parameters[0]) { const promise = runMarketing(options); await vi.runAllTimersAsync(); return promise; } beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); process.env.MARKETING_TOKEN_SECRET = "test-secret"; process.env.NEXT_PUBLIC_APP_URL = "https://app.kumasy.de"; setup(); }); afterEach(() => { vi.useRealTimers(); }); describe("runMarketing", () => { it("verschickt die Geburtstagsmail mit persönlichem Code", async () => { const report = await run({ today: TODAY }); expect(report.businesses[0]).toMatchObject({ due: 1, sent: 1, failed: 0 }); expect(prisma.marketingSend.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ businessId: "biz-1", userId: "user-1", triggerKind: "birthday", periodKey: "2026", status: "pending", }), }) ); const codeData = vi.mocked(prisma.discountCode.create).mock.calls[0][0].data; expect(codeData).toMatchObject({ businessId: "biz-1", restrictedToUserId: "user-1", origin: "marketing", maxRedemptions: 1, appliesTo: "all", type: "percent", value: 15, }); expect(codeData.code).toMatch(/^GEBURTSTAG-[A-Z2-9]{6}$/); const mail = vi.mocked(sendMail).mock.calls[0][0]; expect(mail).toMatchObject({ to: "anna@example.com", subject: "Alles Gute zum Geburtstag, Anna!", fromName: "Pilates Loft über Kumasy", replyTo: "hallo@loft.de", idempotencyKey: "marketing-send-1", }); expect(mail.headers?.["List-Unsubscribe"]).toMatch( /^ { setup({ withReward: false }); await run({ today: TODAY }); expect(prisma.discountCode.create).not.toHaveBeenCalled(); expect(sendMail).toHaveBeenCalledTimes(1); }); it("zeigt im Probelauf nur, wer was bekäme", async () => { const report = await run({ today: TODAY, dryRun: true }); expect(report.businesses[0].items).toEqual([ expect.objectContaining({ trigger: "birthday", customer: "anna@example.com", withCode: true, }), ]); expect(prisma.marketingSend.create).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled(); }); it("verschickt nichts zweimal", async () => { vi.mocked(prisma.marketingSend.findMany).mockImplementation(((args: { where: { status?: string }; }) => Promise.resolve( args.where.status ? [] : [ { userId: "user-1", triggerKind: "birthday", periodKey: "2026", status: "sent", attempts: 1, }, ] )) as never); const report = await run({ today: TODAY }); expect(report.businesses[0].due).toBe(0); expect(sendMail).not.toHaveBeenCalled(); }); it("überlässt eine Zeile, die ein paralleler Lauf hält, diesem Lauf", async () => { vi.mocked(prisma.marketingSend.create).mockRejectedValue( new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { code: "P2002", clientVersion: "test", }) ); vi.mocked(prisma.marketingSend.findUnique).mockResolvedValue({ id: "send-1", discountCodeId: null, } as never); vi.mocked(prisma.marketingSend.updateMany).mockResolvedValue({ count: 0 }); const report = await run({ today: TODAY }); expect(report.businesses[0]).toMatchObject({ sent: 0, skipped: 1 }); expect(sendMail).not.toHaveBeenCalled(); }); it("vermerkt einen gescheiterten Versand", async () => { vi.mocked(sendMail).mockResolvedValue({ success: false, error: new Error("rate_limit_exceeded"), } as never); vi.spyOn(console, "error").mockImplementation(() => {}); const report = await run({ today: TODAY }); expect(report.businesses[0]).toMatchObject({ sent: 0, failed: 1 }); expect(prisma.marketingSend.update).toHaveBeenLastCalledWith({ where: { id: "send-1" }, data: { status: "failed", error: "rate_limit_exceeded" }, }); }); it("schreibt ohne Einwilligungen niemanden an", async () => { vi.mocked(prisma.marketingConsent.findMany).mockResolvedValue([]); const report = await run({ today: TODAY }); expect(report.businesses[0]).toMatchObject({ consentingCustomers: 0, due: 0 }); expect(prisma.courseBooking.findMany).not.toHaveBeenCalled(); }); it("verschickt ohne Secret für den Abmeldelink nichts", async () => { delete process.env.MARKETING_TOKEN_SECRET; await expect(runMarketing({ today: TODAY })).rejects.toThrow(/MARKETING_TOKEN_SECRET/); }); });