// @vitest-environment node import { describe, expect, it, vi } from "vitest"; // Die reinen Funktionen ziehen ueber ihre Module lib/prisma.ts nach — fuer // diese Tests reicht ein leerer Ersatz, eine Datenbank wird nie gefragt. vi.mock("@/lib/prisma", () => ({ prisma: {} })); import { formatSponsorshipNumber } from "@/lib/sponsorship/number"; import { certificateRelativePath } from "@/lib/sponsorship/storage"; import { retryDelayMs, MAX_ISSUE_ATTEMPTS } from "@/lib/sponsorship/issue"; import { isOfferAvailable, type SponsorshipOfferView } from "@/lib/sponsorship/offers"; import { expandCheckoutItems, startDateWindow, type CheckoutProduct } from "@/lib/sponsorship/checkout-expand"; describe("Patenschaftsnummer", () => { it("ist fuenfstellig fortlaufend je Jahr", () => { expect(formatSponsorshipNumber(2026, 42)).toBe("WH-P-2026-00042"); expect(formatSponsorshipNumber(2027, 12345)).toBe("WH-P-2027-12345"); }); }); describe("Ablagepfad der Urkunde", () => { it("enthaelt nur Jahr, Nummer und Revision — keine Namen", () => { expect(certificateRelativePath({ number: "WH-P-2026-00042", revision: 3, year: 2026 })).toBe( "2026/WH-P-2026-00042/r3.pdf" ); }); it("weist manipulierte Nummern ab", () => { expect(() => certificateRelativePath({ number: "../../etc", revision: 1, year: 2026 })).toThrow(); }); }); describe("Wiederholung der Ausstellung", () => { it("verdoppelt den Abstand und deckelt bei 12 Stunden", () => { expect(retryDelayMs(1)).toBe(5 * 60 * 1000); expect(retryDelayMs(2)).toBe(10 * 60 * 1000); expect(retryDelayMs(MAX_ISSUE_ATTEMPTS + 10)).toBe(12 * 60 * 60 * 1000); }); }); describe("Bestellbarkeit eines Angebots", () => { const base = { isActive: true, kind: "EINMALIG" as const, hasCarrierProduct: true, honeyJars: 3, honeyProductActive: true, freeJars: 3, maxActiveSubscriptions: null, activeSubscriptions: 0, }; it("braucht genug FREIEN Honig fuer ein Paket", () => { expect(isOfferAvailable(base)).toBe(true); expect(isOfferAvailable({ ...base, freeJars: 2 })).toBe(false); }); it("ist ohne Honigprodukt oder ohne Traegerprodukt nicht bestellbar", () => { expect(isOfferAvailable({ ...base, honeyProductActive: null })).toBe(false); expect(isOfferAvailable({ ...base, hasCarrierProduct: false })).toBe(false); }); it("stoppt Abos, wenn das Kontingent voll ist", () => { const abo = { ...base, kind: "ABO" as const, hasCarrierProduct: false, maxActiveSubscriptions: 5 }; expect(isOfferAvailable({ ...abo, activeSubscriptions: 4 })).toBe(true); expect(isOfferAvailable({ ...abo, activeSubscriptions: 5 })).toBe(false); }); }); describe("Aufloesen von Patenschaftspaketen im Checkout", () => { const now = new Date("2026-11-02T10:00:00Z"); const honey: CheckoutProduct = { id: "honey", name: "Waldhonig 500 g", batchId: "batch-1", priceCents: 990, weightGrams: 500, category: "HONIG", isActive: true, }; const carrier: CheckoutProduct = { id: "carrier", name: "Bienenpatenschaft Wabenfreund", batchId: null, priceCents: 3800, weightGrams: 0, category: "PATENSCHAFT", isActive: true, }; const offer: SponsorshipOfferView = { id: "offer", slug: "wabenfreund", kind: "EINMALIG", name: "Wabenfreund", description: "", features: [], priceCents: 5900, isPhysical: true, honeyJars: 3, honeyShareCentsPerJar: 700, honeyShareCentsPerMonth: 0, cycle: null, medalTier: "GOLD", productId: "carrier", honeyProduct: { id: "honey", name: "Waldhonig 500 g", weightGrams: 500 }, isAvailable: true, }; const personalization = { beeName: "Summsi", recipientName: "Marie-Luise Schmidt", giverNames: "Anna und Ben", dedication: "Für die schönste Zeit.", occasion: "GEBURTSTAG", startDate: "2026-11-02", }; const input = (cartItems: { productId: string; quantity: number; personalization?: unknown }[]) => ({ cartItems, products: new Map([ ["honey", honey], ["carrier", carrier], ]), offers: new Map([["carrier", offer]]), vatRateFor: (product: CheckoutProduct) => (product.category === "HONIG" ? 7 : 19), locale: "de" as const, now, }); it("teilt den Paketpreis in Patenschaft (19 %) und Honig (7 %) auf", () => { const result = expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization }])); expect(result.ok).toBe(true); if (!result.ok) return; const [main, honeyLine] = result.items; expect(main).toMatchObject({ productId: "carrier", totalCents: 3800, vatRatePercent: 19, sponsorshipRole: "MAIN" }); expect(honeyLine).toMatchObject({ productId: "honey", quantity: 3, unitPriceCents: 700, totalCents: 2100, vatRatePercent: 7, sponsorshipRole: "HONEY" }); expect(main.totalCents + honeyLine.totalCents).toBe(5900); // Nur der Honig wird reserviert, die Urkunde hat keinen Bestand. expect(result.reserve).toEqual([{ productId: "honey", quantity: 3, productName: "Waldhonig 500 g" }]); expect(result.physicalQuantity).toBe(3); }); it("haelt zwei gleiche Pakete auseinander und fasst ihre Reservierung zusammen", () => { const result = expandCheckoutItems( input([ { productId: "carrier", quantity: 1, personalization }, { productId: "carrier", quantity: 1, personalization: { ...personalization, beeName: "Brummi" } }, ]) ); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.items.filter((item) => item.sponsorshipRole === "MAIN")).toHaveLength(2); expect(result.reserve).toEqual([{ productId: "honey", quantity: 6, productName: "Waldhonig 500 g" }]); }); it("weist Menge > 1, fehlende oder manipulierte Personalisierung ab", () => { expect(expandCheckoutItems(input([{ productId: "carrier", quantity: 2, personalization }])).ok).toBe(false); expect(expandCheckoutItems(input([{ productId: "carrier", quantity: 1 }])).ok).toBe(false); expect( expandCheckoutItems( input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, dedication: "x".repeat(2000) } }]) ).ok ).toBe(false); }); it("verlangt ein Startdatum zwischen heute und in sechs Monaten", () => { const window = startDateWindow(now); expect(window).toEqual({ min: "2026-11-02", max: "2027-05-02" }); expect( expandCheckoutItems(input([{ productId: "carrier", quantity: 1, personalization: { ...personalization, startDate: "2026-11-01" } }])).ok ).toBe(false); }); it("verkauft kein Paket, dessen Angebot nicht bestellbar ist", () => { const result = expandCheckoutItems({ ...input([{ productId: "carrier", quantity: 1, personalization }]), offers: new Map([["carrier", { ...offer, isAvailable: false }]]), }); expect(result.ok).toBe(false); }); it("erkennt eine rein digitale Bestellung", () => { const digitalOffer = { ...offer, isPhysical: false, honeyJars: 0, honeyShareCentsPerJar: 0, priceCents: 1900, honeyProduct: null }; const result = expandCheckoutItems({ ...input([{ productId: "carrier", quantity: 1, personalization }]), offers: new Map([["carrier", digitalOffer]]), }); expect(result.ok && result.hasPhysical).toBe(false); expect(result.ok && result.items).toHaveLength(1); }); });