first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import type Stripe from "stripe";
import { beforeEach, describe, expect, test } from "vitest";
import { cancelBooking, refundInstallment, saveVoucher, voucherInputSchema } from "@/lib/admin-service";
import { startCheckout } from "@/lib/checkout";
import { handleStripeEvent } from "@/lib/webhook";
import { chargeDueInstallments } from "@/lib/worker-jobs";
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
import { FakeGateway } from "./support/fake-gateway";
const ctx = { now: EARLY_BIRD_NOW, secret: SECRET, adminEmail: null };
const admin = { type: "ADMIN" as const, id: "admin-test" };
let gateway: FakeGateway;
beforeEach(async () => {
await resetDb();
await setupCamp();
gateway = new FakeGateway("test");
});
async function paidInstallmentBooking() {
const result = await startCheckout(db, gateway, bookingRequest({ planType: "THREE_INSTALLMENTS" }), ctx);
if (!result.ok) throw new Error(result.error.code);
const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } });
const s = gateway.completeSession(booking.stripeCheckoutSessionId!);
const event = {
id: `evt_${crypto.randomUUID()}`,
type: "checkout.session.completed",
livemode: false,
data: { object: { id: s.id, status: s.status, payment_status: s.paymentStatus, payment_intent: s.paymentIntentId, customer: s.customerId, metadata: s.metadata } },
} as unknown as Stripe.Event;
await handleStripeEvent(db, gateway, event, ctx);
return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
}
describe("Stornierung", () => {
test("stoppt alle weiteren Abbuchungen, gibt Platz frei, trennt die Zahlungsmethode", async () => {
const booking = await paidInstallmentBooking();
const result = await cancelBooking(db, gateway, { bookingId: booking.id, reason: "Test" }, admin);
expect(result.ok).toBe(true);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: true, reservation: true } });
expect(after.status).toBe("CANCELED");
expect(after.reservation!.status).toBe("RELEASED");
expect(after.installments.filter((i) => i.seq > 1).every((i) => i.status === "CANCELED")).toBe(true);
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
const later = { ...ctx, now: new Date("2027-06-01T12:00:00Z") };
await chargeDueInstallments(db, gateway, later);
expect(gateway.offSessionCharges).toHaveLength(0);
expect(await db.auditLog.count({ where: { action: "booking.canceled", actorId: "admin-test" } })).toBe(1);
});
test("verlangt einen Grund", async () => {
const booking = await paidInstallmentBooking();
expect((await cancelBooking(db, gateway, { bookingId: booking.id, reason: " " }, admin)).ok).toBe(false);
});
});
describe("Erstattung", () => {
test("nur bezahlte Raten, nie mehr als gezahlt, idempotent je Anfrage", async () => {
const booking = await paidInstallmentBooking();
const [first, second] = booking.installments;
const key = crypto.randomUUID();
expect((await refundInstallment(db, gateway, { installmentId: second!.id, amountCents: 100, requestKey: key, reason: "" }, admin)).ok).toBe(false);
expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 80_001, requestKey: key, reason: "" }, admin)).ok).toBe(false);
expect((await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin)).ok).toBe(true);
// Doppelt abgeschicktes Formular (gleicher Schluessel) -> keine zweite Erstattung.
await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 30_000, requestKey: key, reason: "Storno" }, admin);
expect(gateway.refunds).toHaveLength(1);
// Rest bleibt begrenzt auf die noch nicht erstatteten 500 €.
const tooMuch = await refundInstallment(db, gateway, { installmentId: first!.id, amountCents: 50_001, requestKey: crypto.randomUUID(), reason: "" }, admin);
expect(tooMuch.ok).toBe(false);
});
});
describe("Gutscheinverwaltung", () => {
test("Gueltigkeit als Kalendertage in Berlin, Codes normalisiert, keine Dubletten", async () => {
const input = voucherInputSchema.parse({
code: " stammmodel-laura ",
active: true,
validFrom: "2026-11-01",
validUntil: "2026-12-31",
maxRedemptions: "1",
boundEmail: "[email protected]",
internalNote: "",
});
expect(input.code).toBe("STAMMMODEL-LAURA");
expect(input.validFrom!.toISOString()).toBe("2026-10-31T23:00:00.000Z");
// "bis einschliesslich 31.12." = exklusiv 01.01.2027 00:00 Berlin
expect(input.validUntil!.toISOString()).toBe("2026-12-31T23:00:00.000Z");
expect(input.boundEmail).toBe("[email protected]");
expect((await saveVoucher(db, null, input, admin)).ok).toBe(true);
expect((await saveVoucher(db, null, input, admin)).ok).toBe(false);
});
test("der Beispielcode aus dem Briefing existiert nicht automatisch", async () => {
expect(await db.voucher.findUnique({ where: { code: "STAMMMODELLAURA1X0" } })).toBeNull();
});
});
+173
View File
@@ -0,0 +1,173 @@
import { beforeEach, describe, expect, test } from "vitest";
import { countOccupiedSeats, createBooking } from "@/lib/booking-service";
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
const testCtx = { now: EARLY_BIRD_NOW, mode: "test" as const, secret: SECRET };
beforeEach(async () => {
await resetDb();
await setupCamp();
});
async function voucher(code: string, data: Partial<{ maxRedemptions: number; active: boolean; validUntil: Date; validFrom: Date; boundEmail: string }> = {}) {
return db.voucher.create({ data: { code, maxRedemptions: 1, ...data } });
}
describe("Kapazitaet", () => {
test("gleichzeitige Buchungen bei knapper Kapazitaet: nie mehr Zusagen als Plaetze", async () => {
await setupCamp({ capacity: 2 });
const results = await Promise.all(Array.from({ length: 8 }, () => createBooking(db, bookingRequest(), testCtx)));
const ok = results.filter((r) => r.ok);
const soldOut = results.filter((r) => !r.ok && r.error.code === "SOLD_OUT");
expect(ok).toHaveLength(2);
expect(soldOut).toHaveLength(6);
expect(await countOccupiedSeats(db, false, EARLY_BIRD_NOW)).toBe(2);
});
test("abgelaufene Reservierung gibt den Platz wieder frei", async () => {
await setupCamp({ capacity: 1 });
const first = await createBooking(db, bookingRequest(), testCtx);
expect(first.ok).toBe(true);
const blocked = await createBooking(db, bookingRequest(), testCtx);
expect(blocked.ok ? "ok" : blocked.error.code).toBe("SOLD_OUT");
// 45 Minuten spaeter: Stripe-Session (31 min) und Kulanzpuffer (10 min) vorbei.
const later = new Date(EARLY_BIRD_NOW.getTime() + 45 * 60_000);
const again = await createBooking(db, bookingRequest(), { ...testCtx, now: later });
expect(again.ok).toBe(true);
});
test("Testbuchungen belegen keine Live-Plaetze", async () => {
await setupCamp({ capacity: 1 });
await createBooking(db, bookingRequest(), testCtx);
expect(await countOccupiedSeats(db, true, EARLY_BIRD_NOW)).toBe(0);
});
test("doppelt abgeschicktes Formular erzeugt genau eine Buchung", async () => {
const request = bookingRequest();
const [a, b] = await Promise.all([createBooking(db, request, testCtx), createBooking(db, request, testCtx)]);
expect(a.ok && b.ok).toBe(true);
expect(await db.booking.count()).toBe(1);
});
});
describe("Preis und Snapshot", () => {
test("Early-Bird-Preis wird serverseitig berechnet und als Snapshot gespeichert", async () => {
const result = await createBooking(db, bookingRequest({ planType: "THREE_INSTALLMENTS" }), testCtx);
if (!result.ok) throw new Error(result.error.code);
expect(result.booking.totalCents).toBe(220_000);
expect(result.booking.installments.map((i) => i.amountCents)).toEqual([80_000, 70_000, 70_000]);
expect(result.booking.priceLockedUntil.getTime() - EARLY_BIRD_NOW.getTime()).toBe(31 * 60_000);
});
test("Preis-Snapshot und Raten sind per Datenbank-Trigger unveraenderlich", async () => {
const result = await createBooking(db, bookingRequest({ planType: "TWO_INSTALLMENTS" }), testCtx);
if (!result.ok) throw new Error(result.error.code);
await expect(db.booking.update({ where: { id: result.booking.id }, data: { totalCents: 1 } })).rejects.toThrow();
await expect(
db.installment.update({ where: { id: result.booking.installments[1]!.id }, data: { amountCents: 1 } }),
).rejects.toThrow();
// Statusfelder bleiben aenderbar.
await db.booking.update({ where: { id: result.booking.id }, data: { reviewReason: "ok" } });
});
test("Regulaerer Preis ab 01.01.2027 00:00 Berlin, Stammmodel 2.475 €", async () => {
await voucher("STAMM-REG");
const now = new Date("2026-12-31T23:00:00Z");
const result = await createBooking(db, bookingRequest({ voucherCode: "stamm-reg" }), { ...testCtx, now });
if (!result.ok) throw new Error(result.error.code);
expect(result.booking.pricePhase).toBe("REGULAR");
expect(result.booking.totalCents).toBe(247_500);
});
test("Ratenzahlung ohne Camp-Termin wird serverseitig abgelehnt", async () => {
await setupCamp({ campStartDate: null });
const result = await createBooking(db, bookingRequest({ planType: "TWO_INSTALLMENTS" }), testCtx);
expect(result.ok ? "ok" : result.error.code).toBe("PLAN_UNAVAILABLE");
});
});
describe("Gutscheine", () => {
test("gueltiger Code: Early Bird + Stammmodel = 1.980 €", async () => {
await voucher("STAMMMODELTEST1");
const result = await createBooking(db, bookingRequest({ voucherCode: " stammmodeltest1 " }), testCtx);
if (!result.ok) throw new Error(result.error.code);
expect(result.booking.totalCents).toBe(198_000);
expect(await db.voucherRedemption.count({ where: { status: "RESERVED" } })).toBe(1);
});
test.each([
["unbekannt", "GIBTESNICHT", {}, "UNKNOWN"],
["inaktiv", "CODE-INAKTIV", { active: false }, "INACTIVE"],
["abgelaufen", "CODE-ALT", { validUntil: new Date("2026-11-01T00:00:00Z") }, "EXPIRED"],
["noch nicht gueltig", "CODE-SPAET", { validFrom: new Date("2026-12-01T00:00:00Z") }, "NOT_YET_VALID"],
["an andere E-Mail gebunden", "CODE-BOUND", { boundEmail: "[email protected]" }, "EMAIL_MISMATCH"],
] as const)("%s wird abgelehnt", async (_label, code, data, reason) => {
if (reason !== "UNKNOWN") await voucher(code, data);
const result = await createBooking(db, bookingRequest({ voucherCode: code }), testCtx);
expect(result.ok).toBe(false);
if (!result.ok && result.error.code === "VOUCHER_REJECTED") expect(result.error.reason).toBe(reason);
expect(await db.booking.count()).toBe(0);
});
test("E-Mail-Bindung akzeptiert die richtige Adresse unabhaengig von Grossschreibung", async () => {
await voucher("CODE-MAIL", { boundEmail: "[email protected]" });
const result = await createBooking(db, bookingRequest({ voucherCode: "CODE-MAIL", email: "[email protected]" }), testCtx);
expect(result.ok).toBe(true);
});
test("ausgeschoepfter Code wird abgelehnt", async () => {
await voucher("CODE-EINMAL");
expect((await createBooking(db, bookingRequest({ voucherCode: "CODE-EINMAL" }), testCtx)).ok).toBe(true);
const second = await createBooking(db, bookingRequest({ voucherCode: "CODE-EINMAL" }), testCtx);
expect(second.ok ? "ok" : second.error.code === "VOUCHER_REJECTED" && second.error.reason).toBe("EXHAUSTED");
});
test("gleichzeitige Einloesung eines begrenzten Codes: genau so viele wie erlaubt", async () => {
await voucher("CODE-ZWEI", { maxRedemptions: 2 });
const results = await Promise.all(
Array.from({ length: 7 }, () => createBooking(db, bookingRequest({ voucherCode: "CODE-ZWEI" }), testCtx)),
);
expect(results.filter((r) => r.ok)).toHaveLength(2);
expect(await db.voucherRedemption.count()).toBe(2);
});
test("Gesamtkontingent fuer Stammmodel-Buchungen greift ueber alle Codes", async () => {
await setupCamp({ stammmodelQuota: 2 });
await voucher("CODE-A", { maxRedemptions: 5 });
await voucher("CODE-B", { maxRedemptions: 5 });
const results = await Promise.all([
createBooking(db, bookingRequest({ voucherCode: "CODE-A" }), testCtx),
createBooking(db, bookingRequest({ voucherCode: "CODE-B" }), testCtx),
createBooking(db, bookingRequest({ voucherCode: "CODE-A" }), testCtx),
createBooking(db, bookingRequest({ voucherCode: "CODE-B" }), testCtx),
]);
expect(results.filter((r) => r.ok)).toHaveLength(2);
const rejected = results.find((r) => !r.ok);
expect(rejected && !rejected.ok && rejected.error.code === "VOUCHER_REJECTED" && rejected.error.reason).toBe("QUOTA_EXHAUSTED");
});
test("abgelaufene Reservierung gibt die Einloesung wieder frei", async () => {
await voucher("CODE-FREI");
await createBooking(db, bookingRequest({ voucherCode: "CODE-FREI" }), testCtx);
const later = new Date(EARLY_BIRD_NOW.getTime() + 45 * 60_000);
const again = await createBooking(db, bookingRequest({ voucherCode: "CODE-FREI" }), { ...testCtx, now: later });
expect(again.ok).toBe(true);
});
});
describe("Verkaufsfreigabe im Buchungsservice", () => {
test("Livemodus ohne Freigaben: keine Buchung, auch bei direktem Aufruf", async () => {
const result = await createBooking(db, bookingRequest(), { ...testCtx, mode: "live" });
expect(result.ok ? "ok" : result.error.code).toBe("SALES_CLOSED");
expect(await db.booking.count()).toBe(0);
});
test("Vorschauphase: im Testmodus ohne Override gesperrt, mit Override buchbar", async () => {
const preview = new Date("2026-10-01T10:00:00Z");
const closed = await createBooking(db, bookingRequest(), { ...testCtx, now: preview });
expect(closed.ok ? "ok" : closed.error.code).toBe("SALES_CLOSED");
await setupCamp({ testPhaseOverride: "EARLY_BIRD" });
const open = await createBooking(db, bookingRequest(), { ...testCtx, now: preview });
expect(open.ok).toBe(true);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { buildSchedule, splitIntoInstallments } from "@/lib/payment-plan";
import { calculatePrice } from "@/lib/pricing";
describe("Exakte Zahlungsplaene laut Vorgabe", () => {
test.each([
["Early Bird", "EARLY_BIRD", false, [[220_000], [110_000, 110_000], [80_000, 70_000, 70_000]]],
["Early Bird Stammmodel", "EARLY_BIRD", true, [[198_000], [99_000, 99_000], [66_000, 66_000, 66_000]]],
["Regulaer", "REGULAR", false, [[275_000], [137_500, 137_500], [95_000, 90_000, 90_000]]],
["Regulaer Stammmodel", "REGULAR", true, [[247_500], [123_750, 123_750], [82_500, 82_500, 82_500]]],
] as const)("%s", (_label, phase, code, expected) => {
const total = calculatePrice(phase, code).totalCents;
for (const [index, plan] of expected.entries()) {
const split = splitIntoInstallments(total, index + 1);
expect(split).toEqual(plan);
expect(split.reduce((a, b) => a + b, 0)).toBe(total);
}
});
test("1.237,50 € je Rate bleibt centgenau", () => {
expect(splitIntoInstallments(247_500, 2)).toEqual([123_750, 123_750]);
});
test("mehr als drei Raten sind ausgeschlossen", () => {
expect(() => splitIntoInstallments(220_000, 4)).toThrow();
});
});
describe("Faelligkeiten", () => {
const base = {
totalCents: 220_000,
campStartDate: "2027-06-05",
finalInstallmentLeadDays: 30,
minInstallmentGapDays: 14,
};
test("letzte Rate 30 Tage vor Anreise, zweite in der Mitte, Einzug 09:00 Berlin", () => {
const result = buildSchedule({ ...base, planType: "THREE_INSTALLMENTS", now: new Date("2026-11-15T10:00:00Z") });
expect(result.ok).toBe(true);
if (!result.ok) return;
// 172 Tage bis zur Frist -> zweite Rate nach 86 Tagen.
expect(result.installments.map((i) => i.dueDate)).toEqual([null, "2027-02-09", "2027-05-06"]);
// 06.05.2027 09:00 Sommerzeit = 07:00 UTC
expect(result.installments[2]!.dueAt!.toISOString()).toBe("2027-05-06T07:00:00.000Z");
expect(result.installments[0]!.amountCents).toBe(80_000);
});
test("ohne Camp-Termin keine Ratenzahlung, Einmalzahlung bleibt moeglich", () => {
const now = new Date("2026-11-15T10:00:00Z");
expect(buildSchedule({ ...base, campStartDate: null, planType: "TWO_INSTALLMENTS", now })).toEqual({
ok: false,
reason: "CAMP_DATE_MISSING",
});
expect(buildSchedule({ ...base, campStartDate: null, planType: "FULL", now }).ok).toBe(true);
});
test("zu knapp vor der Frist wird die Variante serverseitig deaktiviert", () => {
// Frist 06.05.2027; 20 Tage vorher reichen fuer zwei, nicht fuer drei Raten.
const now = new Date("2027-04-16T10:00:00Z");
expect(buildSchedule({ ...base, planType: "TWO_INSTALLMENTS", now }).ok).toBe(true);
expect(buildSchedule({ ...base, planType: "THREE_INSTALLMENTS", now })).toEqual({ ok: false, reason: "TOO_LATE" });
const later = new Date("2027-04-30T10:00:00Z");
expect(buildSchedule({ ...base, planType: "TWO_INSTALLMENTS", now: later })).toEqual({ ok: false, reason: "TOO_LATE" });
});
});
+333
View File
@@ -0,0 +1,333 @@
import Stripe from "stripe";
import { beforeEach, describe, expect, test } from "vitest";
import { startCheckout, startInstallmentCheckout } from "@/lib/checkout";
import { applyRefundState, MAX_OFF_SESSION_ATTEMPTS } from "@/lib/payment-events";
import { verifyStripeWebhook } from "@/lib/payment-gateway";
import { handleStripeEvent, settleCheckoutSession } from "@/lib/webhook";
import { chargeDueInstallments, detachFinishedPaymentMethods, releaseExpiredReservations } from "@/lib/worker-jobs";
import { STRIPE_ACTOR } from "@/lib/audit";
import type { PlanType } from "@/lib/payment-plan";
import { bookingRequest, db, EARLY_BIRD_NOW, resetDb, SECRET, setupCamp } from "./support/db";
import { FakeGateway } from "./support/fake-gateway";
const ctx = { now: EARLY_BIRD_NOW, secret: SECRET, adminEmail: "[email protected]" };
let gateway: FakeGateway;
beforeEach(async () => {
await resetDb();
await setupCamp();
gateway = new FakeGateway("test");
});
async function checkout(planType: PlanType = "FULL", voucherCode?: string) {
const request = bookingRequest({ planType, voucherCode });
const result = await startCheckout(db, gateway, request, ctx);
if (!result.ok) throw new Error(`Checkout fehlgeschlagen: ${result.error.code}`);
const booking = await db.booking.findUniqueOrThrow({ where: { reference: result.reference } });
return { request, result, booking };
}
function sessionEvent(type: string, session: ReturnType<FakeGateway["completeSession"]>, id = `evt_${crypto.randomUUID()}`) {
return {
id,
type,
livemode: false,
data: {
object: {
id: session.id,
object: "checkout.session",
status: session.status,
payment_status: session.paymentStatus,
payment_intent: session.paymentIntentId,
customer: session.customerId,
url: null,
metadata: session.metadata,
},
},
} as unknown as Stripe.Event;
}
async function paidBooking(planType: PlanType = "FULL") {
const { booking } = await checkout(planType);
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
return db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { installments: { orderBy: { seq: "asc" } } } });
}
describe("Checkout-Erstellung", () => {
test("idempotent: derselbe Buchungsversuch liefert dieselbe Stripe-Session", async () => {
const { request, result } = await checkout();
const again = await startCheckout(db, gateway, request, ctx);
expect(again.ok && again.url).toBe(result.url);
expect(gateway.sessions.size).toBe(1);
});
test("Session laeuft mit der Preisbindung ab; Ratenplan speichert die Zahlungsmethode", async () => {
const { booking } = await checkout("THREE_INSTALLMENTS");
const session = gateway.sessions.get(booking.stripeCheckoutSessionId!)!;
expect(session.params.amountCents).toBe(80_000);
expect(session.params.saveForFutureUsage).toBe(true);
expect(session.params.expiresAt.getTime()).toBe(booking.priceLockedUntil.getTime());
expect(session.params.submitMessage).toContain("Heute fällig: 800,00 €");
});
test("Stripe-Ausfall gibt Platz und Gutschein sofort wieder frei", async () => {
await db.voucher.create({ data: { code: "CODE-X" } });
gateway.failNextCheckout = true;
const result = await startCheckout(db, gateway, bookingRequest({ voucherCode: "CODE-X" }), ctx);
expect(result.ok ? "ok" : result.error.code).toBe("PAYMENT_PROVIDER");
expect(await db.seatReservation.count({ where: { status: "RELEASED" } })).toBe(1);
expect(await db.voucherRedemption.count({ where: { status: "RELEASED" } })).toBe(1);
});
});
describe("Webhook", () => {
test("Signatur wird gegen den unveraenderten Body geprueft", () => {
const secret = "whsec_test_secret";
const payload = JSON.stringify({ id: "evt_1", object: "event", type: "checkout.session.completed", data: { object: {} } });
const header = Stripe.webhooks.generateTestHeaderString({ payload, secret });
expect(verifyStripeWebhook(payload, header, secret).id).toBe("evt_1");
expect(() => verifyStripeWebhook(payload.replace("evt_1", "evt_2"), header, secret)).toThrow();
expect(() => verifyStripeWebhook(payload, header, "whsec_falsch")).toThrow();
});
test("Einmalzahlung: bestaetigt und vollstaendig bezahlt, Mail im Postausgang", async () => {
const booking = await paidBooking("FULL");
expect(booking.status).toBe("CONFIRMED");
expect(booking.paymentStatus).toBe("PAID");
expect(booking.paidCents).toBe(220_000);
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
expect(await db.emailMessage.count({ where: { dedupeKey: `booking-confirmed:${booking.id}` } })).toBe(1);
});
test("Ratenzahlung: erste Rate bezahlt heisst Teilzahlung, nicht vollstaendig bezahlt", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
expect(booking.status).toBe("CONFIRMED");
expect(booking.paymentStatus).toBe("PARTIALLY_PAID");
expect(booking.paidCents).toBe(110_000);
expect(booking.stripePaymentMethodId).toMatch(/^pm_/);
expect(booking.installments.map((i) => i.status)).toEqual(["PAID", "PENDING"]);
});
test("doppelt zugestelltes Event wird nur einmal verbucht", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
const event = sessionEvent("checkout.session.completed", session, "evt_dup");
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("processed");
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("duplicate");
// Anderes Event, gleicher Inhalt (z. B. async_payment_succeeded): idempotent.
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_succeeded", session), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paidCents).toBe(220_000);
});
test("ungeordnet: 'expired' nach 'completed' hebt die Buchung nicht auf", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.expired", { ...session, status: "expired" }), ctx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("CONFIRMED");
});
test("Abbruch/Ablauf: unbezahlte Buchung verfaellt, Platz wird frei", async () => {
const { booking } = await checkout();
const session = await gateway.retrieveCheckoutSession(booking.stripeCheckoutSessionId!);
await handleStripeEvent(
db,
gateway,
sessionEvent("checkout.session.expired", { ...session, status: "expired" } as ReturnType<FakeGateway["completeSession"]>),
ctx,
);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("EXPIRED");
expect(after.reservation!.status).toBe("RELEASED");
});
test("Livemodus-Event im Testbetrieb wird ignoriert", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
const event = { ...sessionEvent("checkout.session.completed", session), livemode: true } as Stripe.Event;
expect((await handleStripeEvent(db, gateway, event, ctx)).outcome).toBe("ignored");
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("PENDING");
});
});
describe("Verzoegerte und verspaetete Zahlung", () => {
test("verzoegerte Zahlung haelt den Platz, Fehlschlag gibt ihn frei", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!, { paid: false });
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), ctx);
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.paymentStatus).toBe("PROCESSING");
expect(after.reservation!.status).toBe("HELD");
// Reservierungsfrist laeuft ab, der Platz bleibt trotzdem gehalten.
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 3 * 86_400_000) };
await releaseExpiredReservations(db, gateway, later);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.reservation!.status).toBe("HELD");
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.async_payment_failed", session), later);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("EXPIRED");
expect(after.paymentStatus).toBe("FAILED");
expect(after.reservation!.status).toBe("RELEASED");
});
test("verlorener Webhook: Worker fragt Stripe und verbucht die Zahlung statt freizugeben", async () => {
const { booking } = await checkout();
gateway.completeSession(booking.stripeCheckoutSessionId!);
const later = { ...ctx, now: new Date(EARLY_BIRD_NOW.getTime() + 60 * 60_000) };
const result = await releaseExpiredReservations(db, gateway, later);
expect(result.settled).toBe(1);
expect((await db.booking.findUniqueOrThrow({ where: { id: booking.id } })).status).toBe("CONFIRMED");
});
test("verspaetete Zahlung nach Freigabe: Platz noch frei -> bestaetigt", async () => {
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
// Platz wurde (z. B. durch einen Fehler) bereits freigegeben.
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id }, include: { reservation: true } });
expect(after.status).toBe("CONFIRMED");
expect(after.reservation!.status).toBe("CONVERTED");
});
test("verspaetete Zahlung nach Freigabe ohne freien Platz -> Pruefung statt Ueberbuchung", async () => {
await setupCamp({ capacity: 1 });
const { booking } = await checkout();
const session = gateway.completeSession(booking.stripeCheckoutSessionId!);
await db.seatReservation.update({ where: { bookingId: booking.id }, data: { status: "RELEASED" } });
await db.booking.update({ where: { id: booking.id }, data: { status: "EXPIRED" } });
await paidBooking("FULL"); // jemand anderes bekommt den letzten Platz
await settleCheckoutSession(db, gateway, session, ctx, STRIPE_ACTOR);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("NEEDS_REVIEW");
expect(after.paidCents).toBe(220_000);
expect(await db.seatReservation.count({ where: { status: "CONVERTED" } })).toBe(1);
expect(await db.emailMessage.count({ where: { toAddress: "[email protected]" } })).toBe(1);
});
});
describe("Folgeraten", () => {
const afterLastDue = (booking: { installments: { dueAt: Date | null }[] }) => ({
...ctx,
now: new Date(Math.max(...booking.installments.map((i) => i.dueAt?.getTime() ?? 0)) + 60_000),
});
test("Einzug aller Raten, danach keine weitere Abbuchung und Zahlungsmethode getrennt", async () => {
const booking = await paidBooking("THREE_INSTALLMENTS");
const runCtx = afterLastDue(booking);
const summary = await chargeDueInstallments(db, gateway, runCtx);
expect(summary.charged).toBe(2);
let after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PAID");
expect(after.paidCents).toBe(220_000);
await detachFinishedPaymentMethods(db, gateway);
after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentMethodDetachedAt).not.toBeNull();
expect(gateway.detached).toEqual([booking.stripePaymentMethodId]);
// Weitere Laeufe buchen nichts mehr ab.
await chargeDueInstallments(db, gateway, runCtx);
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 30 * 86_400_000) });
expect(gateway.offSessionCharges).toHaveLength(2);
expect(gateway.offSessionCharges.reduce((s, c) => s + c.amountCents, 0)).toBe(140_000);
});
test("nur faellige Raten werden eingezogen", async () => {
const booking = await paidBooking("THREE_INSTALLMENTS");
const between = { ...ctx, now: new Date(booking.installments[1]!.dueAt!.getTime() + 60_000) };
expect((await chargeDueInstallments(db, gateway, between)).charged).toBe(1);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
expect(after.paidCents).toBe(150_000);
});
test("Fehlschlag: Wiederholung nach Wartezeit, nach dem letzten Versuch manuelle Pruefung", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
gateway.offSessionOutcomes = ["failed", "failed", "failed"];
let runCtx = afterLastDue(booking);
for (let attempt = 1; attempt <= MAX_OFF_SESSION_ATTEMPTS; attempt++) {
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
expect(inst.status).toBe("FAILED");
expect(inst.attemptCount).toBe(attempt);
// Vor Ablauf der Wartezeit kein erneuter Versuch.
await chargeDueInstallments(db, gateway, runCtx);
expect((await db.installment.findFirstOrThrow({ where: { id: inst.id } })).attemptCount).toBe(attempt);
runCtx = { ...runCtx, now: new Date(runCtx.now.getTime() + 4 * 86_400_000) };
}
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.status).toBe("NEEDS_REVIEW");
expect(after.paymentStatus).toBe("PARTIALLY_PAID");
expect(gateway.offSessionCharges).toHaveLength(0);
});
test("Bank verlangt Authentifizierung: Kundin bekommt Link und zahlt selbst", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
gateway.offSessionOutcomes = ["requires_action"];
const runCtx = afterLastDue(booking);
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
expect(inst.status).toBe("REQUIRES_ACTION");
expect(await db.emailMessage.count({ where: { dedupeKey: { startsWith: `installment-action:${inst.id}` } } })).toBe(1);
const pay = await startInstallmentCheckout(db, gateway, { bookingId: booking.id, installmentId: inst.id }, runCtx);
expect(pay.ok).toBe(true);
// Waehrend die Session offen ist, zieht der Worker nicht parallel ein.
await chargeDueInstallments(db, gateway, { ...runCtx, now: new Date(runCtx.now.getTime() + 60_000) });
expect(gateway.offSessionCharges).toHaveLength(0);
const sessionId = (await db.installment.findUniqueOrThrow({ where: { id: inst.id } })).checkoutSessionId!;
const session = gateway.completeSession(sessionId);
await handleStripeEvent(db, gateway, sessionEvent("checkout.session.completed", session), runCtx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paymentStatus).toBe("PAID");
expect(after.paidCents).toBe(220_000);
});
test("Webhook und Worker melden denselben Erfolg: nur einmal verbucht", async () => {
const booking = await paidBooking("TWO_INSTALLMENTS");
const runCtx = afterLastDue(booking);
await chargeDueInstallments(db, gateway, runCtx);
const inst = await db.installment.findFirstOrThrow({ where: { bookingId: booking.id, seq: 2 } });
const pi = await gateway.retrievePaymentIntent(inst.stripePaymentIntentId!);
const event = {
id: "evt_pi_ok",
type: "payment_intent.succeeded",
livemode: false,
data: {
object: {
id: pi.id,
object: "payment_intent",
amount_received: pi.amountReceived,
latest_charge: pi.chargeId,
payment_method: pi.paymentMethodId,
customer: null,
metadata: pi.metadata,
},
},
} as unknown as Stripe.Event;
await handleStripeEvent(db, gateway, event, runCtx);
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.paidCents).toBe(220_000);
expect(after.status).toBe("CONFIRMED");
});
test("Erstattung aktualisiert Zahlungsstatus absolut (ungeordnete Events harmlos)", async () => {
const booking = await paidBooking("FULL");
const pi = booking.installments[0]!.stripePaymentIntentId!;
const ectx = { ...ctx, actor: STRIPE_ACTOR };
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 50_000 }, ectx));
await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: pi, amountRefundedCents: 220_000 }, ectx));
const after = await db.booking.findUniqueOrThrow({ where: { id: booking.id } });
expect(after.refundedCents).toBe(220_000);
expect(after.paymentStatus).toBe("REFUNDED");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, test } from "vitest";
import { calculatePrice, EARLY_BIRD_START, pricePhaseAt, REGULAR_START } from "@/lib/pricing";
import { formatEuro } from "@/lib/money";
describe("Gesamtpreise laut Preistabelle", () => {
test.each([
["EARLY_BIRD", false, 220_000],
["EARLY_BIRD", true, 198_000],
["REGULAR", false, 275_000],
["REGULAR", true, 247_500],
] as const)("%s mit Stammmodel-Code=%s kostet %i Cent", (phase, code, expected) => {
expect(calculatePrice(phase, code).totalCents).toBe(expected);
});
test("kombinierter Nachlass im Early Bird betraegt 28 %, nicht 30 %", () => {
const price = calculatePrice("EARLY_BIRD", true);
const discount = price.basePriceCents - price.totalCents;
expect(discount / price.basePriceCents).toBe(0.28);
expect(price.phaseDiscountCents).toBe(55_000);
expect(price.voucherDiscountCents).toBe(22_000);
});
test("Formatierung in Euro", () => {
expect(formatEuro(220_000)).toBe("2.200,00 €");
expect(formatEuro(123_750)).toBe("1.237,50 €");
});
});
describe("Zeitgrenzen in Europe/Berlin", () => {
test("Early Bird beginnt am 01.11.2026 um 00:00 Berliner Zeit (= 31.10. 23:00 UTC, Winterzeit)", () => {
expect(EARLY_BIRD_START.toISOString()).toBe("2026-10-31T23:00:00.000Z");
expect(pricePhaseAt(new Date("2026-10-31T22:59:59.999Z"))).toBe("PREVIEW");
expect(pricePhaseAt(new Date("2026-10-31T23:00:00.000Z"))).toBe("EARLY_BIRD");
});
test("Sommerzeit-Ende am 25.10.2026 verschiebt die Grenze nicht", () => {
// 31.10.2026 20:00 Berlin (Winterzeit) ist noch Vorschau.
expect(pricePhaseAt(new Date("2026-10-31T19:00:00Z"))).toBe("PREVIEW");
});
test("Early Bird gilt bis einschliesslich 31.12.2026, regulaer ab 01.01.2027 00:00 Berlin", () => {
expect(REGULAR_START.toISOString()).toBe("2026-12-31T23:00:00.000Z");
expect(pricePhaseAt(new Date("2026-12-31T22:59:59.999Z"))).toBe("EARLY_BIRD");
expect(pricePhaseAt(new Date("2026-12-31T23:00:00.000Z"))).toBe("REGULAR");
expect(pricePhaseAt(new Date("2027-06-01T12:00:00Z"))).toBe("REGULAR");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, test } from "vitest";
import { evaluateSales, type CampGateFields } from "@/lib/sales-gate";
const unreleased: CampGateFields = {
salesEnabled: false,
capacityConfirmed: false,
stammmodelQuotaConfirmed: false,
campStartDate: null,
dateConfirmed: false,
locationName: null,
locationConfirmed: false,
accommodationText: null,
accommodationConfirmed: false,
servicesConfirmed: false,
organizerName: null,
organizerAddress: null,
organizerEmail: null,
organizerConfirmed: false,
taxNote: null,
taxConfirmed: false,
termsVersion: null,
termsApproved: false,
travelLawChecked: false,
installmentsApproved: false,
installmentRuleApproved: false,
testPhaseOverride: null,
};
const released: CampGateFields = {
...unreleased,
salesEnabled: true,
capacityConfirmed: true,
stammmodelQuotaConfirmed: true,
campStartDate: "2027-06-05",
dateConfirmed: true,
locationName: "Villa",
locationConfirmed: true,
accommodationText: "Beschreibung",
accommodationConfirmed: true,
servicesConfirmed: true,
organizerName: "Veranstalter",
organizerAddress: "Adresse",
organizerEmail: "[email protected]",
organizerConfirmed: true,
taxNote: "Hinweis",
taxConfirmed: true,
termsVersion: "1",
termsApproved: true,
travelLawChecked: true,
};
const earlyBird = new Date("2026-11-10T12:00:00Z");
describe("Verkaufsfreigabe", () => {
test("Livemodus ohne Freigaben: Checkout gesperrt, alle Blocker benannt", () => {
const state = evaluateSales(earlyBird, unreleased, "live");
expect(state.checkoutOpen).toBe(false);
expect(state.closedReason).toBe("NOT_RELEASED");
expect(state.releaseBlockers).toHaveLength(11);
});
test("Livemodus: eine einzige fehlende Freigabe sperrt den Verkauf", () => {
const state = evaluateSales(earlyBird, { ...released, travelLawChecked: false }, "live");
expect(state.checkoutOpen).toBe(false);
expect(state.releaseBlockers).toEqual(["TRAVEL_LAW"]);
});
test("Livemodus mit allen Freigaben in der Early-Bird-Phase: offen", () => {
expect(evaluateSales(earlyBird, released, "live").checkoutOpen).toBe(true);
});
test("Livemodus vor dem 01.11.2026: Vorschau ohne Kaufmoeglichkeit, Override wirkungslos", () => {
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...released, testPhaseOverride: "EARLY_BIRD" }, "live");
expect(state.checkoutOpen).toBe(false);
expect(state.closedReason).toBe("PREVIEW");
});
test("Testmodus ohne Freigaben: Checkout offen, Preisphase simulierbar", () => {
const state = evaluateSales(new Date("2026-10-01T12:00:00Z"), { ...unreleased, testPhaseOverride: "REGULAR" }, "test");
expect(state.checkoutOpen).toBe(true);
expect(state.effectivePhase).toBe("REGULAR");
expect(state.phaseSimulated).toBe(true);
});
test("Testmodus ohne Override vor Verkaufsstart bleibt Vorschau", () => {
expect(evaluateSales(new Date("2026-10-01T12:00:00Z"), unreleased, "test").checkoutOpen).toBe(false);
});
test("Ratenzahlung live erst nach ausdruecklicher Freigabe", () => {
expect(evaluateSales(earlyBird, released, "live").installmentsAllowed).toBe(false);
expect(
evaluateSales(earlyBird, { ...released, installmentsApproved: true, installmentRuleApproved: true }, "live")
.installmentsAllowed,
).toBe(true);
});
test("ab Anreisetag ist der Verkauf geschlossen", () => {
const state = evaluateSales(new Date("2027-06-05T08:00:00Z"), released, "live");
expect(state.closedReason).toBe("CAMP_STARTED");
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { Prisma } from "@/generated/prisma/client";
import { CAMP_ID, getDb } from "@/lib/db";
import type { BookingRequest } from "@/lib/booking-service";
import type { PlanType } from "@/lib/payment-plan";
export const db = getDb();
export const SECRET = "test-secret-test-secret-test-secret-123";
/** Leert alle Anwendungstabellen der Testdatenbank. */
export async function resetDb(): Promise<void> {
await db.$executeRawUnsafe(`TRUNCATE TABLE
"Refund", "EmailMessage", "AuditLog", "StripeEvent", "RateLimitBucket",
"VoucherRedemption", "SeatReservation", "Installment", "Booking", "Voucher", "Camp",
"session", "account", "verification", "twoFactor", "user", "rateLimit"
RESTART IDENTITY CASCADE`);
}
export async function setupCamp(data: Prisma.CampUpdateInput = {}): Promise<void> {
await db.camp.upsert({
where: { id: CAMP_ID },
create: { id: CAMP_ID },
update: {},
});
await db.camp.update({
where: { id: CAMP_ID },
data: {
campStartDate: new Date("2027-06-05T00:00:00Z"),
testPhaseOverride: null,
...data,
},
});
}
let counter = 0;
export function bookingRequest(overrides: { planType?: PlanType; voucherCode?: string; email?: string } = {}): BookingRequest {
counter++;
const planType = overrides.planType ?? "FULL";
return {
idempotencyKey: crypto.randomUUID(),
participant: {
firstName: "Test",
lastName: `Person ${counter}`,
email: overrides.email ?? `test${counter}@example.org`,
phone: "+49 30 1234567",
country: "Deutschland",
notes: undefined,
},
planType,
voucherCode: overrides.voucherCode,
consents: { adult: true, terms: true, accommodation: true, privacy: true, installmentMandate: planType !== "FULL" },
};
}
/** Mitten in der Early-Bird-Phase. */
export const EARLY_BIRD_NOW = new Date("2026-11-15T10:00:00Z");
+1
View File
@@ -0,0 +1 @@
export {};
+143
View File
@@ -0,0 +1,143 @@
import type {
CheckoutSessionInfo,
CreateCheckoutParams,
OffSessionResult,
PaymentGateway,
PaymentIntentInfo,
} from "@/lib/payment-gateway";
import type { StripeMode } from "@/lib/sales-gate";
type StoredSession = CheckoutSessionInfo & { params: CreateCheckoutParams };
/**
* Stripe-Attrappe im Speicher. Bildet die fuer die Geschaeftslogik
* relevanten Eigenschaften nach: Idempotenzschluessel, Session-Zustaende,
* PaymentIntents und das Ergebnis von Off-Session-Einzuegen.
*/
export class FakeGateway implements PaymentGateway {
private seq = 0;
private readonly byKey = new Map<string, unknown>();
readonly sessions = new Map<string, StoredSession>();
readonly paymentIntents = new Map<string, PaymentIntentInfo>();
readonly offSessionCharges: { key: string; amountCents: number; installmentId: string }[] = [];
readonly detached: string[] = [];
readonly refunds: { id: string; paymentIntentId: string; amountCents: number }[] = [];
/** Ergebnisse fuer die naechsten Off-Session-Einzuege (Standard: Erfolg). */
offSessionOutcomes: OffSessionResult["outcome"][] = [];
failNextCheckout = false;
constructor(readonly mode: StripeMode = "test") {}
private id(prefix: string): string {
this.seq++;
return `${prefix}_test_${this.seq}`;
}
private once<T>(key: string, create: () => T): T {
if (!this.byKey.has(key)) this.byKey.set(key, create());
return this.byKey.get(key) as T;
}
async createCustomer(_params: { email: string; name: string }, key: string) {
return this.once(`cus:${key}`, () => ({ id: this.id("cus") }));
}
async createCheckoutSession(params: CreateCheckoutParams, key: string) {
if (this.failNextCheckout) {
this.failNextCheckout = false;
throw new Error("Stripe nicht erreichbar (simuliert)");
}
return this.once(`cs:${key}`, () => {
const id = this.id("cs");
const url = `https://checkout.stripe.test/${id}`;
this.sessions.set(id, {
id,
status: "open",
paymentStatus: "unpaid",
paymentIntentId: null,
customerId: params.customerId,
url,
metadata: params.metadata,
params,
});
return { id, url };
});
}
async retrieveCheckoutSession(id: string): Promise<CheckoutSessionInfo> {
const s = this.sessions.get(id);
if (!s) throw new Error(`Unbekannte Session ${id}`);
return { ...s };
}
async expireCheckoutSession(id: string): Promise<void> {
const s = this.sessions.get(id);
if (s && s.status === "open") s.status = "expired";
}
/** Simuliert die Zahlung durch die Kundin in Stripe Checkout. */
completeSession(id: string, opts: { paid?: boolean; paymentMethodId?: string; amountCents?: number } = {}): CheckoutSessionInfo {
const s = this.sessions.get(id);
if (!s) throw new Error(`Unbekannte Session ${id}`);
const piId = this.id("pi");
const paid = opts.paid ?? true;
this.paymentIntents.set(piId, {
id: piId,
status: paid ? "succeeded" : "processing",
amountReceived: paid ? (opts.amountCents ?? s.params.amountCents) : 0,
paymentMethodId: opts.paymentMethodId ?? this.id("pm"),
chargeId: paid ? this.id("ch") : null,
metadata: s.metadata,
});
s.status = "complete";
s.paymentStatus = paid ? "paid" : "unpaid";
s.paymentIntentId = piId;
return { ...s };
}
async retrievePaymentIntent(id: string): Promise<PaymentIntentInfo> {
const pi = this.paymentIntents.get(id);
if (!pi) throw new Error(`Unbekannter PaymentIntent ${id}`);
return { ...pi };
}
async chargeOffSession(
params: { customerId: string; paymentMethodId: string; amountCents: number; metadata: Record<string, string> },
key: string,
): Promise<OffSessionResult> {
return this.once(`pi:${key}`, () => {
const outcome = this.offSessionOutcomes.shift() ?? "succeeded";
const piId = this.id("pi");
const chargeId = outcome === "succeeded" ? this.id("ch") : null;
this.paymentIntents.set(piId, {
id: piId,
status: outcome === "succeeded" ? "succeeded" : outcome === "processing" ? "processing" : outcome === "requires_action" ? "requires_action" : "requires_payment_method",
amountReceived: outcome === "succeeded" ? params.amountCents : 0,
paymentMethodId: params.paymentMethodId,
chargeId,
metadata: params.metadata,
});
if (outcome === "succeeded") {
this.offSessionCharges.push({ key, amountCents: params.amountCents, installmentId: params.metadata.installmentId! });
}
return {
outcome,
paymentIntentId: piId,
chargeId,
failureCode: outcome === "failed" ? "card_declined" : outcome === "requires_action" ? "authentication_required" : null,
};
});
}
async detachPaymentMethod(id: string): Promise<void> {
this.detached.push(id);
}
async createRefund(params: { paymentIntentId: string; amountCents: number }, key: string) {
return this.once(`re:${key}`, () => {
const refund = { id: this.id("re"), paymentIntentId: params.paymentIntentId, amountCents: params.amountCents };
this.refunds.push(refund);
return { id: refund.id, status: "succeeded", amount: params.amountCents };
});
}
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Startet fuer die Testlaeufe eine echte, frische PostgreSQL-Instanz.
*
* Bewusst kein Mock und kein SQLite: die Tests fuer gleichzeitige Buchungen
* und Gutscheineinloesungen pruefen Zeilensperren (SELECT … FOR UPDATE) und
* Datenbank-Trigger, die nur eine echte PostgreSQL-Instanz abbildet.
*
* Alternativ laesst sich eine vorhandene Datenbank per TEST_DATABASE_URL
* nutzen (z. B. in CI mit einem postgres-Service). Deren Inhalt wird dabei
* zurueckgesetzt.
*/
import EmbeddedPostgres from "embedded-postgres";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
let pg: EmbeddedPostgres | null = null;
let dir: string | null = null;
export async function setup(): Promise<void> {
let url = process.env.TEST_DATABASE_URL;
if (!url) {
dir = mkdtempSync(path.join(os.tmpdir(), "artista-test-db-"));
const port = 55000 + Math.floor(Math.random() * 1000);
pg = new EmbeddedPostgres({
databaseDir: dir,
user: "test",
password: "test",
port,
persistent: false,
onLog: () => {},
});
await pg.initialise();
await pg.start();
await pg.createDatabase("artista_test");
url = `postgresql://test:test@localhost:${port}/artista_test`;
}
process.env.DATABASE_URL = url;
const prismaCli = path.resolve("node_modules/prisma/build/index.js");
try {
// Nicht-destruktiv: wendet nur fehlende Migrationen an. Die Tests leeren
// ihre Tabellen selbst (tests/support/db.ts).
execFileSync(process.execPath, [prismaCli, "migrate", "deploy"], {
env: { ...process.env, DATABASE_URL: url },
stdio: "pipe",
});
} catch (error) {
const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? "";
throw new Error(`Migration der Testdatenbank fehlgeschlagen:\n${stderr}`);
}
}
export async function teardown(): Promise<void> {
if (pg) await pg.stop();
if (dir) rmSync(dir, { recursive: true, force: true });
}