Kuma/tests/courses/cancel-pass-booking.test.ts
2026-10-02 14:30:54 +02:00

158 lines
6 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: { update: 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.update.mockResolvedValue({});
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");
expect(tx.customerPass.update).toHaveBeenCalledWith({
where: { id: "pass-1" },
data: { 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.update).not.toHaveBeenCalled();
expect(vi.mocked(generatePassBookingCancellationEmail).mock.calls[0][0].creditsReturned).toBe(
false
);
});
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("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.update.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");
expect(result.success).toBe(false);
expect(tx.customerPass.update).not.toHaveBeenCalled();
});
});