Files
Wabenhain/src/app/api/checkout/route.test.ts
T
Nick SzaunigandClaude Opus 5 9b10a9f0c5 perf: Checkout haelt gleichzeitigem Andrang stand
Ausgangsfrage: Was passiert, wenn 20 oder 100 Leute zugleich bestellen?
20 waren unproblematisch, 100 nicht. Drei Engpaesse, alle im Checkout.

1. Serialisierungskonflikte landeten ungefiltert bei der Kundschaft

Die Bestellanlage laeuft unter "Serializable". Bricht die Datenbank eine
von zwei widerspruechlichen Transaktionen ab (P2034), ist das die
VORGESEHENE Antwort auf Gleichzeitigkeit und kein Sachfehler — erwartet
wird, dass der Aufrufer es noch einmal versucht. Genau das fehlte: der
Abbruch wurde direkt als 409 durchgereicht ("bitte in ein paar Sekunden
erneut versuchen"). Weil alle Bestellungen desselben Produkts auf
dieselbe Lagerbestandszeile zugreifen, sind solche Konflikte bei Andrang
der Normalfall — ein Teil der Kundschaft haette ausgerechnet im Moment
der Kaufentscheidung eine Fehlermeldung bekommen.

Jetzt bis zu vier Versuche mit wachsender, zufaellig gestreuter
Wartezeit. Ohne den Zufallsanteil starten alle abgebrochenen Anfragen
gleichzeitig neu und kollidieren wieder. Wiederholt wird ausschliesslich
dieser Fehlerfall: "ausverkauft" und "Gutschein aufgebraucht" sind
Sachentscheidungen und faenden beim zweiten Versuch dasselbe vor.

2. Zu enger Verbindungspool, zu knappe Transaktionsfristen

Der Postgres-Adapter lief mit der Voreinstellung von node-postgres: 10
Verbindungen. Eine Bestellung haelt ihre Verbindung ueber die gesamte
Transaktion, und Prisma gibt einer Transaktion nur 2 Sekunden, um
ueberhaupt eine zu bekommen (maxWait), sowie 5 Sekunden zum Ausfuehren.
Sind alle zehn belegt, scheitern weitere Checkouts nicht an der Sache,
sondern am Warten.

Pool auf 20 (PostgreSQL erlaubt voreingestellt 100 Verbindungen, daneben
brauchen CronJob, Sicherung und Konsole noch welche; die Anwendung laeuft
mit einer Instanz). maxWait 10s, timeout 15s — Puffer fuer die
Warteschlange, nicht fuer lange Rechnungen. Dazu eine Zeitgrenze beim
Holen einer Verbindung, damit sich unter Last nichts mehr lautlos
aufstaut.

Der MariaDB-Zweig bleibt unveraendert: sein Adapter nimmt entweder die
Verbindungszeichenkette ODER ein Einstellungsobjekt, nicht beides.
Produktiv laeuft PostgreSQL.

3. Drei Regressionstests

Wiederholung fuehrt zum Erfolg, nach vier Versuchen wird aufgegeben, und
bei "ausverkauft" wird NICHT wiederholt.

tsc: 0 Fehler. vitest: 336/336 gruen (zuvor 333). eslint: 0 Warnungen.
"next build" laeuft durch.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 06:10:41 +02:00

324 lines
12 KiB
TypeScript

/**
* Tests fuer die Checkout-Route — die Stelle, an der eine Bestellung entsteht
* und dabei sofort Lagerbestand reserviert.
*
* Der Schwerpunkt liegt auf den Wegen, die schieflaufen koennen, nicht auf dem
* Normalfall: ein deaktiviertes Produkt, ein Preis von 0, ausverkaufte Ware
* und vor allem der Fall, dass Stripe nicht antwortet. Genau dort lag die
* Luecke, die diese Tests festhalten — eine Bestellung ohne Zahlungssitzung
* blieb auf "NEU" stehen und hielt ihren reservierten Bestand fuer immer fest,
* ohne dass irgendwo ein Fehler sichtbar wurde.
*/
import { describe, test, expect, beforeEach, vi } from "vitest";
import { Prisma } from "@/generated/prisma/client";
const sessionsCreate = vi.fn();
const sessionsExpire = vi.fn();
const couponsCreate = vi.fn();
vi.mock("@/lib/stripe", () => ({
getStripeClient: () => ({
checkout: { sessions: { create: sessionsCreate, expire: sessionsExpire } },
coupons: { create: couponsCreate },
}),
}));
const getProductsByIds = vi.fn();
vi.mock("@/lib/data", () => ({ getProductsByIds: (...args: unknown[]) => getProductsByIds(...args) }));
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: vi.fn(async () => null) }));
const isTrustedOrigin = vi.fn<(request: Request) => boolean>(() => true);
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: (request: Request) => isTrustedOrigin(request) }));
const verifyTurnstileToken =
vi.fn<(token: string | null, remoteIp?: string) => Promise<boolean>>(async () => true);
vi.mock("@/lib/turnstile", () => ({
verifyTurnstileToken: (token: string | null, remoteIp?: string) =>
verifyTurnstileToken(token, remoteIp),
}));
const rateLimit = vi.fn<(key: string, limit: number, windowMs: number) => boolean>(() => true);
vi.mock("@/lib/rate-limit", () => ({
rateLimit: (key: string, limit: number, windowMs: number) => rateLimit(key, limit, windowMs),
getClientIp: vi.fn(async () => "203.0.113.7"),
}));
vi.mock("@/lib/shipping", () => ({ getShippingCostCents: vi.fn(async () => 700) }));
vi.mock("@/lib/bulk-discount", () => ({ ensureBulkDiscountCode: vi.fn(async () => undefined) }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/i18n/locale", () => ({ getLocale: vi.fn(async () => "de") }));
const releaseUnpaidOrder = vi.fn();
vi.mock("@/lib/order-release", () => ({
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
}));
/** Siehe webhook/route.test.ts — dieselbe Begruendung fuer denselben Ersatz. */
const db = {
order: { create: vi.fn(), count: vi.fn(), update: vi.fn() },
inventory: { updateMany: vi.fn() },
discountCode: { findUnique: vi.fn(), findMany: vi.fn() },
$transaction: vi.fn(),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const { POST } = await import("./route");
const ORDER = { id: "order_1", orderNumber: "WH-2026-AABBCCDD" };
const honey = {
id: "p1",
name: "Fruehtracht 2026",
slug: "fruehtracht-2026",
priceCents: 990,
weightGrams: 500,
isActive: true,
category: "HONIG",
batchId: null,
vatRatePercent: null,
};
const validPayload = {
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
fulfillmentMethod: "VERSAND",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmünster",
country: "DE",
items: [{ productId: "p1", quantity: 2 }],
};
function post(payload: Record<string, unknown> = validPayload): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json", origin: "https://wabenhain.test" },
body: JSON.stringify(payload),
})
);
}
beforeEach(() => {
vi.clearAllMocks();
isTrustedOrigin.mockReturnValue(true);
rateLimit.mockReturnValue(true);
verifyTurnstileToken.mockResolvedValue(true);
getProductsByIds.mockResolvedValue([honey]);
db.discountCode.findMany.mockResolvedValue([]);
db.inventory.updateMany.mockResolvedValue({ count: 1 });
db.order.create.mockResolvedValue(ORDER);
db.$transaction.mockImplementation(async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg
);
sessionsCreate.mockResolvedValue({
id: "cs_test_1",
url: "https://checkout.stripe.com/c/pay/cs_test_1",
});
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("Checkout-Route", () => {
describe("Torwaechter vor dem ersten Schreibzugriff", () => {
test("weist einen fremden Origin ab, ohne Bestand zu reservieren", async () => {
// Route-Handler haben keinen eingebauten CSRF-Schutz: ohne diese
// Pruefung koennte eine fremde Seite Bestellungen unter dem Konto einer
// eingeloggten Kundschaft anlegen.
isTrustedOrigin.mockReturnValue(false);
const response = await post();
expect(response.status).toBe(403);
expect(db.inventory.updateMany).not.toHaveBeenCalled();
});
test("weist eine fehlgeschlagene Sicherheitspruefung ab", async () => {
verifyTurnstileToken.mockResolvedValue(false);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("bremst zu viele Bestellversuche derselben Adresse", async () => {
rateLimit.mockReturnValue(false);
const response = await post();
expect(response.status).toBe(429);
expect(verifyTurnstileToken).not.toHaveBeenCalled();
});
});
describe("Was nicht verkauft werden darf", () => {
test("laesst ein im Admin deaktiviertes Produkt nicht durch", async () => {
// Ein Warenkorb, der im Browser lag, bevor das Produkt aus dem Verkauf
// genommen wurde, kam frueher bis zur Zahlung durch.
getProductsByIds.mockResolvedValue([{ ...honey, isActive: false }]);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("laesst ein Produkt ohne gepflegten Preis nicht durch", async () => {
// Preis 0 ist kein Geschenk, sondern ein fehlender Preis — aus ApiCore
// importierte Produkte kommen genau so an.
getProductsByIds.mockResolvedValue([{ ...honey, priceCents: 0 }]);
const response = await post();
expect(response.status).toBe(400);
expect(db.order.create).not.toHaveBeenCalled();
});
test("bricht ab, wenn der Bestand waehrend des Checkouts nicht mehr reicht", async () => {
// Das bedingte updateMany trifft keine Zeile mehr — jemand anders war
// schneller.
db.inventory.updateMany.mockResolvedValue({ count: 0 });
const response = await post();
expect(response.status).toBe(409);
expect(sessionsCreate).not.toHaveBeenCalled();
});
});
describe("Normalfall", () => {
test("legt die Bestellung an, oeffnet eine Stripe-Sitzung und merkt sich deren Kennung", async () => {
const response = await post();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
url: "https://checkout.stripe.com/c/pay/cs_test_1",
});
expect(db.order.update).toHaveBeenCalledWith({
where: { id: ORDER.id },
data: { stripeCheckoutSessionId: "cs_test_1" },
});
});
test("begrenzt die Lebensdauer der Sitzung, damit Bestand nicht tagelang blockiert bleibt", async () => {
await post();
const { expires_at: expiresAt } = sessionsCreate.mock.calls[0][0];
const minutes = (expiresAt - Math.floor(Date.now() / 1000)) / 60;
// Stripes Voreinstellung waeren 24 Stunden — damit liesse sich der Shop
// mit wenigen Klicks leerraeumen, ohne einen Cent zu zahlen. Stripes
// Untergrenze sind 30 Minuten.
expect(minutes).toBeGreaterThanOrEqual(30);
expect(minutes).toBeLessThanOrEqual(35);
});
});
describe("Wenn viele gleichzeitig bestellen", () => {
/**
* Bei "Serializable" bricht die Datenbank eine von zwei widerspruechlichen
* Transaktionen ab (P2034). Das ist die vorgesehene Antwort auf
* Gleichzeitigkeit, kein Sachfehler — und weil alle Bestellungen desselben
* Produkts auf dieselbe Bestandszeile zugreifen, ist das bei Andrang der
* Normalfall. Frueher landete jeder dieser Abbrueche als Fehlermeldung bei
* der Kundschaft.
*/
function serializationConflict(): Error {
return new Prisma.PrismaClientKnownRequestError("could not serialize access", {
code: "P2034",
clientVersion: "7",
});
}
test("wiederholt die Bestellanlage und kommt durch", async () => {
const runTransaction = async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg;
db.$transaction
.mockRejectedValueOnce(serializationConflict())
.mockRejectedValueOnce(serializationConflict())
.mockImplementation(runTransaction);
const response = await post();
expect(response.status).toBe(200);
expect(db.$transaction).toHaveBeenCalledTimes(3);
expect(sessionsCreate).toHaveBeenCalledOnce();
});
test("gibt nach vier vergeblichen Versuchen auf, statt endlos zu wiederholen", async () => {
db.$transaction.mockRejectedValue(serializationConflict());
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledTimes(4);
});
test("wiederholt NICHT, wenn die Ware schlicht ausverkauft ist", async () => {
// Eine Sachentscheidung faende beim zweiten Versuch dasselbe Ergebnis
// vor — die Wiederholung wuerde die Antwort nur verzoegern.
db.inventory.updateMany.mockResolvedValue({ count: 0 });
const response = await post();
expect(response.status).toBe(409);
expect(db.$transaction).toHaveBeenCalledOnce();
});
});
describe("Wenn Stripe nicht mitspielt", () => {
test("loest die Bestellung auf und meldet ehrlich, dass nichts abgebucht wurde", async () => {
// Regression: frueher blieb die Bestellung als "NEU" ohne Sitzungs-ID
// stehen. Kein Aufraeumweg griff danach — der Abbruch-Klick braucht die
// Sitzungs-ID, der CronJob ebenso, und ein Stripe-Ereignis kann es ohne
// Sitzung nicht geben. Der reservierte Bestand war dauerhaft verloren.
sessionsCreate.mockRejectedValue(new Error("Invalid API Key provided: sk_live_***"));
releaseUnpaidOrder.mockResolvedValue({
orderNumber: ORDER.orderNumber,
email: validPayload.email,
firstName: validPayload.firstName,
locale: "de",
});
const response = await post();
const body = await response.json();
expect(response.status).toBe(502);
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
expect(db.order.update).toHaveBeenCalledWith({
where: { id: ORDER.id },
data: { notes: expect.stringContaining("Automatisch storniert") },
});
// Ohne orderNumber: mit ihr schickte die Oberflaeche frueher auf die
// Erfolgsseite ("wir melden uns") — eine Zusage, die niemand einloesen
// konnte.
expect(body).not.toHaveProperty("orderNumber");
// Die Stripe-Meldung darf nicht nach aussen: dieser Endpunkt ist ohne
// Anmeldung erreichbar und sie kann Kontodetails enthalten.
expect(body.error).not.toContain("sk_live");
});
test("schliesst die Sitzung wieder, wenn sich ihre Kennung nicht speichern laesst", async () => {
// Sonst entstuende genau der Zustand, den es nicht geben darf: eine
// Bestellung auf "NEU" ohne Sitzungs-ID. Die Sitzung darf dabei
// gefahrlos verfallen — ihre URL steht nur in der Antwort, die wir
// daraufhin nicht ausliefern.
db.order.update.mockRejectedValueOnce(new Error("connection reset"));
releaseUnpaidOrder.mockResolvedValue({
orderNumber: ORDER.orderNumber,
email: validPayload.email,
firstName: validPayload.firstName,
locale: "de",
});
const response = await post();
expect(response.status).toBe(502);
expect(sessionsExpire).toHaveBeenCalledWith("cs_test_1");
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER.id, "cancel");
});
});
});