This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user