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();
});
});