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