/** * Tests fuer die Checkout-Route — die Stelle, an der eine Bestellung entsteht * und dabei sofort Lagerbestand reserviert. * * Der Schwerpunkt liegt auf den Wegen, die schieflaufen koennen, nicht auf dem * Normalfall: ein deaktiviertes Produkt, ein Preis von 0, ausverkaufte Ware * und vor allem der Fall, dass Stripe nicht antwortet. Genau dort lag die * Luecke, die diese Tests festhalten — eine Bestellung ohne Zahlungssitzung * blieb auf "NEU" stehen und hielt ihren reservierten Bestand fuer immer fest, * ohne dass irgendwo ein Fehler sichtbar wurde. */ import { describe, test, expect, beforeEach, vi } from "vitest"; import { Prisma } from "@/generated/prisma/client"; const sessionsCreate = vi.fn(); const sessionsExpire = vi.fn(); const couponsCreate = vi.fn(); vi.mock("@/lib/stripe", () => ({ getStripeClient: () => ({ checkout: { sessions: { create: sessionsCreate, expire: sessionsExpire } }, coupons: { create: couponsCreate }, }), })); const getProductsByIds = vi.fn(); vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) })); vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) })); const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true); vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) })); const verifyTurnstileToken = vi.fn<(token: string | null, remoteIp?: string) => Promise>(async () => true); vi.mock("@/lib/turnstile", () => ({ verifyTurnstileToken: (token: string | null, remoteIp?: string) => verifyTurnstileToken(token, remoteIp), })); const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true); vi.mock("@/lib/rate-limit", () => ({ rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs), getClientIp: vi.fn(async () => "203.0.113.7"), })); vi.mock("@/lib/shipping", () => ({ getShippingCostCents: vi.fn(async () => 700) })); vi.mock("@/lib/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) })); vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" })); vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") })); const releaseUnpaidOrder = vi.fn(); vi.mock("@/lib/order-release", () => ({ releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args), })); /** Siehe webhook/route.test.ts — dieselbe Begruendung fuer denselben Ersatz. */ const db = { order: { create: vi.fn(), count: vi.fn(), update: vi.fn() }, inventory: { updateMany: vi.fn() }, discountCode: { findUnique: vi.fn(), findMany: vi.fn() }, $transaction: vi.fn(), }; vi.mock("@/lib/prisma", () => ({ prisma: db })); const { POST } = await import("./route"); const ORDER = { id: "order_1", orderNumber: "WH-2026-AABBCCDD" }; const honey = { id: "p1", name: "Fruehtracht 2026", slug: "fruehtracht-2026", priceCents: 990, weightGrams: 500, isActive: true, category: "HONIG", batchId: null, vatRatePercent: null, }; const validPayload = { email: "erika@example.org", firstName: "Erika", lastName: "Mustermann", fulfillmentMethod: "VERSAND", street: "Wiesenweg 1", zip: "86450", city: "Altenmünster", country: "DE", items: [{ productId: "p1", quantity: 2 }], }; function post(payload: Record = validPayload): Promise { return POST( new Request("https://wabenhain.test/api/checkout", { method: "POST", headers: { "Content-Type": "application/json", origin: "https://wabenhain.test" }, body: JSON.stringify(payload), }) ); } beforeEach(() => { vi.clearAllMocks(); isTrustedOrigin.mockReturnValue(true); rateLimit.mockReturnValue(true); verifyTurnstileToken.mockResolvedValue(true); getProductsByIds.mockResolvedValue([honey]); db.discountCode.findMany.mockResolvedValue([]); db.inventory.updateMany.mockResolvedValue({ count: 1 }); db.order.create.mockResolvedValue(ORDER); db.$transaction.mockImplementation(async (arg: unknown) => typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg ); sessionsCreate.mockResolvedValue({ id: "cs_test_1", url: "https://checkout.stripe.com/c/pay/cs_test_1", }); vi.spyOn(console, "error").mockImplementation(() => {}); }); describe("Checkout-Route", () => { describe("Torwaechter vor dem ersten Schreibzugriff", () => { test("weist einen fremden Origin ab, ohne Bestand zu reservieren", async () => { // Route-Handler haben keinen eingebauten CSRF-Schutz: ohne diese // Pruefung koennte eine fremde Seite Bestellungen unter dem Konto einer // eingeloggten Kundschaft anlegen. isTrustedOrigin.mockReturnValue(false); const response = await post(); expect(response.status).toBe(403); expect(db.inventory.updateMany).not.toHaveBeenCalled(); }); test("weist eine fehlgeschlagene Sicherheitspruefung ab", async () => { verifyTurnstileToken.mockResolvedValue(false); const response = await post(); expect(response.status).toBe(400); expect(db.order.create).not.toHaveBeenCalled(); }); test("bremst zu viele Bestellversuche derselben Adresse", async () => { rateLimit.mockReturnValue(false); const response = await post(); expect(response.status).toBe(429); expect(verifyTurnstileToken).not.toHaveBeenCalled(); }); }); describe("Was nicht verkauft werden darf", () => { test("laesst ein im Admin deaktiviertes Produkt nicht durch", async () => { // Ein Warenkorb, der im Browser lag, bevor das Produkt aus dem Verkauf // genommen wurde, kam frueher bis zur Zahlung durch. getProductsByIds.mockResolvedValue([{ ...honey, isActive: false }]); const response = await post(); expect(response.status).toBe(400); expect(db.order.create).not.toHaveBeenCalled(); }); test("laesst ein Produkt ohne gepflegten Preis nicht durch", async () => { // Preis 0 ist kein Geschenk, sondern ein fehlender Preis — aus ApiCore // importierte Produkte kommen genau so an. getProductsByIds.mockResolvedValue([{ ...honey, priceCents: 0 }]); const response = await post(); expect(response.status).toBe(400); expect(db.order.create).not.toHaveBeenCalled(); }); test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => { // Das bedingte updateMany trifft keine Zeile mehr — jemand anders war // schneller. db.inventory.updateMany.mockResolvedValue({ count: 0 }); const response = await post(); expect(response.status).toBe(409); expect(sessionsCreate).not.toHaveBeenCalled(); }); }); describe("Normalfall", () => { test("legt die Bestellung an, oeffnet eine Stripe-Sitzung und merkt sich deren Kennung", async () => { const response = await post(); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ url: "https://checkout.stripe.com/c/pay/cs_test_1", }); expect(db.order.update).toHaveBeenCalledWith({ where: { id: ORDER.id }, data: { stripeCheckoutSessionId: "cs_test_1" }, }); }); test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => { await post(); const { expires_at: expiresAt } = sessionsCreate.mock.calls[0][0]; const minutes = (expiresAt - Math.floor(Date.now() / 1000)) / 60; // Stripes Voreinstellung waeren 24 Stunden — damit liesse sich der Shop // mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen. Stripes // Untergrenze sind 30 Minuten. expect(minutes).toBeGreaterThanOrEqual(30); expect(minutes).toBeLessThanOrEqual(35); }); }); describe("Wenn viele gleichzeitig bestellen", () => { /** * Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen * Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf * Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben * Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der * Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei * der Kundschaft. */ function serializationConflict(): Error { return new Prisma.PrismaClientKnownRequestError("could not serialize access", { code: "P2034", clientVersion: "7", }); } test("wiederholt die Bestellanlage und kommt durch", async () => { const runTransaction = async (arg: unknown) => typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg; db.$transaction .mockRejectedValueOnce(serializationConflict()) .mockRejectedValueOnce(serializationConflict()) .mockImplementation(runTransaction); const response = await post(); expect(response.status).toBe(200); expect(db.$transaction).toHaveBeenCalledTimes(3); expect(sessionsCreate).toHaveBeenCalledOnce(); }); test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => { db.$transaction.mockRejectedValue(serializationConflict()); const response = await post(); expect(response.status).toBe(409); expect(db.$transaction).toHaveBeenCalledTimes(4); }); test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => { // Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis // vor — die Wiederholung wuerde die Antwort nur verzoegern. db.inventory.updateMany.mockResolvedValue({ count: 0 }); const response = await post(); expect(response.status).toBe(409); expect(db.$transaction).toHaveBeenCalledOnce(); }); }); describe("Wenn Stripe nicht mitspielt", () => { test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => { // Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID // stehen. Kein Aufraeumweg griff danach — der Abbruch-Klick braucht die // Sitzungs-ID, der CronJob ebenso, und ein Stripe-Ereignis kann es ohne // Sitzung nicht geben. Der reservierte Bestand war dauerhaft verloren. sessionsCreate.mockRejectedValue(new Error("Invalid API Key provided: sk_live_***")); releaseUnpaidOrder.mockResolvedValue({ orderNumber: ORDER.orderNumber, email: validPayload.email, firstName: validPayload.firstName, locale: "de", }); const response = await post(); const body = await response.json(); expect(response.status).toBe(502); expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel"); expect(db.order.update).toHaveBeenCalledWith({ where: { id: ORDER.id }, data: { notes: expect.stringContaining("Automatisch storniert") }, }); // Ohne orderNumber: mit ihr schickte die Oberflaeche frueher auf die // Erfolgsseite ("wir melden uns") — eine Zusage, die niemand einloesen // konnte. expect(body).not.toHaveProperty("orderNumber"); // Die Stripe-Meldung darf nicht nach aussen: dieser Endpunkt ist ohne // Anmeldung erreichbar und sie kann Kontodetails enthalten. expect(body.error).not.toContain("sk_live"); }); test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => { // Sonst entstuende genau der Zustand, den es nicht geben darf: eine // Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei // gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir // daraufhin nicht ausliefern. db.order.update.mockRejectedValueOnce(new Error("connection reset")); releaseUnpaidOrder.mockResolvedValue({ orderNumber: ORDER.orderNumber, email: validPayload.email, firstName: validPayload.firstName, locale: "de", }); const response = await post(); expect(response.status).toBe(502); expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1"); expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel"); }); }); });