/** * Tests fuer die Stornorechnung: hoechstens eine je Rechnung, auch wenn * Admin-Stornierung und Stripe-Webhook derselben Erstattung gleichzeitig * ankommen, und sie geht an die Kundschaft und als Kopie hinaus. */ import { describe, test, expect, vi, beforeEach } from "vitest"; const tx = { cancellationCounter: { upsert: vi.fn() }, cancellationInvoice: { create: vi.fn() }, }; const db = { invoice: { findUnique: vi.fn() }, cancellationInvoice: { findUnique: vi.fn(), update: vi.fn() }, $transaction: vi.fn(async (run: (client: typeof tx) => Promise) => run(tx)), }; vi.mock("@/lib/prisma", () => ({ prisma: db })); const generateInvoicePdf = vi.fn(async () => Buffer.from("storno-pdf")); vi.mock("@/lib/invoice-pdf", () => ({ generateInvoicePdf: (...args: unknown[]) => generateInvoicePdf(...(args as [])) })); const storeInvoicePdf = vi.fn(async () => undefined); vi.mock("@/lib/invoice-storage", () => ({ invoiceRelativePath: vi.fn(() => "2026/musterfrau-erika/ST-2026-0003.pdf"), storeInvoicePdf: (...args: unknown[]) => storeInvoicePdf(...(args as [])), readStoredInvoicePdf: vi.fn(async () => null), })); const sendMail = vi.fn(async () => true); vi.mock("@/lib/mail", () => ({ sendMail: (...args: unknown[]) => sendMail(...(args as [])) })); vi.mock("@/lib/email-templates", () => ({ cancellationInvoiceEmail: vi.fn(async () => ({ subject: "Stornorechnung", html: "

x

", text: "x" })), })); const sendInvoiceCopy = vi.fn(async () => true); vi.mock("@/lib/invoice-mail", () => ({ sendInvoiceCopy: (...args: unknown[]) => sendInvoiceCopy(...(args as [])) })); const { issueCancellationInvoice, isCancellationNumber } = await import("./cancellation-invoice"); const order = { orderNumber: "WH-2026-0042", email: "erika@example.org", firstName: "Erika", lastName: "Musterfrau", locale: "de", items: [], }; const loaded = { number: "ST-2026-0003", issuedAt: new Date("2026-10-14T09:00:00Z"), pdfPath: null, invoice: { invoiceNumber: "RE-2026-0042", issuedAt: new Date("2026-10-12T10:00:00Z"), totalCents: 5375, order }, }; beforeEach(() => { vi.clearAllMocks(); db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: null }); tx.cancellationCounter.upsert.mockResolvedValue({ lastNumber: 3 }); tx.cancellationInvoice.create.mockImplementation(async ({ data }: { data: { number: string } }) => ({ number: data.number })); db.cancellationInvoice.findUnique.mockResolvedValue(loaded); }); describe("issueCancellationInvoice", () => { test("ohne Rechnung gibt es nichts aufzuheben", async () => { db.invoice.findUnique.mockResolvedValue(null); expect(await issueCancellationInvoice("order1", "STORNIERUNG")).toBeNull(); expect(db.$transaction).not.toHaveBeenCalled(); }); test("legt die Stornorechnung mit eigener Nummer an und verschickt sie mit Kopie", async () => { // Act const result = await issueCancellationInvoice("order1", "STORNIERUNG"); // Assert const year = new Date().getFullYear(); expect(result).toEqual({ number: `ST-${year}-0003`, created: true }); expect(tx.cancellationInvoice.create).toHaveBeenCalledWith( expect.objectContaining({ data: { invoiceId: "inv1", number: `ST-${year}-0003`, totalCents: 5375, reason: "STORNIERUNG" } }) ); expect(generateInvoicePdf).toHaveBeenCalledWith( expect.objectContaining({ invoiceNumber: "RE-2026-0042", cancellation: { number: "ST-2026-0003", issuedAt: loaded.issuedAt } }) ); expect(storeInvoicePdf).toHaveBeenCalledOnce(); expect(sendMail).toHaveBeenCalledWith( expect.objectContaining({ to: "erika@example.org", attachments: [expect.objectContaining({ filename: `ST-${year}-0003.pdf` })] }) ); expect(sendInvoiceCopy).toHaveBeenCalledWith(expect.objectContaining({ title: "Stornorechnung", orderNumber: "WH-2026-0042" })); }); test("zweiter Ausloeser findet die vorhandene Stornorechnung, ohne erneut zu verschicken", async () => { db.invoice.findUnique.mockResolvedValue({ id: "inv1", totalCents: 5375, cancellation: { number: "ST-2026-0001" } }); expect(await issueCancellationInvoice("order1", "ERSTATTUNG")).toEqual({ number: "ST-2026-0001", created: false }); expect(db.$transaction).not.toHaveBeenCalled(); expect(sendMail).not.toHaveBeenCalled(); }); test("gleichzeitig angelegt: die zweite Transaktion scheitert eindeutig und liefert die erste", async () => { // Arrange: die Datenbank lehnt die zweite Stornorechnung zur selben Rechnung ab. tx.cancellationInvoice.create.mockRejectedValue(Object.assign(new Error("Unique constraint failed"), { code: "P2002" })); db.cancellationInvoice.findUnique.mockResolvedValue({ number: "ST-2026-0001" }); // Act const result = await issueCancellationInvoice("order1", "ERSTATTUNG"); // Assert expect(result).toEqual({ number: "ST-2026-0001", created: false }); expect(sendMail).not.toHaveBeenCalled(); }); test("ein Fehler beim Verschicken laesst die Stornorechnung bestehen", async () => { generateInvoicePdf.mockRejectedValueOnce(new Error("Schrift fehlt")); const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); const result = await issueCancellationInvoice("order1", "RETOURE"); expect(result?.created).toBe(true); expect(errors).toHaveBeenCalled(); errors.mockRestore(); }); }); test("isCancellationNumber erkennt nur ST-Nummern", () => { expect(isCancellationNumber("ST-2026-0001")).toBe(true); expect(isCancellationNumber("RE-2026-0001")).toBe(false); expect(isCancellationNumber("ST-2026-0001/../x")).toBe(false); });