mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
247 lines
7.8 KiB
TypeScript
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/);
|
|
});
|
|
});
|