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 => ({ key: "p1", 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( ); } const sponsorshipItem = (key: string, beeName: string, honeyJars = 0): CartItem => ({ key, productId: "pkg-bienenfreund", slug: "bienenfreund", name: "Bienenfreund", weightGrams: 0, priceCents: 1900, image: null, quantity: 1, sponsorship: { offerSlug: "bienenfreund", offerName: "Bienenfreund", isPhysical: honeyJars > 0, honeyJars, personalization: { beeName, recipientName: "Erika Muster", occasion: "GEBURTSTAG", startDate: "2026-11-01" }, }, }); 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(); }); test("a cart with only digital sponsorships needs no delivery and is sent as DIGITAL with personalization", 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([sponsorshipItem("sp_1", "Frieda"), sponsorshipItem("sp_2", "Frieda")]); expect(screen.queryByRole("button", { name: /abholung/i })).not.toBeInTheDocument(); expect(screen.queryByLabelText(/straße|strasse/i)).not.toBeInTheDocument(); expect(screen.getAllByText("Biene „Frieda“")).toHaveLength(2); 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.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i })); await screen.findByTestId("embedded-payment"); const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as { fulfillmentMethod: string; street?: string; items: { productId: string; quantity: number; personalization?: { beeName: string } }[]; }; expect(body.fulfillmentMethod).toBe("DIGITAL"); expect(body.street).toBeUndefined(); // Zwei gleiche Pakete bleiben zwei Positionen, jede mit eigener Personalisierung. expect(body.items).toHaveLength(2); expect(body.items.every((line) => line.quantity === 1 && line.personalization?.beeName === "Frieda")).toBe(true); }); test("a package with honey keeps the delivery choice and counts its jars for shipping", () => { renderCheckout([sponsorshipItem("sp_1", "Lotte", 1)]); expect(screen.getByRole("button", { name: /abholung/i })).toBeInTheDocument(); expect(screen.getByText("7,00 €")).toBeInTheDocument(); }); test("shows the account message with a login link when the server answers 401", async () => { const userEventInstance = userEvent.setup(); const message = "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an."; fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: message }) } as Response); renderCheckout([sponsorshipItem("sp_1", "Frieda")]); 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.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i })); expect(await screen.findByText(message, { exact: false })).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Zur Anmeldung" })).toHaveAttribute("href", "/konto?next=%2Fcheckout"); }); });