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]>
This commit is contained in:
1 parent
dcb578f062
commit
f227aeb8f9
269 files changed
+28054
-141
No files matched your search
@@ -0,0 +1,189 @@
|
||||
// @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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user