Files
Wabenhain/src/lib/sponsorship/core.test.ts
T
Nick SzaunigandClaude Opus 5.5 f227aeb8f9 feat: Bienenpatenschaft mit Urkunde, Abo, Medaillen und 3D-Konto
Setzt Teilprojekte 1-5 der Spec 2026-10-16 um:
- Urkunden-Engine (lib/certificate): ein Layout fuer PDF, Vorschau und
  3D-Textur, Vorlage classic@1, eingebettete OFL-Schriften, Testmatrix
- Einmal-Patenschaft ueber Traegerprodukte (Kategorie PATENSCHAFT),
  Paket wird im Checkout in Patenschaft (Regelsatz) und Honig aufgeteilt,
  Kontopflicht, rein digitale Bestellungen (FulfillmentMethod DIGITAL)
- Ausstellung nach Zahlung idempotent, Wiederholung im App-Prozess
  (instrumentation + background-jobs), Ablage in CERTIFICATE_DIR
- Verkaufsseite /patenschaft mit Konfigurator und Live-Vorschau,
  Warenkorb mit Schluessel je Position
- Kundenkonto: 3D-Ansicht von Urkunde und Medaille, Downloads, ZIP,
  Uebertragung per befristetem Einmal-Link, Treuestufen bis VIP
- Admin: Angebote, Patenschaften, Druck, Korrektur mit Revisionen,
  Abos, Erntefenster, Deckung, SEPA-Frist, Treuestufen
- Abo ueber Stripe Billing: Lieferung erst nach bezahlten Monaten,
  SEPA-Neukunden-Frist und Rueckbuchungsschutz, Honigreservierung,
  Kuendigungsbutton, Monatsrechnungen, Stripe-Einrichtungsskript

Startangebote werden inaktiv angelegt; Livebetrieb erst nach Freigabe.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 14:07:30 +02:00

190 lines
7.2 KiB
TypeScript

// @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);
});
});