added marketing-building-blog-v1

This commit is contained in:
Rajpreet0 2026-10-02 17:31:08 +02:00
parent a9f248c91d
commit dc4f08ab0c
71 changed files with 6307 additions and 183 deletions

View file

@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/src/modules/marketing/lib/run-marketing", () => ({ runMarketing: vi.fn() }));
import { GET } from "@/src/app/api/cron/marketing/route";
import { runMarketing } from "@/src/modules/marketing/lib/run-marketing";
function request(query = "", auth: string | null = "Bearer geheim") {
return new NextRequest(`http://localhost/api/cron/marketing${query}`, {
headers: auth ? { authorization: auth } : {},
});
}
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "geheim";
delete process.env.VERCEL_ENV;
vi.mocked(runMarketing).mockResolvedValue({
today: "2026-10-05",
dryRun: false,
incomplete: false,
businesses: [],
});
});
afterEach(() => {
delete process.env.VERCEL_ENV;
});
describe("GET /api/cron/marketing", () => {
it("lässt nur Aufrufe mit dem Secret durch", async () => {
expect((await GET(request("", null))).status).toBe(401);
expect((await GET(request("", "Bearer falsch"))).status).toBe(401);
expect(runMarketing).not.toHaveBeenCalled();
});
it("startet den Lauf mit den Test-Parametern", async () => {
const res = await GET(request("?dryRun=1&date=2026-05-12&businessId=biz-1"));
expect(res.status).toBe(200);
expect(runMarketing).toHaveBeenCalledWith(
expect.objectContaining({ dryRun: true, today: "2026-05-12", businessId: "biz-1" })
);
});
it("erlaubt kein Datum in Production", async () => {
process.env.VERCEL_ENV = "production";
expect((await GET(request("?date=2026-05-12"))).status).toBe(400);
expect(runMarketing).not.toHaveBeenCalled();
});
it("lehnt ein kaputtes Datum ab", async () => {
expect((await GET(request("?date=12.05.2026"))).status).toBe(400);
});
it("verweigert ohne CRON_SECRET", async () => {
delete process.env.CRON_SECRET;
expect((await GET(request())).status).toBe(500);
});
});

View file

