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]>
190 lines
7.2 KiB
TypeScript
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);
|
|
});
|
|
});
|