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:
Nick SzaunigandClaude Opus 5 committed 2026-09-18 17:50:38 +02:00
1 parent 1fa8dc9ce2
commit dbf146c19f
4 files changed
+732 -12

No files matched your search

+10
View File
@@ -308,6 +308,16 @@ Die App läuft danach hinter [Caddy](https://caddyserver.com/) unter `http://loc
pnpm preflight
```
**Geprüft wird die Umgebung, in der der Befehl läuft — nicht „das Projekt".** Auf einem Entwicklungsrechner liest er die lokale `.env`, und die enthält absichtlich einen `sk_test`-Schlüssel, kein SMTP und keine echte Domain. Er meldet dort also Blocker, die für den Live-Betrieb längst erledigt sind: `NEXT_PUBLIC_SITE_URL`, `SMTP_FROM` und die Steuersätze stehen in `k8s/01-configmap.yaml`, die Geheimnisse in `k8s/02-secret.yaml`. Wer das ein paarmal erlebt, gewöhnt sich an, über den Bericht hinwegzulesen — und übersieht dann den einen Blocker, der echt ist.
Für eine belastbare Aussage deshalb **im laufenden Pod** ausführen, wo ConfigMap und Secret tatsächlich anliegen:
```bash
kubectl exec -n wabenhain deploy/wabenhain-app -- pnpm preflight
```
Der Exit-Code ist dabei derselbe (1, solange ein Blocker offen ist), der Aufruf lässt sich also unverändert als letzter Schritt in die Deploy-Pipeline hängen.
Prüft die Konfiguration gegen alles, was einen öffentlichen Shop still unbrauchbar machen kann, und beendet sich mit Exit-Code 1, solange ein Blocker offen ist — dadurch lässt sich der Aufruf unverändert als Schritt in die Deploy-Pipeline hängen. Geprüft werden unter anderem:
- `SESSION_SECRET`, `ADMIN_EMAIL` und `ADMIN_PASSWORD` auf Länge und darauf, dass noch der Beispielwert aus `.env.example` darin steht (ein bekanntes Signiergeheimnis macht jede Sitzung fälschbar).
+58 -12
View File
@@ -43,6 +43,33 @@ function should(label: string, ok: boolean, hint: string): void {
const value = (name: string): string => (process.env[name] ?? "").trim();
const isSet = (name: string): boolean => value(name).length > 0;
/**
* Eine Regel, die einen GESETZTEN Wert voraussetzt.
*
* Ohne diese Weiche meldete der Bericht gruene Haken fuer Variablen, die gar
* nicht existieren: "NEXT_PUBLIC_SITE_URL ist die echte Domain, kein
* Platzhalter" stand als bestanden da, weil eine leere Zeichenkette nun einmal
* kein "example.com" enthaelt — direkt neben dem Blocker
* "NEXT_PUBLIC_SITE_URL gesetzt". Dasselbe galt fuer den Test auf einen
* sk_test-Schluessel: ohne jeden Stripe-Schluessel galt der Live-Modus als
* bestaetigt.
*
* Das ist die gefaehrlichste Art von Fehlmeldung, die eine Go-Live-Pruefung
* machen kann — sie beruhigt ueber genau den Punkt, der offen ist. Fehlt der
* Wert, entfaellt die Folgeregel hier stillschweigend; gemeldet wird er
* ohnehin schon von der Regel, die seine blosse Existenz prueft.
*/
function mustWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
must(label, ok(value(name)), hint);
}
/** Wie mustWhenSet, nur als Warnung statt als Blocker. */
function shouldWhenSet(name: string, label: string, ok: (raw: string) => boolean, hint: string): void {
if (!isSet(name)) return;
should(label, ok(value(name)), hint);
}
// --- Sitzungs-/Zugangsgeheimnisse -----------------------------------------
const sessionSecret = value("SESSION_SECRET");
must(
@@ -50,9 +77,10 @@ must(
sessionSecret.length >= 32,
"Signiert die Login-Cookies. Erzeugen mit: openssl rand -base64 32"
);
must(
mustWhenSet(
"SESSION_SECRET",
"SESSION_SECRET ist nicht mehr der Beispielwert",
!sessionSecret.includes("change-me"),
(raw) => !raw.includes("change-me"),
"Der Wert aus .env.example ist oeffentlich bekannt — jede Sitzung waere faelschbar."
);
@@ -75,14 +103,16 @@ must(
siteUrl.length > 0,
"Einzige Quelle fuer Sitemap, Canonical-Tags, JSON-LD und alle Links in E-Mails (src/lib/url.ts)."
);
must(
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL nutzt HTTPS und nicht localhost",
siteUrl.startsWith("https://") && !siteUrl.includes("localhost"),
(raw) => raw.startsWith("https://") && !raw.includes("localhost"),
"Bei HTTP oder localhost landen genau diese Adressen in Bestellbestaetigungen und in der Sitemap."
);
must(
mustWhenSet(
"NEXT_PUBLIC_SITE_URL",
"NEXT_PUBLIC_SITE_URL ist die echte Domain, kein Platzhalter",
!siteUrl.includes("example.com") && !siteUrl.includes("example.org"),
(raw) => !raw.includes("example.com") && !raw.includes("example.org"),
'Die echte Domain steht in k8s/01-configmap.yaml (NEXT_PUBLIC_SITE_URL) und k8s/06-ingress.yaml (host/tls.hosts) und muss an beiden Stellen dieselbe sein. Steht hier eine Beispiel-Domain, zeigen Sitemap, Canonical-Tags und jeder Link in jeder Bestellbestaetigung auf eine fremde Domain.'
);
@@ -97,9 +127,10 @@ must(
isSet("STRIPE_WEBHOOK_SECRET"),
'Ohne Webhook-Secret wird keine Zahlung bestaetigt: Bestellungen bleiben auf "NEU" stehen, es gibt keine Rechnung und keine Bestaetigungsmail.'
);
must(
mustWhenSet(
"STRIPE_SECRET_KEY",
"Stripe laeuft im Live-Modus (kein sk_test-Schluessel)",
!value("STRIPE_SECRET_KEY").startsWith("sk_test"),
(raw) => !raw.startsWith("sk_test"),
"Mit einem Test-Schluessel zahlt niemand echtes Geld — Bestellungen sehen erfolgreich aus, ohne dass Umsatz entsteht."
);
@@ -109,9 +140,12 @@ must(
isSet("SMTP_HOST") && isSet("SMTP_USER") && isSet("SMTP_PASSWORD"),
"Ohne SMTP werden E-Mails nur ins Server-Log geschrieben (siehe src/lib/mail.ts): keine Bestellbestaetigung, keine Versandmeldung, keine Passwort-Zuruecksetzung."
);
should(
// Ohne SMTP_FROM greift der Standardabsender aus lib/mail.ts — deshalb nur
// pruefen, wenn tatsaechlich ein eigener Wert hinterlegt ist.
shouldWhenSet(
"SMTP_FROM",
"SMTP_FROM zeigt auf eine eigene Domain",
!value("SMTP_FROM").includes("example.com"),
(raw) => !raw.includes("example.com"),
"Absenderadressen fremder Domains landen regelmaessig im Spam-Ordner."
);
@@ -126,9 +160,10 @@ must(
vatRate.length > 0 && Number.isInteger(vatRateNumber) && vatRateNumber >= 0 && vatRateNumber <= 100,
"Wird auf jeder Rechnung ausgewiesen. 7 = ermaessigt (Honig), 19 = voll, 0 = Kleinunternehmerregelung nach § 19 UStG. Ohne gueltigen Wert rechnet die Anwendung mit 7% weiter — fuer Kerzen zu niedrig, fuer Kleinunternehmer schlicht falsch."
);
should(
shouldWhenSet(
"VAT_RATE_PERCENT",
"Umsatzsteuersatz ist ein in Deutschland ueblicher Wert",
["0", "7", "19"].includes(vatRate),
(raw) => ["0", "7", "19"].includes(raw),
`Eingetragen ist "${vatRate}". Ueblich sind 0 (§ 19 UStG), 7 (ermaessigt) oder 19 (voll) — ein anderer Wert ist moeglich, aber pruefenswert.`
);
@@ -295,6 +330,17 @@ function report(): void {
// --- Ausgabe ---------------------------------------------------------------
const line = "─".repeat(72);
console.log(`\n${line}\nGo-Live-Pruefung Wabenhain\n${line}`);
// Ohne diesen Hinweis wird der Bericht regelmaessig falsch gelesen: auf einem
// Entwicklungsrechner meldet er Blocker fuer Werte, die in der ConfigMap des
// Clusters laengst richtig stehen (NEXT_PUBLIC_SITE_URL, SMTP_FROM,
// VAT_RATE_PERCENT). Wer das ein paarmal erlebt, sieht ueber den Bericht
// hinweg — und damit auch ueber den einen Blocker, der echt ist.
console.log(
`
Geprueft wird die Umgebung, in der dieses Skript LAEUFT. Fuer eine Aussage
ueber den Live-Betrieb im Pod ausfuehren, nicht auf dem eigenen Rechner —
siehe README, Abschnitt "Vor dem Live-Gang".`
);
console.log(`\n✔ Bestanden: ${passed.length}`);
for (const item of passed) console.log(` ✔ ${item}`);
+270
View File
@@ -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");
});
});
});
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests fuer den Stripe-Webhook — die Stelle, an der Geld in den Shop kommt.
*
* Hier entscheidet sich, ob eine Bestellung als bezahlt gilt, ob eine Rechnung
* mit fortlaufender Nummer entsteht und ob ein Gutschein als eingeloest zaehlt.
* Ein Fehler faellt an dieser Stelle im Betrieb praktisch nicht auf: Stripe
* quittiert brav, die Kundschaft hat bezahlt, und erst Wochen spaeter fehlt
* eine Rechnung oder ein Gutschein wurde doppelt gezaehlt.
*
* Datenbank und Stripe sind vollstaendig ersetzt. Geprueft wird nicht, ob
* Prisma funktioniert, sondern ob dieser Handler unter den Bedingungen, die im
* Betrieb tatsaechlich vorkommen, das Richtige tut — allen voran bei mehrfach
* zugestellten Ereignissen, die Stripe ausdruecklich vorsieht.
*/
import { describe, test, expect, beforeEach, vi } from "vitest";
const constructEvent = vi.fn();
vi.mock("@/lib/stripe", () => ({
getStripeClient: () => ({
webhooks: { constructEvent },
}),
}));
const sendMail = vi.fn();
vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...args) }));
vi.mock("@/lib/email-templates", () => ({
orderConfirmationEmail: vi.fn(async () => ({
subject: "Bestellbestaetigung",
html: "<p>ok</p>",
text: "ok",
})),
paymentFailedEmail: vi.fn(async () => ({
subject: "Zahlung fehlgeschlagen",
html: "<p>fehlgeschlagen</p>",
text: "fehlgeschlagen",
})),
}));
const releaseUnpaidOrder = vi.fn();
vi.mock("@/lib/order-release", () => ({
releaseUnpaidOrder: (...args: unknown[]) => releaseUnpaidOrder(...args),
}));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
// Rechnungs-PDF: die Erzeugung selbst hat eigene Tests (invoice-pdf,
// invoice-storage). Hier zaehlt nur, dass sie angestossen wird und dass das
// erzeugte Dokument an der Bestaetigungsmail haengt.
const getInvoiceByNumber = vi.fn();
vi.mock("@/lib/account-data", () => ({
getInvoiceByNumber: (...args: unknown[]) => getInvoiceByNumber(...args),
}));
vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: vi.fn(async () => Buffer.from("pdf")) }));
vi.mock("@/lib/invoice-storage", () => ({
invoiceRelativePath: vi.fn(() => "2026/mustermann-erika/RE-2026-0001.pdf"),
storeInvoicePdf: vi.fn(async () => undefined),
}));
/**
* Minimaler Prisma-Ersatz. `$transaction` reicht dieselbe Instanz als `tx`
* weiter — der Handler verlaesst sich auf die Atomaritaet der Datenbank, und
* die laesst sich in einem Unit-Test ohnehin nicht nachbilden. Geprueft wird
* hier die Ablauflogik, nicht das Sperrverhalten.
*/
const db = {
order: {
updateMany: vi.fn(),
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
},
discountCode: { update: vi.fn() },
payment: { upsert: vi.fn(), findFirst: vi.fn(), update: vi.fn() },
invoice: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
invoiceCounter: { upsert: vi.fn() },
$transaction: vi.fn(),
};
vi.mock("@/lib/prisma", () => ({ prisma: db }));
const { POST } = await import("./route");
const ORDER_ID = "order_1";
const paidOrder = {
id: ORDER_ID,
orderNumber: "WH-2026-AABBCCDD",
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
locale: "de",
fulfillmentMethod: "VERSAND",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmuenster",
giftMessage: null,
subtotalCents: 1980,
shippingCents: 700,
discountCents: 0,
totalCents: 2680,
discountCodeId: null as string | null,
items: [{ productName: "Fruehtracht 2026", quantity: 2, weightGrams: 500, totalCents: 1980 }],
};
function session(overrides: Record<string, unknown> = {}) {
return {
id: "cs_test_1",
payment_status: "paid",
payment_intent: "pi_test_1",
amount_total: paidOrder.totalCents,
metadata: { orderId: ORDER_ID, orderNumber: paidOrder.orderNumber },
...overrides,
};
}
function post(): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/checkout/webhook", {
method: "POST",
headers: { "stripe-signature": "sig" },
body: "{}",
})
);
}
/** Stellt den Normalfall her: Bestellung steht auf "NEU" und wird beansprucht. */
function givenUnpaidOrderIsClaimed(order = paidOrder): void {
db.order.updateMany.mockResolvedValue({ count: 1 });
db.order.findUniqueOrThrow.mockResolvedValue(order);
db.invoice.findUnique.mockResolvedValue(null);
db.invoiceCounter.upsert.mockResolvedValue({ lastNumber: 1 });
db.invoice.create.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" });
getInvoiceByNumber.mockResolvedValue({
id: "inv_1",
invoiceNumber: "RE-2026-0001",
issuedAt: new Date("2026-01-15T10:30:00.000Z"),
totalCents: order.totalCents,
order: { ...order, locale: "de" },
});
}
beforeEach(() => {
vi.clearAllMocks();
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
db.$transaction.mockImplementation(async (arg: unknown) =>
typeof arg === "function" ? (arg as (tx: typeof db) => unknown)(db) : arg
);
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
});
describe("Stripe-Webhook", () => {
test("weist eine Anfrage mit ungueltiger Signatur ab, ohne irgendetwas zu schreiben", async () => {
constructEvent.mockImplementation(() => {
throw new Error("No signatures found matching the expected signature for payload");
});
const response = await post();
expect(response.status).toBe(400);
expect(db.order.updateMany).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("antwortet mit 500, solange STRIPE_WEBHOOK_SECRET fehlt", async () => {
delete process.env.STRIPE_WEBHOOK_SECRET;
const response = await post();
expect(response.status).toBe(500);
expect(constructEvent).not.toHaveBeenCalled();
});
test('markiert eine Bestellung bei "completed" NICHT als bezahlt, solange payment_status "unpaid" ist', async () => {
// SEPA-Lastschrift: die Sitzung ist abgeschlossen, das Geld aber noch nicht
// eingezogen. Die Bestaetigung kommt spaeter als eigenes Ereignis.
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session({ payment_status: "unpaid" }) },
});
await post();
expect(db.order.updateMany).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
test("markiert die Bestellung als bezahlt, legt eine Rechnung an und verschickt die Bestaetigung", async () => {
givenUnpaidOrderIsClaimed();
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.order.updateMany).toHaveBeenCalledWith({
where: { id: ORDER_ID, status: "NEU" },
data: { status: "BEZAHLT" },
});
expect(db.payment.upsert).toHaveBeenCalledOnce();
expect(db.invoice.create).toHaveBeenCalledOnce();
expect(sendMail).toHaveBeenCalledOnce();
// Die Rechnung liegt der Bestaetigung bei — Gastbestellungen haben kein
// Konto, in dem sie sonst zu finden waere.
expect(sendMail.mock.calls[0][0].attachments?.[0]?.filename).toBe("RE-2026-0001.pdf");
});
test("zaehlt einen Gutschein bei mehrfach zugestelltem Ereignis nur einmal", async () => {
const withCode = { ...paidOrder, discountCodeId: "code_1" };
givenUnpaidOrderIsClaimed(withCode);
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.discountCode.update).toHaveBeenCalledOnce();
// Zweite Zustellung desselben Ereignisses: die Bestellung steht bereits auf
// "BEZAHLT", der Anspruch geht ins Leere (count 0).
db.order.updateMany.mockResolvedValue({ count: 0 });
db.order.findUnique.mockResolvedValue({ status: "BEZAHLT", orderNumber: withCode.orderNumber });
db.invoice.findUnique.mockResolvedValue({ id: "inv_1", invoiceNumber: "RE-2026-0001" });
await post();
// Weiterhin genau ein Mal — Payment, Rechnung und Mail duerfen nachgeholt
// werden, der Gutscheinzaehler aber nicht erneut erhoeht. Auch die
// Rechnungsnummer wird nicht ein zweites Mal gezogen: eine Luecke in der
// Nummernfolge ist genau das, was § 14 Abs. 4 Nr. 4 UStG ausschliesst.
expect(db.discountCode.update).toHaveBeenCalledOnce();
expect(db.invoiceCounter.upsert).toHaveBeenCalledOnce();
});
test("liefert nichts aus und meldet laut, wenn eine Zahlung zu einer stornierten Bestellung eintrifft", async () => {
// Regression: frueher ein stilles "return null". Das Geld war eingezogen,
// im Shop entstand aber weder Rechnung noch Bestaetigung noch ein
// Logeintrag — das faellt niemandem auf, bis sich die Kundschaft meldet.
db.order.updateMany.mockResolvedValue({ count: 0 });
db.order.findUnique.mockResolvedValue({
status: "STORNIERT",
orderNumber: paidOrder.orderNumber,
});
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session() },
});
await post();
expect(db.invoice.create).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining(`Bestellung ${paidOrder.orderNumber}, die auf "STORNIERT" steht`)
);
});
test("meldet eine Betragsabweichung zwischen berechneter und eingezogener Summe", async () => {
givenUnpaidOrderIsClaimed();
constructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: session({ amount_total: 2000 }) },
});
await post();
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Betragsabweichung"));
// Das Geld ist geflossen — die Bestellung bleibt trotzdem bezahlt.
expect(db.payment.upsert).toHaveBeenCalledOnce();
});
test("loescht eine abgelaufene, nie bezahlte Bestellung und schickt dazu KEINE Mail", async () => {
releaseUnpaidOrder.mockResolvedValue({
orderNumber: paidOrder.orderNumber,
email: paidOrder.email,
firstName: paidOrder.firstName,
locale: "de",
});
constructEvent.mockReturnValue({
type: "checkout.session.expired",
data: { object: session({ payment_status: "unpaid" }) },
});
await post();
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "delete", paidOrder.totalCents);
// Eine Mail zu einem Vorgang, den man selbst abgebrochen hat, verwirrt
// mehr als sie hilft.
expect(sendMail).not.toHaveBeenCalled();
});
test("storniert bei fehlgeschlagenem Einzug und benachrichtigt die Kundschaft", async () => {
releaseUnpaidOrder.mockResolvedValue({
orderNumber: paidOrder.orderNumber,
email: paidOrder.email,
firstName: paidOrder.firstName,
locale: "de",
});
constructEvent.mockReturnValue({
type: "checkout.session.async_payment_failed",
data: { object: session({ payment_status: "unpaid", amount_total: 2680 }) },
});
await post();
expect(releaseUnpaidOrder).toHaveBeenCalledWith(ORDER_ID, "cancel", 2680);
// Hier wurde tatsaechlich gezahlt und es ist gescheitert — jemand wartet
// auf Ware und muss erfahren, dass daraus nichts wird.
expect(sendMail).toHaveBeenCalledOnce();
});
describe("Geld, das den Shop wieder verlaesst", () => {
test("setzt die Zahlung bei vollstaendiger Erstattung auf REFUNDED", async () => {
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "SUCCEEDED",
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: {
object: {
payment_intent: "pi_test_1",
refunded: true,
amount: 2680,
amount_refunded: 2680,
},
},
});
await post();
expect(db.payment.update).toHaveBeenCalledWith({
where: { id: "pay_1" },
data: { status: "REFUNDED" },
});
});
test("laesst den Zahlungsstatus bei einer TEILerstattung unveraendert", async () => {
// Der Restbetrag ist weiterhin eingezogen — auf "REFUNDED" gesetzt
// wuerde eine Auswertung den ganzen Umsatz abschreiben.
db.payment.findFirst.mockResolvedValue({
id: "pay_1",
status: "SUCCEEDED",
order: { orderNumber: paidOrder.orderNumber },
});
constructEvent.mockReturnValue({
type: "charge.refunded",
data: {
object: {
payment_intent: "pi_test_1",
refunded: false,
amount: 2680,
amount_refunded: 500,
},
},
});
await post();
expect(db.payment.update).not.toHaveBeenCalled();
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Teilerstattung"));
});
test("haengt eine Rueckbuchung an die Notizen der Bestellung an, ohne bestehende zu verlieren", async () => {
db.payment.findFirst.mockResolvedValue({
orderId: ORDER_ID,
order: { orderNumber: paidOrder.orderNumber, notes: "Kundenwunsch: bitte klingeln" },
});
constructEvent.mockReturnValue({
type: "charge.dispute.created",
data: {
object: {
id: "dp_test_1",
payment_intent: "pi_test_1",
amount: 2680,
reason: "fraudulent",
},
},
});
await post();
const written = db.order.update.mock.calls[0][0].data.notes as string;
expect(written).toContain("Kundenwunsch: bitte klingeln");
expect(written).toContain("dp_test_1");
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("RUECKBUCHUNG"));
});
});
});