Kuma/tests/marketing/run-marketing.test.ts
2026-10-02 17:31:08 +02:00

247 lines
7.8 KiB
TypeScript

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<typeof runMarketing>[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(
/^<https:\/\/app\.kumasy\.de\/api\/marketing\/unsubscribe\?t=/
);
expect(mail.headers?.["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
expect(mail.html).toContain(codeData.code);
expect(mail.html).toContain("https://app.kumasy.de/abmelden?t=");
expect(prisma.marketingSend.update).toHaveBeenLastCalledWith(
expect.objectContaining({
where: { id: "send-1" },
data: expect.objectContaining({ status: "sent" }),
})
);
});
it("legt ohne Rabatt keinen Code an", async () => {
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/);
});
});