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

186 lines
6.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
const tx = {
course: { findUnique: vi.fn() },
customerPass: { findUnique: vi.fn(), updateMany: vi.fn() },
courseBooking: { findUnique: vi.fn(), upsert: vi.fn() },
};
vi.mock("@/src/lib/prisma", () => ({
prisma: {
$transaction: vi.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
courseWaitlistEntry: { deleteMany: vi.fn() },
},
}));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: (user: { role: string }) => user.role === "user",
}));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn(), updateTag: vi.fn() }));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
vi.mock("@/src/modules/courses/lib/notify-booking", () => ({ notifyManagerOfBooking: vi.fn() }));
vi.mock("@/src/modules/courses/lib/seat-counts", async (importOriginal) => ({
...(await importOriginal<object>()),
paidSeatCount: vi.fn(),
}));
vi.mock("@/src/modules/courses/lib/course-spots", async (importOriginal) => ({
...(await importOriginal<object>()),
getCourseSpots: vi.fn(),
getBookedSpotIds: vi.fn(),
getHeldSpotIds: vi.fn(),
}));
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { sendMail } from "@/src/lib/mail/resend";
import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
import { paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import {
getBookedSpotIds,
getCourseSpots,
getHeldSpotIds,
} from "@/src/modules/courses/lib/course-spots";
const course = {
id: "course-1",
name: "Mat Pilates",
date: new Date("2099-01-01"),
timeFrom: "10:00",
timeTo: "11:00",
status: "published",
sport: ["pilates"],
maxParticipants: 10,
spotBooking: false,
room: "room-1",
coverImage: null,
businessId: "biz-1",
business: { isPublic: true, name: "Studio", address: "Weg 1", branding: null },
};
const pass = {
id: "pass-1",
userId: "user-1",
businessId: "biz-1",
name: "10er-Karte",
sports: [] as string[],
status: "active",
validUntil: null as Date | null,
creditsRemaining: 5,
};
const book = (overrides: Partial<Parameters<typeof bookCourseWithPass>[0]> = {}) =>
bookCourseWithPass({ courseId: "course-1", passId: "pass-1", seats: 1, ...overrides });
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({
id: "user-1",
email: "kunde@example.com",
name: "Anna",
role: "user",
} as never);
tx.course.findUnique.mockResolvedValue(course);
tx.customerPass.findUnique.mockResolvedValue(pass);
tx.customerPass.updateMany.mockResolvedValue({ count: 1 });
tx.courseBooking.findUnique.mockResolvedValue(null);
tx.courseBooking.upsert.mockResolvedValue({});
vi.mocked(paidSeatCount).mockResolvedValue(3);
vi.mocked(getCourseSpots).mockResolvedValue(null);
vi.mocked(prisma.courseWaitlistEntry.deleteMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(sendMail).mockResolvedValue(undefined as never);
vi.mocked(notifyManagerOfBooking).mockResolvedValue(undefined);
});
describe("bookCourseWithPass", () => {
it("bucht, zieht die Einheiten atomar ab und legt eine Kartenbuchung ohne Zahlung an", async () => {
const result = await book({ seats: 2 });
expect(result).toEqual({ success: true, seats: 2, creditsRemaining: 3 });
expect(tx.customerPass.updateMany).toHaveBeenCalledWith({
where: { id: "pass-1", status: "active", creditsRemaining: { gte: 2 } },
data: { creditsRemaining: { decrement: 2 } },
});
const { create } = tx.courseBooking.upsert.mock.calls[0][0];
expect(create).toMatchObject({
courseId: "course-1",
userId: "user-1",
paymentStatus: "paid",
passId: "pass-1",
seats: 2,
amountPaid: null,
stripePaymentIntentId: null,
});
expect(sendMail).toHaveBeenCalled();
});
it("lehnt Gäste und Team-Accounts ab", async () => {
vi.mocked(getUserData).mockResolvedValue({ id: "m1", role: "manager" } as never);
expect((await book()).success).toBe(false);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("lehnt die Karte einer anderen Person ab", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, userId: "someone-else" });
expect(await book()).toMatchObject({ success: false, error: "Karte nicht gefunden." });
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("lehnt eine Karte ab, die nicht für diese Kursart gilt", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, sports: ["yoga"] });
expect(await book()).toMatchObject({
success: false,
error: "Diese Karte gilt nicht für diesen Kurs.",
});
});
it("lehnt eine abgelaufene Karte ab", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, validUntil: new Date("2000-01-01") });
expect((await book()).success).toBe(false);
});
it("lehnt mehr Plätze ab, als Einheiten übrig sind", async () => {
tx.customerPass.findUnique.mockResolvedValue({ ...pass, creditsRemaining: 1 });
expect(await book({ seats: 2 })).toMatchObject({
success: false,
error: "Auf Ihrer Karte ist nur noch 1 Einheit frei.",
});
});
it("lehnt ab, wenn der Kurs voll ist", async () => {
vi.mocked(paidSeatCount).mockResolvedValue(10);
expect(await book()).toMatchObject({
success: false,
error: "Dieser Kurs ist bereits ausgebucht.",
});
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("überschreibt keine frühere, bezahlte Buchung mit ihren Zahlungsbelegen", async () => {
tx.courseBooking.findUnique.mockResolvedValue({
paymentStatus: "refunded",
stripePaymentIntentId: "pi_old",
});
expect((await book()).success).toBe(false);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
});
it("lehnt einen Platz ab, den gerade jemand anderes im Checkout hält", async () => {
vi.mocked(getCourseSpots).mockResolvedValue([
{ id: "r1", type: "reformer", x: 0, y: 0, rotation: 0, label: "Reformer 1" },
] as never);
vi.mocked(getBookedSpotIds).mockResolvedValue(new Set());
vi.mocked(getHeldSpotIds).mockResolvedValue(new Set(["r1"]));
const result = await book({ spotIds: ["r1"] });
expect(result).toMatchObject({ success: false, spotUnavailable: true });
expect(tx.customerPass.updateMany).not.toHaveBeenCalled();
});
it("meldet, wenn eine gleichzeitige Buchung die Einheiten schon verbraucht hat", async () => {
tx.customerPass.updateMany.mockResolvedValue({ count: 0 });
expect((await book()).success).toBe(false);
expect(tx.courseBooking.upsert).not.toHaveBeenCalled();
});
});