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

211 lines
8.2 KiB
TypeScript

/**
* Lieferauftraege im Abo: nur aus bezahlten Monaten, nie doppelt, und ohne
* Adresse oder Bestand zurueckgestellt statt still verloren.
*/
import { beforeEach, describe, expect, test, vi } from "vitest";
const m = vi.hoisted(() => ({
db: {
subscriptionDelivery: { findMany: vi.fn(), findUniqueOrThrow: vi.fn(), create: vi.fn(), updateMany: vi.fn() },
sponsorshipSubscription: { findMany: vi.fn() },
subscriptionPayment: { updateMany: vi.fn() },
inventory: { updateMany: vi.fn() },
order: { create: vi.fn() },
customer: { findUniqueOrThrow: vi.fn() },
$transaction: vi.fn(),
},
sendMail: vi.fn(),
recordOrderSale: vi.fn(),
scheduleApiCoreStockSync: vi.fn(),
}));
vi.mock("@/lib/prisma", () => ({ prisma: m.db }));
vi.mock("@/lib/mail", () => ({ sendMail: m.sendMail }));
vi.mock("@/lib/url", () => ({ getBaseUrl: () => "https://wabenhain.test" }));
vi.mock("@/lib/apicore-stock/stock-events", () => ({ recordOrderSale: m.recordOrderSale }));
vi.mock("@/lib/apicore-stock/schedule", () => ({ scheduleApiCoreStockSync: m.scheduleApiCoreStockSync }));
vi.mock("@/lib/sponsorship-subscription/emails", () => ({
subscriptionDeliveryEmail: vi.fn(async () => ({ subject: "Lieferung", html: "<p></p>", text: "" })),
}));
const { createDueDeliveries, NOTE_NO_ADDRESS, NOTE_NO_STOCK } = await import("./deliveries");
const NOW = new Date("2026-10-16T12:00:00.000Z");
const DAY = 24 * 60 * 60 * 1000;
const paid = (id: string, daysAgo: number) => {
const paidAt = new Date(NOW.getTime() - daysAgo * DAY);
return { id, paidAt, countsFrom: paidAt, reversedAt: null, deliveryId: null };
};
const ADDRESS = {
firstName: "Erika",
lastName: "Mustermann",
street: "Wiesenweg 1",
zip: "86450",
city: "Altenmünster",
country: "DE",
packstationNumber: null,
postNumber: null,
};
function eligibleSubscription(payments = [paid("p1", 70), paid("p2", 40), paid("p3", 10)]) {
return {
id: "sub_local",
status: "AKTIV",
offer: { cycle: "VIERTELJAEHRLICH", honeyJars: 2, harvestWindows: [] },
payments,
};
}
const delivery = {
id: "del_1",
jars: 2,
orderId: null,
subscription: {
id: "sub_local",
status: "AKTIV",
locale: "de",
customerId: "cust_1",
offerId: "offer_1",
offer: {
name: "Vierteljährlich",
honeyProduct: { id: "honey_1", name: "Frühtracht 500 g", batchId: "batch_1", weightGrams: 500, category: "HONIG", vatRatePercent: null },
},
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(console, "info").mockImplementation(() => {});
m.db.$transaction.mockImplementation(async (fn: (tx: typeof m.db) => unknown) => fn(m.db));
m.db.subscriptionDelivery.findMany.mockResolvedValue([]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription()]);
m.db.subscriptionDelivery.create.mockResolvedValue({ id: "del_1" });
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 3 });
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue(delivery);
m.db.subscriptionDelivery.updateMany.mockResolvedValue({ count: 1 });
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [ADDRESS],
});
m.db.inventory.updateMany.mockResolvedValue({ count: 1 });
m.db.order.create.mockResolvedValue({ id: "order_9", orderNumber: "WH-2026-00000009" });
m.sendMail.mockResolvedValue(true);
});
describe("createDueDeliveries", () => {
test("legt nach drei bezahlten Monaten eine 0-€-Versandbestellung an und verbraucht die Zahlungen", async () => {
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 1, deferred: 0 });
expect(m.db.subscriptionPayment.updateMany).toHaveBeenCalledWith({
where: { id: { in: ["p1", "p2", "p3"] }, deliveryId: null, reversedAt: null },
data: { deliveryId: "del_1" },
});
expect(m.db.inventory.updateMany).toHaveBeenCalledWith({
where: { productId: "honey_1", quantity: { gte: 2 } },
data: { quantity: { decrement: 2 } },
});
const order = m.db.order.create.mock.calls[0][0].data;
expect(order).toMatchObject({ status: "BEZAHLT", fulfillmentMethod: "VERSAND", totalCents: 0, street: "Wiesenweg 1" });
expect(order.items.create).toMatchObject({ productId: "honey_1", quantity: 2, unitPriceCents: 0, totalCents: 0 });
// Bezahlt-Nachweis fuer den DHL-Ablauf (admin/actions/dhl.ts verlangt eine erfolgreiche Zahlung).
expect(order.payment.create).toMatchObject({ provider: "abo", status: "SUCCEEDED", amountCents: 0, paidAt: expect.any(Date) });
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { orderId: "order_9", status: "ANGELEGT", note: null },
});
expect(m.recordOrderSale).toHaveBeenCalledWith(m.db, "order_9");
expect(m.scheduleApiCoreStockSync).toHaveBeenCalledWith(["honey_1"]);
expect(m.sendMail).toHaveBeenCalledOnce();
});
test("zwei bezahlte Monate: keine Lieferung", async () => {
m.db.sponsorshipSubscription.findMany.mockResolvedValue([eligibleSubscription([paid("p1", 40), paid("p2", 10)])]);
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
});
test("ein gleichzeitiger zweiter Lauf, der die Zahlungen nicht mehr bekommt, legt nichts an", async () => {
m.db.subscriptionPayment.updateMany.mockResolvedValue({ count: 2 });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.sendMail).not.toHaveBeenCalled();
});
test("ohne Standardadresse wird die Lieferung mit Hinweis zurueckgestellt", async () => {
m.db.customer.findUniqueOrThrow.mockResolvedValue({
email: "[email protected]",
firstName: "Erika",
lastName: "Mustermann",
phone: null,
addresses: [],
});
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 1 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_ADDRESS },
});
});
test("ohne ausreichenden Bestand wird die Lieferung zurueckgestellt, nichts wird abgebucht", async () => {
m.db.inventory.updateMany.mockResolvedValue({ count: 0 });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 1 });
expect(m.db.order.create).not.toHaveBeenCalled();
expect(m.db.subscriptionDelivery.updateMany).toHaveBeenCalledWith({
where: { id: "del_1", orderId: null },
data: { status: "ZURUECKGESTELLT", note: NOTE_NO_STOCK },
});
});
test("zurueckgestellte Lieferungen werden erneut versucht, sobald die Adresse da ist", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 1, deferred: 0 });
expect(m.db.subscriptionDelivery.create).not.toHaveBeenCalled();
});
test("eine bereits angelegte Lieferung wird nicht ein zweites Mal bestellt", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({ ...delivery, orderId: "order_9" });
const result = await createDueDeliveries(NOW);
expect(result).toEqual({ created: 0, deferred: 0 });
expect(m.db.order.create).not.toHaveBeenCalled();
});
test("im Verzug ruht eine faellige Lieferung", async () => {
m.db.subscriptionDelivery.findMany.mockResolvedValue([{ id: "del_1" }]);
m.db.sponsorshipSubscription.findMany.mockResolvedValue([]);
m.db.subscriptionDelivery.findUniqueOrThrow.mockResolvedValue({
...delivery,
subscription: { ...delivery.subscription, status: "IM_VERZUG" },
});
await createDueDeliveries(NOW);
expect(m.db.order.create).not.toHaveBeenCalled();
});
});