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
- 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]>
211 lines
8.2 KiB
TypeScript
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();
|
|
});
|
|
});
|