@ -10,11 +10,26 @@ vi.mock("@/src/lib/stripe-checkout", () => ({
createBrandedCheckoutSession: vi.fn(),
studioCheckoutBranding: () => ({}),
}));
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
return {
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
calculateDiscountedPriceCents: actual.calculateDiscountedPriceCents,
};
});
vi.mock("@/src/modules/marketing/lib/consent", () => ({ grantConsentQuietly: vi.fn() }));
import { POST } from "@/src/app/api/stripe/checkout/pass/route";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { createBrandedCheckoutSession } from "@/src/lib/stripe-checkout";
import {
releaseDiscountReservation,
reserveDiscountCode,
} from "@/src/modules/settings/lib/discount-validation";
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
const product = {
id: "prod-1",
@ -93,4 +108,67 @@ describe("POST /api/stripe/checkout/pass", () => {
]);
expect(params.metadata).toMatchObject({ kind: "pass", productId: "prod-1", userId: "user-1" });
});
describe("mit Rabattcode", () => {
beforeEach(() => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "percent", value: 25 },
});
});
it("mindert nur den Kartenpreis, nie die Servicegebühr", async () => {
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
expect(res.status).toBe(200);
expect(reserveDiscountCode).toHaveBeenCalledWith(expect.anything(), {
code: "KARTE25",
businessId: "biz-1",
productId: "prod-1",
userId: "user-1",
});
const params = vi.mocked(createBrandedCheckoutSession).mock.calls[0][0];
expect(params.line_items?.[0].price_data?.unit_amount).toBe(9000);
expect(params.payment_intent_data?.application_fee_amount).toBe(500);
expect(params.metadata).toMatchObject({
discountCodeId: "code-1",
discountAmountCents: "3000",
discountReservationToken: "tok-1",
});
});
it("lehnt einen ungültigen Code ab, statt zum vollen Preis weiterzumachen", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: false,
reason: "Dieser Rabattcode gilt nicht für Karten.",
});
const res = await POST(request({ productId: "prod-1", discountCode: "KURS10" }));
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("Dieser Rabattcode gilt nicht für Karten.");
expect(createBrandedCheckoutSession).not.toHaveBeenCalled();
});
it("gibt die Reservierung frei, wenn Stripe die Session nicht anlegt", async () => {
vi.mocked(createBrandedCheckoutSession).mockRejectedValue(new Error("Stripe weg"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
expect(res.status).toBe(500);
expect(releaseDiscountReservation).toHaveBeenCalledWith(expect.anything(), "tok-1");
});
});
it("speichert das Häkchen für Werbe-Mails", async () => {
await POST(request({ productId: "prod-1", marketingConsent: true }));
expect(grantConsentQuietly).toHaveBeenCalledWith({
userId: "user-1",
businessId: "biz-1",
source: "pass_purchase",
});
});
it("ohne Häkchen keine Einwilligung", async () => {
await POST(request({ productId: "prod-1" }));
expect(grantConsentQuietly).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
vi.mock("@/src/lib/prisma", () => {
const prisma = {
marketingConsent: { findUnique: vi.fn(), upsert: vi.fn() },
marketingAuditLog: { createMany: vi.fn() },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation((fn: (tx: unknown) => unknown) => fn(prisma));
return { prisma };
});
import { prisma } from "@/src/lib/prisma";
import { setMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { CONSENT_TEXT_VERSION } from "@/src/modules/marketing/lib/consent-text";
beforeEach(() => {
vi.clearAllMocks();
});
describe("setMarketingConsent", () => {
it("speichert die Einwilligung mit Wortlaut-Version und protokolliert sie", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue(null);
const changed = await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: true,
source: "course_booking",
});
expect(changed).toBe(true);
const upsert = vi.mocked(prisma.marketingConsent.upsert).mock.calls[0][0];
expect(upsert.create).toMatchObject({
granted: true,
textVersion: CONSENT_TEXT_VERSION,
source: "course_booking",
});
expect(upsert.create?.grantedAt).toBeInstanceOf(Date);
const [entry] = vi.mocked(prisma.marketingAuditLog.createMany).mock.calls[0][0]!.data as {
action: string;
actorType: string;
userId: string;
}[];
expect(entry).toMatchObject({
action: "consent.granted",
actorType: "customer",
userId: "user-1",
});
});
it("protokolliert den Widerruf", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue({ granted: true } as never);
await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: false,
source: "unsubscribe_link",
});
const upsert = vi.mocked(prisma.marketingConsent.upsert).mock.calls[0][0];
expect(upsert.update).toMatchObject({ granted: false });
expect(upsert.update?.revokedAt).toBeInstanceOf(Date);
const [entry] = vi.mocked(prisma.marketingAuditLog.createMany).mock.calls[0][0]!.data as {
action: string;
}[];
expect(entry.action).toBe("consent.revoked");
});
it("ändert nichts, wenn der Stand schon stimmt", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue({ granted: true } as never);
const changed = await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: true,
source: "profile",
});
expect(changed).toBe(false);
expect(prisma.marketingConsent.upsert).not.toHaveBeenCalled();
expect(prisma.marketingAuditLog.createMany).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
diffGraph,
normalizeGraph,
validateGraph,
type GraphNode,
} from "@/src/modules/marketing/lib/graph";
import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config";
const ids = {
birthday: "11111111-1111-4111-8111-111111111111",
milestone: "22222222-2222-4222-8222-222222222222",
mail: "33333333-3333-4333-8333-333333333333",
mail2: "44444444-4444-4444-8444-444444444444",
};
const mail = (
id = ids.mail,
config: Record<string, unknown> = { ...EMAIL_TEMPLATES.birthday }
): GraphNode => ({
id,
kind: "email",
config,
position: { x: 300, y: 0 },
emailNodeId: null,
isActive: false,
});
const birthday = (overrides: Partial<GraphNode> = {}): GraphNode => ({
id: ids.birthday,
kind: "birthday",
config: {},
position: { x: 0, y: 0 },
emailNodeId: ids.mail,
isActive: true,
...overrides,
});
describe("validateGraph", () => {
it("akzeptiert einen verbundenen Trigger mit gültiger E-Mail", () => {
expect(validateGraph([birthday(), mail()])).toBeNull();
});
it("erlaubt Geburtstag nur einmal", () => {
const second = birthday({ id: ids.milestone });
expect(validateGraph([birthday(), second, mail()])?.message).toMatch(/nur einmal/);
});
it("lehnt Verbindungen zu fehlenden E-Mails ab", () => {
expect(validateGraph([birthday({ emailNodeId: ids.mail2 }), mail()])?.message).toMatch(
/nicht gefunden/
);
});
it("prüft die Werte der Trigger", () => {
const milestone: GraphNode = {
...birthday({ id: ids.milestone }),
kind: "milestone",
config: { bookings: 0 },
};
expect(validateGraph([milestone, mail()])?.nodeId).toBe(ids.milestone);
});
it("verlangt einen Rabatt für {code}", () => {
const invalid = mail(ids.mail, {
...EMAIL_TEMPLATES.birthday,
reward: null,
body: "Ihr Code: {code}",
});
expect(validateGraph([invalid])?.message).toMatch(/\{code\}/);
});
it("lehnt Bausteine ohne gültige ID ab", () => {
expect(validateGraph([mail("abc")])?.message).toMatch(/Ungültig/);
});
});
describe("diffGraph", () => {
it("protokolliert Anlegen, Verbinden und Einschalten", () => {
const actions = diffGraph([], [birthday(), mail()]).map((c) => c.action);
expect(actions).toEqual(
expect.arrayContaining(["node.created", "trigger.connected", "trigger.activated"])
);
});
it("ignoriert reines Verschieben", () => {
const before = [birthday(), mail()];
const after = [birthday({ position: { x: 99, y: 99 } }), mail()];
expect(diffGraph(before, after)).toEqual([]);
});
it("nennt geänderte Felder einer E-Mail", () => {
const before = [mail()];
const after = [mail(ids.mail, { ...EMAIL_TEMPLATES.birthday, subject: "Neu" })];
const [change] = diffGraph(before, after);
expect(change.action).toBe("node.updated");
expect(change.summary).toContain("Betreff");
});
it("protokolliert Ausschalten und Löschen", () => {
const before = [birthday(), mail(), mail(ids.mail2)];
const after = [birthday({ isActive: false }), mail()];
const actions = diffGraph(before, after).map((c) => c.action);
expect(actions).toEqual(expect.arrayContaining(["node.deleted", "trigger.deactivated"]));
});
});
describe("normalizeGraph", () => {
it("speichert nur geprüfte Werte", () => {
const [trigger, email] = normalizeGraph([
birthday({ config: { fremd: "x" } }),
{
...mail(ids.mail, { ...EMAIL_TEMPLATES.birthday, subject: " Hallo ", extra: 1 }),
isActive: true,
},
]);
expect(trigger.config).toEqual({});
expect(email.config).not.toHaveProperty("extra");
expect(email.config.subject).toBe("Hallo");
expect(email.isActive).toBe(false);
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { firstName, renderPlaceholders } from "@/src/modules/marketing/lib/placeholders";
describe("renderPlaceholders", () => {
it("ersetzt alle Platzhalter", () => {
expect(
renderPlaceholders(
"Hallo {vorname}, {anzahl} Kurse bei {studio}: {code} ({rabatt}, bis {gueltig_bis})",
{
vorname: "Anna",
studio: "Pilates Loft",
anzahl: 10,
code: "DANKE-ABC234",
rabatt: "10 %",
gueltig_bis: "31.10.2026",
}
)
).toBe("Hallo Anna, 10 Kurse bei Pilates Loft: DANKE-ABC234 (10 %, bis 31.10.2026)");
});
it("räumt auf, wenn der Name fehlt", () => {
expect(
renderPlaceholders("Alles Gute zum Geburtstag, {vorname}!", { vorname: "", studio: "X" })
).toBe("Alles Gute zum Geburtstag!");
expect(renderPlaceholders("Hallo {vorname},\n\nschön", { vorname: "", studio: "X" })).toBe(
"Hallo,\n\nschön"
);
});
it("lässt unbekannte geschweifte Klammern stehen", () => {
expect(renderPlaceholders("{foo} {studio}", { vorname: "", studio: "X" })).toBe("{foo} X");
});
});
describe("firstName", () => {
it("nimmt den ersten Teil des Namens", () => {
expect(firstName(" Anna Maria Schmidt ")).toBe("Anna");
expect(firstName(null)).toBe("");
});
});

View file

@ -0,0 +1,247 @@
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/);
});
});

