test: Geldpfade absichern und Go-Live-Pruefung ehrlich machen
Die beiden Dateien, in denen Geld und Lagerbestand zusammenlaufen, hatten bisher keinen einzigen Test. Dazu meldete die Go-Live-Pruefung gruene Haken fuer Werte, die gar nicht gesetzt waren. Tests fuer api/checkout/route.ts (10 Faelle) Torwaechter (fremder Origin, Turnstile, Rate-Limit), deaktiviertes Produkt, Preis 0, ausverkaufte Ware, Sitzungslebensdauer — und die beiden Regressionen: scheitert die Stripe-Sitzung, wird die Bestellung aufgeloest und OHNE orderNumber geantwortet; laesst sich die Sitzungs-ID nicht speichern, wird die Sitzung geschlossen. Per Mutationstest geprueft, dass der zweite Fall ohne den Fix rot wird. Tests fuer api/checkout/webhook/route.ts (12 Faelle) Signaturpruefung, verzoegerte Zahlung (SEPA), Auslieferung samt Rechnung und Anhang, mehrfach zugestelltes Ereignis (Gutschein und Rechnungsnummer duerfen sich nicht wiederholen), Zahlung auf eine stornierte Bestellung, Betragsabweichung, abgelaufene Sitzung, fehlgeschlagener Einzug, volle und teilweise Erstattung, Rueckbuchung. Datenbank und Stripe sind in beiden Dateien vollstaendig ersetzt. Go-Live-Pruefung: falsch-positive Haken beseitigt Regeln, die einen Wert voraussetzen, bestanden bei fehlendem Wert einfach — eine leere Zeichenkette enthaelt nun einmal kein "example.com". Der Bericht meldete deshalb "NEXT_PUBLIC_SITE_URL ist die echte Domain" als bestanden, direkt neben dem Blocker "NEXT_PUBLIC_SITE_URL gesetzt". Ohne jeden Stripe-Schluessel galt sogar der Live-Modus als bestaetigt. Das ist die gefaehrlichste Fehlmeldung, die eine solche Pruefung machen kann: sie beruhigt ueber genau den Punkt, der offen ist. mustWhenSet/shouldWhenSet lassen Folgeregeln bei fehlendem Wert entfallen. Ausserdem sagt der Bericht jetzt ausdruecklich, dass er die Umgebung prueft, in der er LAEUFT. Lokal meldet er Blocker, die in der ConfigMap des Clusters laengst erledigt sind; wer das ein paarmal erlebt, liest ueber den Bericht hinweg. Das README nennt den Aufruf im Pod. tsc: 0 Fehler. vitest: 316/316 gruen (zuvor 294). eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
1fa8dc9ce2
commit
dbf146c19f
4 files changed
+732
-12
No files matched your search
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user