Bezahlt wird jetzt direkt auf /checkout: die Checkout-Session laeuft mit ui_mode "embedded_page", das Zahlungsformular ist ein Stripe-iframe (PCI SAQ A bleibt). PayPal, Karte, SEPA und Klarna kommen weiterhin dynamisch aus dem Dashboard. - Zahlungsschritt ersetzt das Formular; "Zurueck zu deinen Angaben" gibt Sitzung und Bestand sofort frei (POST /api/checkout/cancel) und behaelt alle Eingaben, Turnstile wird neu geladen - Rueckkehrseite prueft serverseitig bei Stripe und gleicht die Sitzung mit der eigenen Bestellung ab; bei abgebrochener PayPal-Zahlung wird dieselbe Sitzung erneut eingebettet - Erneuter Versuch und Abbruch sind per HttpOnly-Cookie an den bestellenden Browser gebunden, die session_id in der URL allein reicht nicht - Alte /checkout/cancel?ref= Links aendern per GET nichts mehr - CSP um die Stripe-Hosts fuer Embedded Checkout und 3-D-Secure erweitert - STRIPE_PUBLISHABLE_KEY zur Laufzeit gelesen (kein NEXT_PUBLIC_), mit Preflight-Pruefung auf pk_live und Schutz gegen versehentliche sk_/rk_ Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
183 lines
7.2 KiB
TypeScript
183 lines
7.2 KiB
TypeScript
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;
|
|
}) => (
|
|
<div data-testid="embedded-payment" data-client-secret={props.clientSecret}>
|
|
<button type="button" onClick={props.onCancelled}>
|
|
{props.cancelLabel}
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
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> = {}): 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(
|
|
<I18nProvider locale="de">
|
|
<CheckoutForm
|
|
tiers={tiers}
|
|
bulkDiscountTiers={bulkDiscountTiers}
|
|
stripePublishableKey={stripePublishableKey}
|
|
/>
|
|
</I18nProvider>
|
|
);
|
|
}
|
|
|
|
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
|
|
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
|
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
|
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
|
|
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), "[email protected]");
|
|
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();
|
|
});
|
|
});
|