Files
Wabenhain/src/app/api/patenschaft/abo/route.test.ts
T
Nick SzaunigandClaude Opus 5.5 515fca1c16
Docker Image bauen und veroeffentlichen / typecheck (push) Successful in 2m57s
Docker Image bauen und veroeffentlichen / lint (push) Successful in 3m7s
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Docker Image bauen und veroeffentlichen / test (push) Canceled after 1m22s
fix: Befunde aus Sicherheits- und Korrektheitsreview der Bienenpatenschaft
- Oeffentliche Kuendigung: exakte Identifikation, Limits je Vertrag und
  Adresse, Bestaetigung nur an die Kontoadresse mit Ruecknahme-Link (HMAC)
- Vorschau: Body-Grenze beim Lesen, Parallelitaets- und Minutenlimit, Cache
- Rabatte gelten nicht fuer Patenschaftspositionen
- Abo aktiv erst nach bestaetigter Zahlung, offene Abschluesse zaehlen
  zum Kontingent und werden nach 24 h aufgeraeumt, Sitzung 58 Minuten
- Konto-Loeschung nur ohne laufendes Abo, Urkunden-Dateien werden entfernt
- Abo-Lieferungen mit Zahlungszeile (DHL-Marke moeglich)
- Shop zeigt freien Bestand nach Abo-Reservierung
- Storno/volle Erstattung widerruft die Patenschaft (Status WIDERRUFEN)
- Umsatzsteuer der Patenschaft per VAT_RATE_PERCENT_PATENSCHAFT (19 %)
- Digitale Bestellungen archivieren sich, Kalendertage in Europe/Berlin,
  Monatsenden begrenzt, Datenauskunft um Patenschaften ergaenzt

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

111 lines
5.7 KiB
TypeScript

/** Abo-Abschluss: nur mit verifiziertem Konto, nur fuer verfuegbare Abo-Angebote, mit gepruefter Personalisierung. */
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
getCurrentCustomer: vi.fn(),
getOfferBySlug: vi.fn(),
createSubscriptionCheckout: vi.fn(),
preparePendingCheckout: vi.fn(),
isTrustedOrigin: vi.fn(),
}));
vi.mock("@/lib/auth", () => ({ getCurrentCustomer: m.getCurrentCustomer }));
vi.mock("@/lib/csrf", () => ({ isTrustedOrigin: m.isTrustedOrigin }));
vi.mock("@/lib/rate-limit", () => ({ rateLimit: () => true, getClientIp: async () => "127.0.0.1" }));
vi.mock("@/i18n/locale", () => ({ getLocale: async () => "de" }));
vi.mock("@/lib/sponsorship/offers", () => ({ getOfferBySlug: m.getOfferBySlug }));
vi.mock("@/lib/sponsorship-subscription/checkout", () => ({ createSubscriptionCheckout: m.createSubscriptionCheckout }));
vi.mock("@/lib/sponsorship-subscription/pending", () => ({ preparePendingCheckout: m.preparePendingCheckout }));
vi.mock("@/lib/certificate/layout", () => ({ checkCertificateFits: () => ({ ok: true }) }));
const { POST } = await import("./route");
const CUSTOMER = { id: "cust_1", email: "[email protected]", emailVerifiedAt: new Date(), stripeCustomerId: null, sepaBlockedAt: null };
const PERSONALIZATION = { beeName: "Summsi", recipientName: "Erika Mustermann", occasion: "SELBST", startDate: "2030-01-01" };
function post(body: unknown): Promise<Response> {
return POST(
new Request("https://wabenhain.test/api/patenschaft/abo", {
method: "POST",
headers: { "Content-Type": "application/json", Origin: "https://wabenhain.test" },
body: JSON.stringify(body),
})
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "error").mockImplementation(() => {});
m.isTrustedOrigin.mockReturnValue(true);
m.getCurrentCustomer.mockResolvedValue(CUSTOMER);
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: true });
m.createSubscriptionCheckout.mockResolvedValue({ clientSecret: "cs_secret", subscriptionId: "sub_local" });
m.preparePendingCheckout.mockResolvedValue({ ok: true });
});
describe("POST /api/patenschaft/abo", () => {
test("liefert das client_secret und ersetzt das Startdatum durch heute", async () => {
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ clientSecret: "cs_secret" });
const input = m.createSubscriptionCheckout.mock.calls[0][0];
expect(input.personalization.startDate).toBe(new Date().toISOString().slice(0, 10));
expect(input.offerId).toBe("offer_1");
});
test("weist fremde Herkunft ab", async () => {
m.isTrustedOrigin.mockReturnValue(false);
expect((await post({ offerSlug: "x", personalization: PERSONALIZATION })).status).toBe(403);
});
test("ohne Konto 401, ohne bestaetigte Adresse 403", async () => {
m.getCurrentCustomer.mockResolvedValueOnce(null);
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(401);
m.getCurrentCustomer.mockResolvedValueOnce({ ...CUSTOMER, emailVerifiedAt: null });
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(403);
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
});
test("ungueltige Personalisierung wird abgelehnt", async () => {
const response = await post({ offerSlug: "vierteljaehrlich", personalization: { ...PERSONALIZATION, beeName: "" } });
expect(response.status).toBe(400);
expect((await response.json()).field).toBe("beeName");
});
test("Einmal-Angebote laufen nicht ueber den Abo-Weg", async () => {
m.getOfferBySlug.mockResolvedValue({ id: "offer_2", slug: "bienenfreund", kind: "EINMALIG", isAvailable: true });
expect((await post({ offerSlug: "bienenfreund", personalization: PERSONALIZATION })).status).toBe(404);
});
test("ausgebuchtes Abo (Kontingent oder Honig) nimmt keine Abschluesse an", async () => {
m.getOfferBySlug.mockResolvedValue({ id: "offer_1", slug: "vierteljaehrlich", kind: "ABO", isAvailable: false });
expect((await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION })).status).toBe(409);
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
});
test("ein Stripe-Fehler wird ohne Details gemeldet", async () => {
m.createSubscriptionCheckout.mockRejectedValue(new Error("Invalid API Key provided: sk_test_***"));
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(response.status).toBe(502);
expect(JSON.stringify(await response.json())).not.toContain("sk_test");
});
test("bereitet den Abschluss vor (alter offener Abschluss desselben Angebots wird ersetzt)", async () => {
await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(m.preparePendingCheckout).toHaveBeenCalledWith({ customerId: "cust_1", offerId: "offer_1" });
expect(m.preparePendingCheckout.mock.invocationCallOrder[0]).toBeLessThan(m.createSubscriptionCheckout.mock.invocationCallOrder[0]);
});
test("mehr als drei offene Abschluesse je Konto: klare Ablehnung, kein neuer Checkout", async () => {
m.preparePendingCheckout.mockResolvedValue({ ok: false, reason: "too_many_pending" });
const response = await post({ offerSlug: "vierteljaehrlich", personalization: PERSONALIZATION });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ code: "too_many_pending", error: expect.stringContaining("offene Abo-Abschlüsse") });
expect(m.createSubscriptionCheckout).not.toHaveBeenCalled();
});
});