mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { POST } from "@/src/app/api/stripe/checkout/course/route";
|
||
|
||
vi.mock("@/src/lib/auth/getUser", () => ({
|
||
getUserData: vi.fn(),
|
||
isUser: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@/src/lib/auth/account-role-by-email", () => ({
|
||
isManagerOrEmployeeEmail: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@/src/lib/prisma", () => ({
|
||
prisma: {
|
||
course: { findUnique: vi.fn() },
|
||
courseBooking: { findUnique: vi.fn() },
|
||
},
|
||
}));
|
||
|
||
vi.mock("@/src/lib/stripe", () => ({
|
||
stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
|
||
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||
eurosToCents: vi.fn().mockReturnValue(2900),
|
||
}));
|
||
|
||
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
|
||
// Reservierung wieder freigibt.
|
||
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||
reserveDiscountCode: vi.fn(),
|
||
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
|
||
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
|
||
}));
|
||
|
||
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
|
||
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
||
getCourseSpots: vi.fn().mockResolvedValue(null),
|
||
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
|
||
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
|
||
holdSpot: vi.fn(),
|
||
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
||
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
|
||
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
|
||
SPOT_HOLD_MINUTES: 30,
|
||
}));
|
||
|
||
import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||
import { prisma } from "@/src/lib/prisma";
|
||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
||
import {
|
||
attachSessionToHold,
|
||
getCourseSpots,
|
||
getOpenCheckoutHolds,
|
||
holdSpot,
|
||
releaseSpotHold,
|
||
} from "@/src/modules/courses/lib/course-spots";
|
||
import {
|
||
reserveDiscountCode,
|
||
releaseDiscountReservation,
|
||
} from "@/src/modules/settings/lib/discount-validation";
|
||
|
||
let ipCounter = 0;
|
||
|
||
function makeRequest(body: Record<string, unknown>) {
|
||
// Jede Anfrage aus einer eigenen IP, damit der In-Memory-Rate-Limiter
|
||
// (10/Minute/IP) die Tests nicht gegenseitig beeinflusst.
|
||
ipCounter += 1;
|
||
return new Request("http://localhost/api/stripe/checkout/course", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"x-forwarded-for": `10.0.0.${ipCounter}`,
|
||
},
|
||
body: JSON.stringify(body),
|
||
}) as never;
|
||
}
|
||
|
||
const publishedCourse = {
|
||
id: "course-1",
|
||
name: "Yoga",
|
||
status: "published",
|
||
price: 29,
|
||
date: new Date("2099-01-01"),
|
||
timeFrom: "10:00",
|
||
maxParticipants: 20,
|
||
_count: { bookings: 0 },
|
||
business: {
|
||
id: "biz-1",
|
||
name: "Studio",
|
||
stripeAccountId: "acct_123",
|
||
stripeAccountStatus: "active",
|
||
},
|
||
};
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(getUserData).mockReset();
|
||
vi.mocked(isUser).mockReset();
|
||
vi.mocked(isManagerOrEmployeeEmail).mockReset();
|
||
vi.mocked(prisma.course.findUnique).mockReset();
|
||
vi.mocked(prisma.courseBooking.findUnique).mockReset();
|
||
vi.mocked(stripe.checkout.sessions.create).mockReset();
|
||
|
||
vi.mocked(stripe.checkout.sessions.create).mockResolvedValue({
|
||
url: "https://checkout.stripe.com/session",
|
||
} as never);
|
||
vi.mocked(prisma.course.findUnique).mockResolvedValue(publishedCourse as never);
|
||
});
|
||
|
||
describe("POST /api/stripe/checkout/course – Gast mit Team-Account-E-Mail (Ticket 6)", () => {
|
||
it("lehnt eine Gast-Buchung mit Manager/Employee-E-Mail mit 403 ab, ohne Stripe-Session", async () => {
|
||
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(true);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "Manager@Studio.de" }));
|
||
|
||
expect(res.status).toBe(403);
|
||
const data = await res.json();
|
||
expect(data.error).toMatch(/Team-Account/i);
|
||
expect(isManagerOrEmployeeEmail).toHaveBeenCalledWith("manager@studio.de");
|
||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("lässt eine normale Gast-Buchung durch, wenn die E-Mail zu keinem Team-Account gehört", async () => {
|
||
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "gast@example.com" }));
|
||
|
||
expect(res.status).toBe(200);
|
||
const data = await res.json();
|
||
expect(data.url).toBe("https://checkout.stripe.com/session");
|
||
expect(stripe.checkout.sessions.create).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("prüft die Team-Account-E-Mail nicht für eingeloggte Kunden-Accounts", async () => {
|
||
vi.mocked(getUserData).mockResolvedValue({
|
||
id: "user-1",
|
||
email: "kunde@example.com",
|
||
} as never);
|
||
vi.mocked(isUser).mockReturnValue(true);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("weist eine ungültige Gast-E-Mail weiterhin vor der Team-Account-Prüfung mit 400 ab", async () => {
|
||
vi.mocked(getUserData).mockResolvedValue(null as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", guestEmail: "keine-email" }));
|
||
|
||
expect(res.status).toBe(400);
|
||
const data = await res.json();
|
||
expect(data.isGuestRequired).toBe(true);
|
||
expect(isManagerOrEmployeeEmail).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("POST /api/stripe/checkout/course – Servicegebühr", () => {
|
||
it("schlägt die Gebühr als eigene Position auf und behält sie als application_fee ein", async () => {
|
||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||
vi.mocked(isUser).mockReturnValue(true);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
expect(res.status).toBe(200);
|
||
|
||
const [args, options] = vi.mocked(stripe.checkout.sessions.create).mock.calls[0] as never as [
|
||
{
|
||
line_items: { price_data: { unit_amount: number; product_data: { name: string } } }[];
|
||
payment_intent_data: { application_fee_amount?: number; transfer_data?: unknown };
|
||
metadata: Record<string, string>;
|
||
},
|
||
{ stripeAccount?: string },
|
||
];
|
||
|
||
// Direct Charge: die Session entsteht auf dem Studio-Account, ohne
|
||
// Transfer von der Plattform.
|
||
expect(options).toEqual({ stripeAccount: "acct_123" });
|
||
expect(args.payment_intent_data.transfer_data).toBeUndefined();
|
||
|
||
// Kurs zum vollen Preis + separate Gebührenposition
|
||
expect(args.line_items).toHaveLength(2);
|
||
expect(args.line_items[0].price_data.unit_amount).toBe(2900);
|
||
expect(args.line_items[1].price_data.product_data.name).toBe("Servicegebühr");
|
||
expect(args.line_items[1].price_data.unit_amount).toBe(50);
|
||
|
||
// Kumasy bekommt exakt die Gebühr als application_fee.
|
||
expect(args.payment_intent_data.application_fee_amount).toBe(50);
|
||
|
||
// Der Webhook erkennt daran, dass die Gebühr obendrauf kam.
|
||
expect(args.metadata.platformFeeModel).toBe("on_top");
|
||
});
|
||
|
||
it("erzeugt keine Gebührenposition, wenn die Gebühr auf 0 konfiguriert ist", async () => {
|
||
vi.mocked(getPlatformFeeCents).mockReturnValueOnce(0);
|
||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||
vi.mocked(isUser).mockReturnValue(true);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
expect(res.status).toBe(200);
|
||
|
||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||
line_items: unknown[];
|
||
payment_intent_data: { application_fee_amount?: number };
|
||
};
|
||
expect(args.line_items).toHaveLength(1);
|
||
// Ohne Gebühr wird gar keine application_fee gesetzt statt einer von 0.
|
||
expect(args.payment_intent_data.application_fee_amount).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("POST /api/stripe/checkout/course – Checkout-Seite", () => {
|
||
function loggedIn() {
|
||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||
vi.mocked(isUser).mockReturnValue(true);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
}
|
||
|
||
it("verlangt die Rechnungsadresse und trägt das Branding des Studios", async () => {
|
||
loggedIn();
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
expect(res.status).toBe(200);
|
||
|
||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||
billing_address_collection: string;
|
||
branding_settings: Record<string, unknown>;
|
||
};
|
||
expect(args.billing_address_collection).toBe("required");
|
||
// Ohne eigenes Studio-Branding greifen die Kumasy-Farben.
|
||
expect(args.branding_settings).toMatchObject({
|
||
display_name: "Studio",
|
||
background_color: "#1E293B",
|
||
button_color: "#F4C00C",
|
||
border_style: "rounded",
|
||
});
|
||
});
|
||
|
||
it("bucht ohne Branding weiter, wenn Stripe das Branding ablehnt", async () => {
|
||
loggedIn();
|
||
vi.mocked(stripe.checkout.sessions.create)
|
||
.mockRejectedValueOnce(
|
||
Object.assign(new Error("Invalid logo"), { param: "branding_settings[logo][url]" })
|
||
)
|
||
.mockResolvedValueOnce({ url: "https://checkout.stripe.com/ohne-branding" } as never);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
|
||
expect(res.status).toBe(200);
|
||
expect((await res.json()).url).toBe("https://checkout.stripe.com/ohne-branding");
|
||
const retry = vi.mocked(stripe.checkout.sessions.create).mock.calls[1][0] as never as {
|
||
branding_settings?: unknown;
|
||
billing_address_collection: string;
|
||
};
|
||
expect(retry.branding_settings).toBeUndefined();
|
||
expect(retry.billing_address_collection).toBe("required");
|
||
});
|
||
});
|
||
|
||
describe("POST /api/stripe/checkout/course – Platzwahl", () => {
|
||
const reformer = (id: string, label: string) => ({
|
||
id,
|
||
type: "reformer" as const,
|
||
x: 1,
|
||
y: 1.5,
|
||
rotation: 0 as const,
|
||
label,
|
||
});
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
|
||
vi.mocked(isUser).mockReturnValue(true);
|
||
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
|
||
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
|
||
vi.mocked(holdSpot).mockReset();
|
||
vi.mocked(releaseSpotHold).mockClear();
|
||
vi.mocked(attachSessionToHold).mockClear();
|
||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
|
||
vi.mocked(stripe.checkout.sessions.expire).mockReset();
|
||
vi.mocked(releaseDiscountReservation).mockClear();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||
});
|
||
|
||
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
|
||
const res = await POST(makeRequest({ courseId: "course-1" }));
|
||
|
||
expect(res.status).toBe(400);
|
||
expect((await res.json()).spotUnavailable).toBe(true);
|
||
expect(holdSpot).not.toHaveBeenCalled();
|
||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("lehnt einen Platz ab, den gerade jemand anderes bucht", async () => {
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: false, reason: "Platz gerade vergeben" });
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||
|
||
expect(res.status).toBe(409);
|
||
expect(await res.json()).toMatchObject({
|
||
error: "Platz gerade vergeben",
|
||
spotUnavailable: true,
|
||
});
|
||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("hält den Platz und lässt die Session mit dem Hold verfallen", async () => {
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||
expect(res.status).toBe(200);
|
||
|
||
expect(vi.mocked(holdSpot).mock.calls[0][1]).toMatchObject({
|
||
courseId: "course-1",
|
||
spotId: "spot-2",
|
||
holderKey: "user:user-1",
|
||
});
|
||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||
expires_at?: number;
|
||
metadata: Record<string, string>;
|
||
line_items: { price_data: { product_data: { description: string } } }[];
|
||
};
|
||
expect(args.metadata).toMatchObject({ spotId: "spot-2", spotHoldId: "hold-1" });
|
||
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
|
||
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
|
||
expect(inMinutes).toBeGreaterThan(29);
|
||
expect(inMinutes).toBeLessThanOrEqual(30);
|
||
});
|
||
|
||
it("gibt den Platz frei, wenn Stripe die Session nicht anlegt", async () => {
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
vi.mocked(stripe.checkout.sessions.create).mockRejectedValueOnce(new Error("Stripe down"));
|
||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-1");
|
||
consoleError.mockRestore();
|
||
});
|
||
|
||
it("setzt ohne Platzwahl kein Ablaufdatum - Stripes Standard bleibt", async () => {
|
||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||
expect(res.status).toBe(200);
|
||
|
||
const args = vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as {
|
||
expires_at?: number;
|
||
metadata: Record<string, string>;
|
||
};
|
||
expect(args.expires_at).toBeUndefined();
|
||
expect(args.metadata.spotId).toBe("");
|
||
expect(holdSpot).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
|
||
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||
reserved: true,
|
||
reservationToken: "tok-1",
|
||
discountCode: { id: "code-1", type: "percent", value: 30 },
|
||
} as never);
|
||
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
|
||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
|
||
const res = await POST(
|
||
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
|
||
);
|
||
|
||
expect(res.status).toBe(500);
|
||
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
|
||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||
consoleError.mockRestore();
|
||
});
|
||
|
||
it("hängt die neue Session an den Hold", async () => {
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
|
||
id: "cs_neu",
|
||
url: "https://checkout.stripe.com/neu",
|
||
} as never);
|
||
|
||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||
|
||
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
|
||
});
|
||
|
||
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
|
||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||
]);
|
||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||
id: "cs_alt",
|
||
status: "open",
|
||
} as never);
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
|
||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||
|
||
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
|
||
"cs_alt",
|
||
{},
|
||
{ stripeAccount: "acct_123" }
|
||
);
|
||
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
|
||
});
|
||
|
||
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
|
||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||
]);
|
||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||
id: "cs_alt",
|
||
status: "complete",
|
||
} as never);
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
|
||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||
|
||
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
|
||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||
});
|
||
|
||
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
|
||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||
]);
|
||
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
|
||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
|
||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||
consoleWarn.mockRestore();
|
||
});
|
||
});
|