View file

@ -0,0 +1,187 @@
import { describe, it, expect } from "vitest";
import {
birthdayInYear,
findDueEvents,
type ActiveTrigger,
type CustomerFacts,
} from "@/src/modules/marketing/lib/triggers";
// activatedAt als Berliner Mittag, damit der Tag eindeutig ist.
const activatedOn = (day: string) => new Date(`${day}T10:00:00Z`);
function trigger(overrides: Partial<ActiveTrigger> & Pick<ActiveTrigger, "kind">): ActiveTrigger {
return {
id: `t-${overrides.kind}`,
config: {},
activatedAt: activatedOn("2026-01-01"),
emailNodeId: "mail-1",
...overrides,
};
}
function customer(overrides: Partial<CustomerFacts> = {}): CustomerFacts {
return { userId: "u1", birthday: null, bookingDays: [], ...overrides };
}
describe("birthdayInYear", () => {
it("nimmt Monat und Tag aus UTC", () => {
expect(birthdayInYear(new Date("1990-05-12"), 2026)).toBe("2026-05-12");
});
it("feiert den 29.2. in anderen Jahren am 28.2.", () => {
expect(birthdayInYear(new Date("1996-02-29"), 2027)).toBe("2027-02-28");
expect(birthdayInYear(new Date("1996-02-29"), 2028)).toBe("2028-02-29");
});
});
describe("Geburtstag", () => {
const birthday = trigger({ kind: "birthday" });
it("ist am Geburtstag fällig, Zeitraum ist das Jahr", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toEqual([
expect.objectContaining({ triggerKind: "birthday", userId: "u1", periodKey: "2026" }),
]);
});
it("holt bis zu zwei Tage nach, danach nicht mehr", () => {
const facts = [customer({ birthday: new Date("1990-05-12") })];
expect(
findDueEvents({ today: "2026-05-14", triggers: [birthday], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-05-15", triggers: [birthday], customers: facts })
).toHaveLength(0);
});
it("holt am 1.1. den 31.12. des Vorjahres nach", () => {
const events = findDueEvents({
today: "2027-01-01",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-12-31") })],
});
expect(events[0]?.periodKey).toBe("2026");
});
it("zählt keinen Geburtstag vor dem Einschalten", () => {
const events = findDueEvents({
today: "2026-05-13",
triggers: [trigger({ kind: "birthday", activatedAt: activatedOn("2026-05-13") })],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toHaveLength(0);
});
it("überspringt Kunden ohne Geburtsdatum", () => {
expect(
findDueEvents({ today: "2026-05-12", triggers: [birthday], customers: [customer()] })
).toHaveLength(0);
});
});
describe("Meilenstein", () => {
const milestone = trigger({ kind: "milestone", config: { bookings: 3 } });
it("ist fällig, sobald der 3. Kurs vorbei ist", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-10"] })],
});
expect(events).toEqual([
expect.objectContaining({ periodKey: "3", milestoneCount: 3, triggerKind: "milestone" }),
]);
});
it("zählt kommende Kurse nicht mit", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-12"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der die Schwelle vor dem Einschalten erreicht hat", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [
trigger({
kind: "milestone",
config: { bookings: 3 },
activatedAt: activatedOn("2026-03-08"),
}),
],
customers: [
customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-05", "2026-03-10"] }),
],
});
expect(events).toHaveLength(0);
});
it("holt höchstens sieben Tage nach", () => {
const facts = [customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-03"] })];
expect(
findDueEvents({ today: "2026-03-10", triggers: [milestone], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-03-11", triggers: [milestone], customers: facts })
).toHaveLength(0);
});
});
describe("Reaktivierung", () => {
const winback = trigger({ kind: "winback", config: { days: 30 } });
it("ist fällig, wenn der letzte Kurs 30 Tage her ist", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-31"] })],
});
expect(events).toEqual([expect.objectContaining({ periodKey: "2026-03-31:30" })]);
});
it("schreibt niemanden an, der schon wieder gebucht hat", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-31", "2026-05-02"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der schon vor dem Einschalten so lange weg war", () => {
const events = findDueEvents({
today: "2026-06-01",
triggers: [
trigger({ kind: "winback", config: { days: 30 }, activatedAt: activatedOn("2026-05-15") }),
],
customers: [customer({ bookingDays: ["2026-03-31"] })],
});
expect(events).toHaveLength(0);
});
it("braucht mindestens einen besuchten Kurs", () => {
expect(
findDueEvents({ today: "2026-04-30", triggers: [winback], customers: [customer()] })
).toHaveLength(0);
});
});
it("sortiert Geburtstag vor Meilenstein vor Reaktivierung", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [
trigger({ kind: "winback", config: { days: 30 } }),
trigger({ kind: "milestone", config: { bookings: 1 } }),
trigger({ kind: "birthday" }),
],
customers: [customer({ birthday: new Date("1990-05-12"), bookingDays: ["2026-04-11"] })],
});
expect(events.map((e) => e.triggerKind)).toEqual(["birthday", "winback"]);
});

