import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
import offersPdfRoutes from "../routes/admin/offers-pdf";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
// Offers, received orders (their confirmation PDF) and issued orders are NOT
// tax documents — VAT must never appear on them. These tests pin that contract
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
// is covered in issued-orders.test.ts.)
// The explanatory prices-excl.-VAT notice was removed at user request — the
// total label alone carries the information. Pin its absence too.
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
const EN_NOTE = "Prices are exclusive of VAT";
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
const order = {
order_number: "26730042",
customer_order_number: "PO-XYZ",
created_at: new Date("2026-06-09T12:00:00"),
currency: "CZK",
notes: null,
customers: null,
};
const items = [
{
description: "Materiál",
quantity: 2,
unit: "ks",
unit_price: 100,
is_included_in_total: true,
},
];
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
expect(html).not.toContain("Mezisoučet");
// Line total = netto (2 × 100), no VAT-on-top anywhere.
expect(html).toContain('
200,00 | ');
expect(html).toContain("Celkem bez DPH");
expect(html).not.toContain(CS_NOTE);
});
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
expect(html).not.toContain("VAT%");
expect(html).toContain("Total excl. VAT");
expect(html).not.toContain(EN_NOTE);
});
it("excludes not-included lines from the NET total", () => {
const html = renderOrderConfirmationHtml(
order,
[
...items,
{
description: "Mimo cenu",
quantity: 1,
unit: "ks",
unit_price: 999,
is_included_in_total: false,
},
],
null,
"cs",
"Jan",
);
// Grand total stays 200,00 — the excluded line doesn't count.
expect(html).toContain("200,00 CZK");
});
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
const html = renderOrderConfirmationHtml(
order,
[
{
description: "Velká položka",
quantity: 2,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
},
],
null,
"cs",
"Jan",
);
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
expect(html).toContain("2 000,00 CZK");
expect(html).not.toContain("1 199,00 CZK");
});
});
/* -------------------------------------------------------------------------- */
/* Offer PDF route (returns the HTML for the print view) */
/* -------------------------------------------------------------------------- */
let app: ReturnType | null = null;
let adminToken = "";
const createdQuotationIds: number[] = [];
const createdInvoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
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 createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
});
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
// Direct prisma insert (explicit number → no sequence consumed).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-VATNOTE-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
position: 0,
},
],
},
},
});
createdQuotationIds.push(quotation.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${quotation.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("Celkem bez DPH");
expect(html).not.toContain(CS_NOTE);
expect(html).not.toContain("%DPH");
expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Celkem k úhradě");
});
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-FRACQTY-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Půldenní sazba",
quantity: 1.5,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
position: 0,
},
],
},
},
});
createdQuotationIds.push(quotation.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${quotation.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
// decimals elsewhere (covered by the previous test's qty 2).
expect(html).toContain("1,50 / ks");
expect(html).not.toContain(">2 / ks<");
});
});
/* -------------------------------------------------------------------------- */
/* Invoice PDF route — post-pdf-shared-extraction render marker */
/* -------------------------------------------------------------------------- */
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
it("still renders the invoice HTML after the shared-helper extraction", async () => {
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
const invoice = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
items: [
{
description: "PDF-SHARED-MARKER",
quantity: 2,
unit_price: 1000,
vat_rate: 21,
},
],
});
createdInvoiceIds.push(invoice.id);
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("FAKTURA - DAŇOVÝ DOKLAD");
expect(html).toContain("PDF-SHARED-MARKER");
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
expect(html).toContain("2 000,00");
expect(html).toContain("Celkem k úhradě");
});
});