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