Files
Wabenhain/src/lib/sponsorship-subscription/reservation.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

120 lines
4.5 KiB
TypeScript

/**
* Abo-Reservierung im Stapel: Produktlisten brauchen den freien Bestand
* vieler Produkte auf einmal — mit festen drei Abfragen statt drei je Produkt.
*/
import { describe, expect, test, vi } from "vitest";
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
const { reservedJarsByProduct, reservedJarsForProduct, availableForSale, availableForSaleByProduct } = await import("./reservation");
const NOW = new Date("2026-10-16T12:00:00.000Z");
function fakeDb(data: {
quarterly?: { offer: { honeyProductId: string | null; honeyJars: number } }[];
windows?: { offerId: string; startsOn: Date; endsOn: Date; plannedJars: number; offer: { honeyProductId: string | null } }[];
deliveries?: { jars: number; dueAt: Date; subscription: { offerId: string } }[];
inventory?: { productId: string; quantity: number }[];
}) {
return {
sponsorshipSubscription: { findMany: vi.fn(async () => data.quarterly ?? []) },
harvestWindow: { findMany: vi.fn(async () => data.windows ?? []) },
subscriptionDelivery: { findMany: vi.fn(async () => data.deliveries ?? []) },
inventory: {
findMany: vi.fn(async () => data.inventory ?? []),
findUnique: vi.fn(async ({ where }: { where: { productId: string } }) =>
(data.inventory ?? []).find((row) => row.productId === where.productId) ?? null
),
},
};
}
type Db = Parameters<typeof reservedJarsByProduct>[2];
describe("reservedJarsByProduct", () => {
test("summiert vierteljaehrliche Abos und offene Erntefenster je Produkt in drei Abfragen", async () => {
const db = fakeDb({
quarterly: [
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
{ offer: { honeyProductId: "wald", honeyJars: 2 } },
{ offer: { honeyProductId: "raps", honeyJars: 3 } },
],
windows: [
{
offerId: "ernte",
startsOn: new Date("2026-10-01T00:00:00.000Z"),
endsOn: new Date("2026-10-31T00:00:00.000Z"),
plannedJars: 10,
offer: { honeyProductId: "wald" },
},
],
deliveries: [
{ jars: 4, dueAt: new Date("2026-10-10T08:00:00.000Z"), subscription: { offerId: "ernte" } },
// Ausserhalb des Fensters: zaehlt nicht.
{ jars: 6, dueAt: new Date("2026-09-10T08:00:00.000Z"), subscription: { offerId: "ernte" } },
],
});
const result = await reservedJarsByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db);
expect(result.get("wald")).toBe(2 + 2 + (10 - 4));
expect(result.get("raps")).toBe(3);
expect(result.get("linde")).toBe(0);
expect(db.sponsorshipSubscription.findMany).toHaveBeenCalledOnce();
expect(db.harvestWindow.findMany).toHaveBeenCalledOnce();
expect(db.subscriptionDelivery.findMany).toHaveBeenCalledOnce();
});
test("ein ueberliefertes Erntefenster reserviert nichts mehr (nie negativ)", async () => {
const db = fakeDb({
windows: [
{
offerId: "ernte",
startsOn: new Date("2026-10-01T00:00:00.000Z"),
endsOn: new Date("2026-10-31T00:00:00.000Z"),
plannedJars: 3,
offer: { honeyProductId: "wald" },
},
],
deliveries: [{ jars: 5, dueAt: new Date("2026-10-31T20:00:00.000Z"), subscription: { offerId: "ernte" } }],
});
const result = await reservedJarsByProduct(["wald"], NOW, db as unknown as Db);
expect(result.get("wald")).toBe(0);
});
test("ohne Produkte keine Abfrage", async () => {
const db = fakeDb({});
const result = await reservedJarsByProduct([], NOW, db as unknown as Db);
expect(result.size).toBe(0);
expect(db.sponsorshipSubscription.findMany).not.toHaveBeenCalled();
});
test("Einzelabfrage und freier Bestand bauen auf dem Stapel auf", async () => {
const db = fakeDb({
quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 2 } }],
inventory: [{ productId: "wald", quantity: 5 }],
});
expect(await reservedJarsForProduct("wald", NOW, db as unknown as Db)).toBe(2);
expect(await availableForSale("wald", NOW, db as unknown as Db)).toBe(3);
});
test("freier Bestand im Stapel: Lager minus Reservierung, ohne Lagerzeile 0", async () => {
const db = fakeDb({
quarterly: [{ offer: { honeyProductId: "wald", honeyJars: 4 } }],
inventory: [
{ productId: "wald", quantity: 3 },
{ productId: "raps", quantity: 7 },
],
});
const result = await availableForSaleByProduct(["wald", "raps", "linde"], NOW, db as unknown as Db);
expect(Object.fromEntries(result)).toEqual({ wald: 0, raps: 7, linde: 0 });
});
});