Files
Wabenhain/src/app/checkout/checkout-form.test.tsx
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00

256 lines
11 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 => ({
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(
<I18nProvider locale="de">
<CheckoutForm
tiers={tiers}
bulkDiscountTiers={bulkDiscountTiers}
stripePublishableKey={stripePublishableKey}
/>
</I18nProvider>
);
}
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<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();
});
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), "[email protected]");
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), "[email protected]");
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");
});
});