import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; import Fastify from "fastify"; import jwt from "jsonwebtoken"; import prisma from "../config/database"; import { config } from "../config/env"; import invoicesPdfRoutes from "../routes/admin/invoices-pdf"; import { createInvoice } from "../services/invoices.service"; import { getRate } from "../services/exchange-rates"; /** * Pinning tests for audit finding M5: a foreign-currency invoice PDF must * degrade gracefully when the CNB rate lookup fails — render WITHOUT the CZK * VAT recap/conversion footnote instead of 500ing the whole preview AND the * NAS archival. The rate only feeds the recap conversion; the document itself * is renderable without it. */ vi.mock("../services/exchange-rates", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getRate: vi.fn() }; }); let app: ReturnType; let adminToken: string; const createdInvoiceIds: number[] = []; beforeAll(async () => { app = Fastify({ logger: false }); await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" }); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, }); if (!admin) throw new Error("Test setup: admin user not found"); adminToken = jwt.sign( { sub: admin.id, username: admin.username, role: "admin" }, config.jwt.secret, { expiresIn: "15m" }, ); }); afterAll(async () => { for (const id of createdInvoiceIds) await prisma.invoices.deleteMany({ where: { id } }); if (app) await app.close(); await prisma.$disconnect(); }); async function makeEurInvoice() { // Draft → no number consumed; EUR → triggers the CNB rate lookup. const invoice = await createInvoice({ status: "draft", currency: "EUR", apply_vat: true, vat_rate: 21, items: [ { description: "CNB-DEGRADE-MARKER", quantity: 1, unit_price: 100, vat_rate: 21, }, ], }); createdInvoiceIds.push(invoice.id); return invoice; } describe("invoice PDF vs unreachable CNB (audit M5)", () => { it("renders the invoice without the CZK recap when the rate lookup throws", async () => { vi.mocked(getRate).mockRejectedValue( new Error("Nepodařilo se získat aktuální kurzy z ČNB"), ); const invoice = await makeEurInvoice(); const res = await app.inject({ method: "GET", url: `/api/admin/invoices-pdf/${invoice.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const html = res.body; expect(html).toContain("CNB-DEGRADE-MARKER"); // The CZK recap and the conversion footnote are omitted — their numbers // cannot be computed without the rate. expect(html).not.toContain("Rekapitulace DPH v Kč"); expect(html).not.toContain("Přepočet kurzem ČNB"); }); it("still renders the recap + conversion footnote when the rate is available", async () => { vi.mocked(getRate).mockResolvedValue(25.0); const invoice = await makeEurInvoice(); const res = await app.inject({ method: "GET", url: `/api/admin/invoices-pdf/${invoice.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const html = res.body; expect(html).toContain("Rekapitulace DPH v Kč"); expect(html).toContain("Přepočet kurzem ČNB"); expect(html).toContain("25,000"); }); });