View file

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
createUnsubscribeToken,
verifyUnsubscribeToken,
} from "@/src/modules/marketing/lib/unsubscribe-token";
beforeEach(() => {
process.env.MARKETING_TOKEN_SECRET = "test-secret";
});
describe("Abmelde-Token", () => {
it("liefert Kunde und Studio zurück", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
expect(verifyUnsubscribeToken(token)).toEqual({ userId: "user-1", businessId: "biz-1" });
});
it("lehnt veränderte Tokens ab", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
const forged = token.replace("user-1", "user-2");
expect(verifyUnsubscribeToken(forged)).toBeNull();
expect(verifyUnsubscribeToken("kaputt")).toBeNull();
expect(verifyUnsubscribeToken(null)).toBeNull();
});
it("lehnt Tokens mit anderem Secret ab", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
process.env.MARKETING_TOKEN_SECRET = "anderes-secret";
expect(verifyUnsubscribeToken(token)).toBeNull();
});
});

View file

@ -1,12 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
vi.mock("@/src/lib/prisma", () => {
const prisma = {
customerPass: { findUnique: vi.fn(), create: vi.fn() },
product: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
discountRedemption: { updateMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
discountCode: { update: vi.fn() },
$transaction: vi.fn(),
};
// Callback-Transaktion: läuft mit demselben Mock; Array-Transaktion: alle Promises.
prisma.$transaction.mockImplementation((arg: unknown) =>
typeof arg === "function" ? arg(prisma) : Promise.all(arg as Promise<unknown>[])
);
return { prisma };
});
vi.mock("@/src/lib/stripe", () => ({
stripe: { paymentIntents: { retrieve: vi.fn() } },
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
@ -20,6 +28,7 @@ vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
import {
handlePassCheckoutCompleted,
handlePassCheckoutUnpaid,
isPassCheckout,
} from "@/src/modules/products/lib/pass-webhook";
import { prisma } from "@/src/lib/prisma";
@ -125,3 +134,55 @@ describe("handlePassCheckoutCompleted", () => {
);
});
});
describe("Kartenkauf mit Rabattcode", () => {
const discounted = {
...session,
amount_total: 10500,
metadata: {
...session.metadata,
discountCodeId: "code-1",
discountAmountCents: "2000",
discountReservationToken: "tok-1",
},
};
it("speichert den Rabatt an der Karte und bestätigt die Reservierung", async () => {
vi.mocked(prisma.discountRedemption.updateMany).mockResolvedValue({ count: 1 } as never);
await handlePassCheckoutCompleted(discounted as never, "acct_studio");
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data).toMatchObject({
discountCodeId: "code-1",
discountAmount: 2000,
// Der Kartenpreis ist schon rabattiert: gezahlt minus Servicegebühr.
priceCents: 10000,
});
expect(prisma.discountRedemption.updateMany).toHaveBeenCalledWith({
where: { stripeSessionId: "tok-1", status: "reserved" },
data: { status: "confirmed", passId: "pass-1" },
});
});
it("gibt die Reservierung frei, wenn der Kauf nie bezahlt wurde", async () => {
vi.mocked(prisma.discountRedemption.findUnique).mockResolvedValue({
id: "red-1",
discountCodeId: "code-1",
status: "reserved",
} as never);
await handlePassCheckoutUnpaid(discounted as never);
expect(prisma.discountRedemption.delete).toHaveBeenCalledWith({ where: { id: "red-1" } });
expect(prisma.discountCode.update).toHaveBeenCalledWith({
where: { id: "code-1" },
data: { redeemedCount: { decrement: 1 } },
});
});
it("ohne Rabattcode bleibt nichts freizugeben", async () => {
await handlePassCheckoutUnpaid(session as never);
expect(prisma.discountRedemption.findUnique).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { validateDiscountCode } from "@/src/modules/settings/lib/discount-validation";
function dbWith(code: Record<string, unknown> | null) {
return {
discountCode: { findUnique: vi.fn().mockResolvedValue(code) },
discountRedemption: { findUnique: vi.fn().mockResolvedValue(null) },
} as never;
}
const baseCode = {
id: "code-1",
code: "SOMMER",
type: "percent",
value: 10,
scope: "all_courses",
courseIds: [],
appliesTo: "courses",
restrictedToUserId: null,
maxRedemptions: null,
redeemedCount: 0,
startsAt: null,
expiresAt: null,
isActive: true,
};
const base = { code: "sommer", businessId: "biz-1", userId: "user-1" };
describe("validateDiscountCode - wofür ein Code gilt", () => {
it("Kurs-Codes gelten nicht beim Kartenkauf", async () => {
const result = await validateDiscountCode(dbWith(baseCode), { ...base, productId: "prod-1" });
expect(result).toEqual({ valid: false, reason: "Dieser Rabattcode gilt nicht für Karten." });
});
it("Karten-Codes gelten nicht für Kurse", async () => {
const result = await validateDiscountCode(dbWith({ ...baseCode, appliesTo: "passes" }), {
...base,
courseId: "course-1",
});
expect(result).toEqual({ valid: false, reason: "Dieser Rabattcode gilt nur für Karten." });
});
it("Codes für beides gelten bei Kurs und Karte", async () => {
const db = dbWith({ ...baseCode, appliesTo: "all" });
expect((await validateDiscountCode(db, { ...base, courseId: "c" })).valid).toBe(true);
expect((await validateDiscountCode(db, { ...base, productId: "p" })).valid).toBe(true);
});
it("die Kursauswahl wirkt beim Kartenkauf nicht", async () => {
const db = dbWith({
...baseCode,
appliesTo: "all",
scope: "specific_courses",
courseIds: ["c1"],
});
expect((await validateDiscountCode(db, { ...base, productId: "p" })).valid).toBe(true);
expect((await validateDiscountCode(db, { ...base, courseId: "c2" })).valid).toBe(false);
});
});
describe("validateDiscountCode - persönliche Codes", () => {
it("gilt nur für das eigene Konto", async () => {
const db = dbWith({ ...baseCode, restrictedToUserId: "user-1" });
expect((await validateDiscountCode(db, { ...base, courseId: "c" })).valid).toBe(true);
expect(await validateDiscountCode(db, { ...base, userId: "user-2", courseId: "c" })).toEqual({
valid: false,
reason: "Dieser Rabattcode gehört zu einem anderen Kundenkonto.",
});
});
});