Kuma/tests/courses/cancel-pass-booking.test.ts
2026-10-02 15:36:12 +02:00

178 lines
7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
courseBooking: { updateMany: vi.fn() },
customerPass: { updateMany: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
courseBooking: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() },
courseWaitlistEntry: { count: vi.fn() },
user: { findUnique: vi.fn() },
guestLead: { findUnique: vi.fn() },
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
},
}));
vi.mock("@/src/lib/stripe-refund", () => ({ refundStripePayment: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/lib/mail/templates/pass-booking-cancellation", () => ({
generatePassBookingCancellationEmail: vi
.fn()
.mockReturnValue({ subject: "s", html: "h", text: "t" }),
}));
vi.mock("@/src/modules/courses/actions/notify-waitlist", () => ({ notifyCourseWaitlist: vi.fn() }));
vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
notifyBusinessManager: vi.fn(),
}));
vi.mock("@/src/modules/settings/lib/to-email-branding", () => ({
toEmailBranding: vi.fn().mockReturnValue({}),
}));
vi.mock("@/src/modules/courses/actions/cancellation-policy", async (importOriginal) => {
const actual = await importOriginal<object>();
return { ...actual, calculateRefundAmountCents: vi.fn() };
});
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import { calculateRefundAmountCents } from "@/src/modules/courses/actions/cancellation-policy";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { prisma } from "@/src/lib/prisma";
import { generatePassBookingCancellationEmail } from "@/src/lib/mail/templates/pass-booking-cancellation";
import { notifyCourseWaitlist } from "@/src/modules/courses/actions/notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { sendMail } from "@/src/lib/mail/resend";
// Mit einer 10er-Karte gebucht: kein Geld, keine Stripe-Felder.
const passBooking = {
paymentStatus: "paid",
amountPaid: null,
platformFee: null,
platformFeeOnTop: false,
stripePaymentIntentId: null,
stripeAccountId: null,
userId: "user-1",
guestLeadId: null,
seats: 2,
seatsPurchased: 2,
spotIds: [] as string[],
spotLabels: [] as string[],
refundedAmount: null,
refundedFeeAmount: null,
refundedAt: null,
passId: "pass-1",
pass: { name: "10er-Karte" },
course: {
id: "course-1",
name: "Yoga",
date: new Date("2099-01-01"),
timeFrom: "10:00",
businessId: "biz-1",
business: { name: "Studio", branding: null },
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(passBooking as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ email: "kunde@example.com" } as never);
vi.mocked(prisma.courseWaitlistEntry.count).mockResolvedValue(0 as never);
vi.mocked(notifyCourseWaitlist).mockResolvedValue(undefined as never);
vi.mocked(notifyBusinessManager).mockResolvedValue(undefined as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(generatePassBookingCancellationEmail).mockReturnValue({
subject: "s",
html: "h",
text: "t",
});
tx.courseBooking.updateMany.mockResolvedValue({ count: 1 });
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 0,
});
});
describe("executeBookingCancellation – Kartenbuchung", () => {
it("gibt die Einheiten zurück, wenn die Stornoregel 100 % erstatten würde", async () => {
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 2, refundedAmountCents: 0 });
expect(refundStripePayment).not.toHaveBeenCalled();
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("refunded");
const { where, data } = tx.customerPass.updateMany.mock.calls[0][0];
// Gutschrift nur auf eine Karte, die noch einlösbar ist.
expect(where).toMatchObject({ id: "pass-1", status: "active" });
expect(data).toEqual({ creditsRemaining: { increment: 2 } });
});
it("lässt die Einheiten verfallen, wenn die Frist verstrichen ist", async () => {
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 50,
refundAmountCents: 0,
});
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
creditsReturned: false,
forfeitReason: "policy",
});
});
it("gibt bei einer Kursabsage immer zurück, ohne die Stornoregel zu fragen", async () => {
const result = await executeBookingCancellation("booking-1", "manager", {
forceFullRefund: true,
});
expect(result).toMatchObject({ success: true, creditsReturned: 2 });
expect(calculateRefundAmountCents).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].reason).toBe(
"course_cancelled"
);
});
it("schreibt einer abgelaufenen oder beendeten Karte nichts gut - auch nicht bei Kursabsage", async () => {
// Die Karte ist nicht mehr aktiv: die bedingte Gutschrift trifft keine Zeile.
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
const result = await executeBookingCancellation("booking-1", "manager", {
forceFullRefund: true,
});
expect(result).toMatchObject({ success: true, creditsReturned: 0 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data.paymentStatus).toBe("cancelled");
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0]).toMatchObject({
creditsReturned: false,
forfeitReason: "pass_inactive",
});
});
it("storniert einzelne Plätze und gibt nur deren Einheiten zurück", async () => {
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
});
expect(result).toMatchObject({ success: true, remainingSeats: 1, creditsReturned: 1 });
expect(tx.courseBooking.updateMany.mock.calls[0][0].data).toMatchObject({ seats: 1 });
expect(tx.customerPass.updateMany.mock.calls[0][0].data).toEqual({
creditsRemaining: { increment: 1 },
});
});
it("schreibt bei einem Doppelklick nicht zweimal gut", async () => {
tx.courseBooking.updateMany.mockResolvedValue({ count: 0 });
const result = await executeBookingCancellation("booking-1", "user");
// Die Gutschrift lief in derselben Transaktion und wird mit ihr zurückgerollt.
expect(result).toEqual({
success: false,
error: "Stornierung läuft bereits oder wurde bereits verarbeitet.",
});
});
});