diff --git a/README.md b/README.md index 4dd1724..bf8ff31 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/scripts/preflight.ts b/scripts/preflight.ts index 7c39aeb..4abe48c 100644 --- a/scripts/preflight.ts +++ b/scripts/preflight.ts @@ -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}`); diff --git a/src/app/api/checkout/route.test.ts b/src/app/api/checkout/route.test.ts new file mode 100644 index 0000000..ab69846 --- /dev/null +++ b/src/app/api/checkout/route.test.ts @@ -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>(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: "erika@example.org", + 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 = validPayload): Promise { + 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"); + }); + }); +}); diff --git a/src/app/api/checkout/webhook/route.test.ts b/src/app/api/checkout/webhook/route.test.ts new file mode 100644 index 0000000..ac3f1e2 --- /dev/null +++ b/src/app/api/checkout/webhook/route.test.ts @@ -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: "

ok

", + text: "ok", + })), + paymentFailedEmail: vi.fn(async () => ({ + subject: "Zahlung fehlgeschlagen", + html: "

fehlgeschlagen

", + 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: "erika@example.org", + 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 = {}) { + 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 { + 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")); + }); + }); +});