mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
added marketing-building-blog-v1
This commit is contained in:
parent
a9f248c91d
commit
dc4f08ab0c
71 changed files with 6307 additions and 183 deletions
60
tests/api/cron/marketing.test.ts
Normal file
60
tests/api/cron/marketing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
86
tests/marketing/consent.test.ts
Normal file
86
tests/marketing/consent.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
122
tests/marketing/graph.test.ts
Normal file
122
tests/marketing/graph.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
tests/marketing/placeholders.test.ts
Normal file
40
tests/marketing/placeholders.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
247
tests/marketing/run-marketing.test.ts
Normal file
247
tests/marketing/run-marketing.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
187
tests/marketing/triggers.test.ts
Normal file
187
tests/marketing/triggers.test.ts
Normal 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"]);
|
||||
});
|
||||
30
tests/marketing/unsubscribe-token.test.ts
Normal file
30
tests/marketing/unsubscribe-token.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
70
tests/settings/discount-validation.test.ts
Normal file
70
tests/settings/discount-validation.test.ts
Normal 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue