import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CheckoutForm } from "./checkout-form"; import { useCartStore, type CartItem } from "@/store/cart-store"; import { I18nProvider } from "@/i18n/i18n-provider"; import type { ShippingTierLike } from "@/lib/shipping-calc"; import type { BulkDiscountTierLike } from "@/lib/bulk-discount-calc"; // Geteilte Instanz statt eines frischen vi.fn() je Aufruf: nur so laesst sich // pruefen, dass NICHT navigiert wurde. const routerPush = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: routerPush }), })); // Stripe.js laesst sich in jsdom nicht laden — der Zahlungsschritt wird // durch einen Platzhalter ersetzt, der zeigt, was er bekommen hat, und den // Abbruch-Rueckruf ausloesen kann. vi.mock("./embedded-payment", () => ({ EmbeddedPayment: (props: { clientSecret: string; cancelToken: string; cancelLabel: string; onCancelled: () => void; }) => (
), })); const fetchMock = vi.fn(); const PUBLISHABLE_KEY = "pk_test_abc123"; const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCents: 700 }]; const bulkDiscountTiers: BulkDiscountTierLike[] = []; const item = (overrides: Partial = {}): CartItem => ({ productId: "p1", slug: "fruehtracht-2026", name: "Frühtracht 2026", weightGrams: 500, priceCents: 990, image: null, quantity: 1, ...overrides, }); function renderCheckout(items: CartItem[], stripePublishableKey: string | undefined = PUBLISHABLE_KEY) { useCartStore.setState({ items, isOpen: false }); return render( ); } async function fillAndSubmit(userEventInstance: ReturnType) { await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika"); await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann"); await userEventInstance.type(screen.getByLabelText(/e-mail/i), "erika@example.org"); await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1"); await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450"); await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster"); await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i })); } beforeEach(() => { useCartStore.setState({ items: [], isOpen: false }); routerPush.mockClear(); fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); describe("CheckoutForm", () => { test("shows the empty-cart message instead of the form when the cart is empty", () => { renderCheckout([]); expect(screen.queryByRole("button", { name: /kostenpflichtig bestellen/i })).not.toBeInTheDocument(); }); test("charges shipping by default and drops it when switching to pickup", async () => { const userEventInstance = userEvent.setup(); renderCheckout([item()]); expect(screen.getByText("7,00 €")).toBeInTheDocument(); await userEventInstance.click(screen.getByRole("button", { name: /abholung/i })); expect(screen.queryByText("7,00 €")).not.toBeInTheDocument(); expect(screen.getByText("kostenlos")).toBeInTheDocument(); }); /** * Regression: Antwortet /api/checkout mit 502 (Stripe nicht erreichbar), * schickte das Formular frueher auf die Erfolgsseite mit dem Hinweis "wir * melden uns". Es gab zu dieser Bestellung aber gar keine Zahlungssitzung — * sie war auf keinem Weg bezahlbar, und die Seite versprach etwas, das * niemand einloesen konnte. */ test("shows the server error instead of the success page when the payment session cannot be created", async () => { const userEventInstance = userEvent.setup(); const serverError = "Die Zahlung konnte nicht gestartet werden — es wurde nichts abgebucht. Bitte versuche es in ein paar Minuten erneut oder melde dich bei uns."; fetchMock.mockResolvedValue({ ok: false, status: 502, json: async () => ({ error: serverError }), } as Response); renderCheckout([item()]); await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika"); await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann"); await userEventInstance.type(screen.getByLabelText(/e-mail/i), "erika@example.org"); await userEventInstance.type(screen.getByLabelText(/straße|strasse/i), "Wiesenweg 1"); await userEventInstance.type(screen.getByLabelText(/plz|postleitzahl/i), "86450"); await userEventInstance.type(screen.getByLabelText(/ort|stadt/i), "Altenmünster"); await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i })); await waitFor(() => expect(screen.getByText(serverError)).toBeInTheDocument()); // Das Formular navigiert inzwischen gar nicht mehr — diese Zusicherung // faellt erst dann wieder auf, wenn jemand die Weiterleitung auf die // Erfolgsseite erneut einbaut. Genau dafuer steht sie hier. expect(routerPush).not.toHaveBeenCalled(); }); test("shows the embedded payment step on this page instead of redirecting to Stripe", async () => { const userEventInstance = userEvent.setup(); fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }), } as Response); renderCheckout([item()]); await fillAndSubmit(userEventInstance); const payment = await screen.findByTestId("embedded-payment"); expect(payment).toHaveAttribute("data-client-secret", "cs_test_1_secret_abc"); // Das Formular tritt zurueck, bleibt aber erhalten. expect(screen.getByLabelText(/vorname/i)).not.toBeVisible(); }); test("brings back the filled-in form after leaving the payment step", async () => { const userEventInstance = userEvent.setup(); fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }), } as Response); renderCheckout([item()]); await fillAndSubmit(userEventInstance); await userEventInstance.click(await screen.findByRole("button", { name: /zurück zu deinen angaben/i })); expect(screen.queryByTestId("embedded-payment")).not.toBeInTheDocument(); expect(screen.getByLabelText(/vorname/i)).toBeVisible(); expect(screen.getByLabelText(/vorname/i)).toHaveValue("Erika"); }); test("creates no order when no publishable key is configured", async () => { const userEventInstance = userEvent.setup(); renderCheckout([item()], ""); await fillAndSubmit(userEventInstance); expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument(); expect(fetchMock).not.toHaveBeenCalled(); }); });