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,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"));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user