Kuma/tests/products/pass-webhook.test.ts
2026-10-02 17:31:08 +02:00

188 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
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),
}));
vi.mock("@/src/lib/stripe-fee", () => ({ recordStripeFee: vi.fn() }));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
import {
handlePassCheckoutCompleted,
handlePassCheckoutUnpaid,
isPassCheckout,
} from "@/src/modules/products/lib/pass-webhook";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
import { recordStripeFee } from "@/src/lib/stripe-fee";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { sendMail } from "@/src/lib/mail/resend";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
const session = {
id: "cs_1",
payment_status: "paid",
payment_intent: "pi_1",
amount_total: 12500,
customer_details: null,
metadata: { kind: "pass", productId: "prod-1", userId: "user-1" },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.customerPass.findUnique).mockResolvedValue(null);
vi.mocked(prisma.customerPass.create).mockResolvedValue({ id: "pass-1" } as never);
vi.mocked(prisma.product.findUnique).mockResolvedValue({
id: "prod-1",
name: "10er-Karte",
credits: 10,
validityMonths: 6,
sports: ["pilates"],
businessId: "biz-1",
business: { name: "Studio", branding: null },
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
email: "Kunde@Example.com",
name: "Anna",
} as never);
vi.mocked(stripe.paymentIntents.retrieve).mockResolvedValue({
application_fee_amount: 500,
} as never);
vi.mocked(recordStripeFee).mockResolvedValue(undefined);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
});
describe("isPassCheckout", () => {
it("erkennt Kartenkäufe an den Metadaten", () => {
expect(isPassCheckout(session as never)).toBe(true);
expect(isPassCheckout({ metadata: { courseId: "c1" } } as never)).toBe(false);
});
});
describe("handlePassCheckoutCompleted", () => {
it("legt die Karte mit Momentaufnahme des Produkts und Ablaufdatum an", async () => {
await handlePassCheckoutCompleted(session as never, "acct_studio");
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data).toMatchObject({
productId: "prod-1",
businessId: "biz-1",
userId: "user-1",
email: "kunde@example.com",
name: "10er-Karte",
sports: ["pilates"],
creditsTotal: 10,
creditsRemaining: 10,
source: "purchase",
amountPaid: 12500,
platformFee: 500,
priceCents: 12000,
stripeAccountId: "acct_studio",
stripeSessionId: "cs_1",
});
expect(data.validUntil).toBeInstanceOf(Date);
expect(recordStripeFee).toHaveBeenCalledWith("pi_1", "acct_studio");
expect(sendMail).toHaveBeenCalled();
expect(vi.mocked(notifyBusinessManager).mock.calls[0][1].type).toBe("pass.sold");
});
it("legt bei erneuter Zustellung derselben Session keine zweite Karte an", async () => {
vi.mocked(prisma.customerPass.findUnique).mockResolvedValue({ id: "pass-1" } as never);
await handlePassCheckoutCompleted(session as never, "acct_studio");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
});
it("wartet bei SEPA, bis die Zahlung angekommen ist", async () => {
await handlePassCheckoutCompleted({ ...session, payment_status: "unpaid" } as never, "acct");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
});
it("erstattet, wenn das Produkt inzwischen gelöscht wurde", async () => {
vi.mocked(prisma.product.findUnique).mockResolvedValue(null);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
amountRefundedCents: 12500,
});
await handlePassCheckoutCompleted(session as never, "acct_studio");
expect(prisma.customerPass.create).not.toHaveBeenCalled();
expect(refundStripePayment).toHaveBeenCalledWith(
expect.objectContaining({ paymentIntentId: "pi_1", refundApplicationFee: true })
);
});
});
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();
});
});