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: "jemand@example.org" }, "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: "laura@example.org" }); const result = await createBooking(db, bookingRequest({ voucherCode: "CODE-MAIL", email: "Laura@Example.org" }), 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); }); });