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
@@ -39,6 +39,7 @@ const tiers: ShippingTierLike[] = [{ minQuantity: 0, maxQuantity: null, priceCen
|
||||
const bulkDiscountTiers: BulkDiscountTierLike[] = [];
|
||||
|
||||
const item = (overrides: Partial<CartItem> = {}): CartItem => ({
|
||||
key: "p1",
|
||||
productId: "p1",
|
||||
slug: "fruehtracht-2026",
|
||||
name: "Frühtracht 2026",
|
||||
@@ -62,6 +63,24 @@ function renderCheckout(items: CartItem[], stripePublishableKey: string | undefi
|
||||
);
|
||||
}
|
||||
|
||||
const sponsorshipItem = (key: string, beeName: string, honeyJars = 0): CartItem => ({
|
||||
key,
|
||||
productId: "pkg-bienenfreund",
|
||||
slug: "bienenfreund",
|
||||
name: "Bienenfreund",
|
||||
weightGrams: 0,
|
||||
priceCents: 1900,
|
||||
image: null,
|
||||
quantity: 1,
|
||||
sponsorship: {
|
||||
offerSlug: "bienenfreund",
|
||||
offerName: "Bienenfreund",
|
||||
isPhysical: honeyJars > 0,
|
||||
honeyJars,
|
||||
personalization: { beeName, recipientName: "Erika Muster", occasion: "GEBURTSTAG", startDate: "2026-11-01" },
|
||||
},
|
||||
});
|
||||
|
||||
async function fillAndSubmit(userEventInstance: ReturnType<typeof userEvent.setup>) {
|
||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
||||
@@ -179,4 +198,58 @@ describe("CheckoutForm", () => {
|
||||
expect(await screen.findByText(/zahlung ist gerade nicht verfügbar/i)).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a cart with only digital sponsorships needs no delivery and is sent as DIGITAL with personalization", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ clientSecret: "cs_test_1_secret_abc", cancelToken: "d".repeat(64) }),
|
||||
} as Response);
|
||||
|
||||
renderCheckout([sponsorshipItem("sp_1", "Frieda"), sponsorshipItem("sp_2", "Frieda")]);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /abholung/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/straße|strasse/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Biene „Frieda“")).toHaveLength(2);
|
||||
|
||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
||||
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
|
||||
|
||||
await screen.findByTestId("embedded-payment");
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body)) as {
|
||||
fulfillmentMethod: string;
|
||||
street?: string;
|
||||
items: { productId: string; quantity: number; personalization?: { beeName: string } }[];
|
||||
};
|
||||
expect(body.fulfillmentMethod).toBe("DIGITAL");
|
||||
expect(body.street).toBeUndefined();
|
||||
// Zwei gleiche Pakete bleiben zwei Positionen, jede mit eigener Personalisierung.
|
||||
expect(body.items).toHaveLength(2);
|
||||
expect(body.items.every((line) => line.quantity === 1 && line.personalization?.beeName === "Frieda")).toBe(true);
|
||||
});
|
||||
|
||||
test("a package with honey keeps the delivery choice and counts its jars for shipping", () => {
|
||||
renderCheckout([sponsorshipItem("sp_1", "Lotte", 1)]);
|
||||
|
||||
expect(screen.getByRole("button", { name: /abholung/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("7,00 €")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows the account message with a login link when the server answers 401", async () => {
|
||||
const userEventInstance = userEvent.setup();
|
||||
const message = "Für eine Bienenpatenschaft brauchst du ein Kundenkonto. Bitte melde dich an.";
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: message }) } as Response);
|
||||
|
||||
renderCheckout([sponsorshipItem("sp_1", "Frieda")]);
|
||||
await userEventInstance.type(screen.getByLabelText(/vorname/i), "Erika");
|
||||
await userEventInstance.type(screen.getByLabelText(/nachname/i), "Mustermann");
|
||||
await userEventInstance.type(screen.getByLabelText(/e-mail/i), "[email protected]");
|
||||
await userEventInstance.click(screen.getByRole("button", { name: /kostenpflichtig bestellen/i }));
|
||||
|
||||
expect(await screen.findByText(message, { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Zur Anmeldung" })).toHaveAttribute("href", "/konto?next=%2Fcheckout");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user