import { describe, it, expect, beforeAll, afterAll, afterEach, vi, } from "vitest"; import Fastify from "fastify"; import jwt from "jsonwebtoken"; import prisma from "../config/database"; import { config } from "../config/env"; import { generateIssuedOrderNumber, previewIssuedOrderNumber, releaseIssuedOrderNumber, } from "../services/numbering.service"; import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema"; import { computeIssuedOrderTotals, createIssuedOrder, getIssuedOrder, listIssuedOrders, updateIssuedOrder, deleteIssuedOrder, type IssuedOrderInput, } from "../services/issued-orders.service"; import issuedOrdersRoutes from "../routes/admin/issued-orders"; import issuedOrdersPdfRoutes, { renderIssuedOrderHtml, buildIssuedOrderPdfFooter, } from "../routes/admin/issued-orders-pdf"; import { nasOrdersManager } from "../services/nas-financials-manager"; import { htmlToPdf } from "../utils/html-to-pdf"; // Keep Puppeteer OUT of the tests: the /:id/file live-render fallback calls // htmlToPdf — stub the whole module so no browser ever launches. vi.mock("../utils/html-to-pdf", () => ({ htmlToPdf: vi.fn(async () => Buffer.from("%PDF-FAKE issued-orders-test")), closeBrowser: vi.fn(async () => {}), })); afterEach(async () => { await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } }); }); describe("issued-order numbering", () => { it("generates sequential numbers", async () => { const year = new Date().getFullYear(); const a = await generateIssuedOrderNumber(year); const b = await generateIssuedOrderNumber(year); expect(Number(b.number)).toBe(Number(a.number) + 1); }); it("preview does not consume the sequence", async () => { const year = new Date().getFullYear(); const p1 = await previewIssuedOrderNumber(year); const p2 = await previewIssuedOrderNumber(year); expect(p1.number).toBe(p2.number); }); it("release frees only the highest number", async () => { const year = new Date().getFullYear(); await generateIssuedOrderNumber(year); // seq 1 await generateIssuedOrderNumber(year); // seq 2 const c = await generateIssuedOrderNumber(year); // seq 3 (current highest) const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4 // Releasing a number that is NOT the current highest is a no-op. await releaseIssuedOrderNumber(year, "00000000"); expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease); // Releasing the current highest reclaims it. await releaseIssuedOrderNumber(year, c.number); expect((await previewIssuedOrderNumber(year)).number).toBe(c.number); }); }); describe("CreateIssuedOrderSchema", () => { it("coerces string form numbers and rejects a NaN quantity", () => { const ok = CreateIssuedOrderSchema.safeParse({ supplier_id: "5", items: [{ description: "X", quantity: "2", unit_price: "100" }], }); expect(ok.success).toBe(true); if (ok.success) { expect(ok.data.supplier_id).toBe(5); expect(ok.data.items![0].quantity).toBe(2); expect(ok.data.items![0].unit_price).toBe(100); } const bad = CreateIssuedOrderSchema.safeParse({ items: [{ description: "X", quantity: "not-a-number" }], }); expect(bad.success).toBe(false); }); }); const createdIds: number[] = []; const createdSupplierIds: number[] = []; afterEach(async () => { // Orders first — the supplier FK is onDelete Restrict. for (const id of createdIds) await prisma.issued_orders.deleteMany({ where: { id } }); for (const id of createdSupplierIds) await prisma.sklad_suppliers.deleteMany({ where: { id } }); createdIds.length = 0; createdSupplierIds.length = 0; }); async function makeSupplier( data: Partial<{ name: string; ico: string; is_active: boolean }> = {}, ) { const s = await prisma.sklad_suppliers.create({ data: { name: data.name ?? "io_test_Dodavatel s.r.o.", ico: data.ico ?? null, is_active: data.is_active ?? true, }, }); createdSupplierIds.push(s.id); return s; } /** createIssuedOrder + narrow the supplier-validation union + track cleanup. */ async function mkIssued(input: IssuedOrderInput = {}) { const res = await createIssuedOrder(input); if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`); createdIds.push(res.id); return res; } describe("computeIssuedOrderTotals (NET only — no VAT on issued orders)", () => { it("sums qty × unit_price per line, rounded to 2dp", () => { const t = computeIssuedOrderTotals([ { quantity: 2, unit_price: 100 }, { quantity: 1, unit_price: 50 }, ]); expect(t).toEqual({ total: 250 }); }); it("treats null/garbage numerics as zero", () => { const t = computeIssuedOrderTotals([ { quantity: null, unit_price: 100 }, { quantity: 3, unit_price: "abc" }, { quantity: 2, unit_price: "10.555" }, ]); expect(t).toEqual({ total: 21.11 }); }); }); describe("createIssuedOrder", () => { it("defaults to draft with NO PO number, stores supplier_id + items", async () => { const s = await makeSupplier(); const order = await mkIssued({ supplier_id: s.id, items: [{ description: "Materiál", quantity: 2, unit_price: 100 }], }); // Deferred numbering: a draft carries no number. expect(order.po_number).toBeNull(); expect(order.status).toBe("draft"); expect(order.supplier_id).toBe(s.id); const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, }); expect(items.length).toBe(1); expect(Number(items[0].unit_price)).toBe(100); }); it("numbers immediately when created already-finalized (status sent)", async () => { const before = (await previewIssuedOrderNumber()).number; const order = await mkIssued({ status: "sent", items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); expect(order.po_number).toBe(before); expect(order.status).toBe("sent"); }); it("rejects a nonexistent supplier_id instead of throwing P2003", async () => { const res = await createIssuedOrder({ supplier_id: 99999999 }); expect("error" in res && res.error).toBe("supplier_not_found"); }); it("persists order_text on create, updates and clears it on update", async () => { const order = await mkIssued({ order_text: "Objednáváme dle smlouvy:" }); let row = await prisma.issued_orders.findUnique({ where: { id: order.id }, }); expect(row!.order_text).toBe("Objednáváme dle smlouvy:"); await updateIssuedOrder(order.id, { order_text: "Jiný text:" }); row = await prisma.issued_orders.findUnique({ where: { id: order.id } }); expect(row!.order_text).toBe("Jiný text:"); // null clears back to the PDF default. await updateIssuedOrder(order.id, { order_text: null }); row = await prisma.issued_orders.findUnique({ where: { id: order.id } }); expect(row!.order_text).toBeNull(); }); }); describe("updateIssuedOrder status transitions", () => { it("allows draft -> sent and rejects draft -> completed", async () => { const order = await mkIssued({ items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); const ok = await updateIssuedOrder(order.id, { status: "sent" }); expect("error" in ok).toBe(false); const bad = await updateIssuedOrder(order.id, { status: "completed" }); expect("error" in bad && bad.error).toBe("invalid_transition"); }); it("explicitly rejects a field edit once confirmed (not_editable), items unchanged", async () => { const order = await mkIssued({ items: [{ description: "A", quantity: 1, unit_price: 10 }], }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); // The old behavior silently DROPPED the field edit; now it is an explicit // not_editable rejection (offers parity) — and the items stay unchanged. const res = await updateIssuedOrder(order.id, { items: [{ description: "B", quantity: 9, unit_price: 99 }], }); expect("error" in res && res.error).toBe("not_editable"); const items = await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, }); expect(items.length).toBe(1); expect(items[0].description).toBe("A"); }); it("status-only transition payloads still work when not editable", async () => { const order = await mkIssued({ items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); const res = await updateIssuedOrder(order.id, { status: "completed" }); expect("error" in res).toBe(false); const row = await prisma.issued_orders.findUnique({ where: { id: order.id }, }); expect(row!.status).toBe("completed"); }); it("rejects a nonexistent supplier_id on update", async () => { const order = await mkIssued({}); const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); expect("error" in res && res.error).toBe("supplier_not_found"); }); it("finalizes a sections-only order (no items) successfully", async () => { const order = await mkIssued({}); const res = await updateIssuedOrder(order.id, { status: "sent", items: [], sections: [ { title: "Scope", title_cz: "Rozsah prací", content: "

Detail

" }, ], }); expect("error" in res).toBe(false); const row = await prisma.issued_orders.findUnique({ where: { id: order.id }, }); expect(row!.status).toBe("sent"); expect(row!.po_number).toBeTruthy(); }); it("rejects finalizing a completely blank order (no items, no sections)", async () => { const order = await mkIssued({}); const res = await updateIssuedOrder(order.id, { status: "sent", items: [], sections: [], }); expect("error" in res && res.error).toBe("empty_document"); // Stays a draft — the finalize was rejected before any write. const row = await prisma.issued_orders.findUnique({ where: { id: order.id }, }); expect(row!.status).toBe("draft"); expect(row!.po_number).toBeNull(); }); it("rejects creating a non-draft order with no content", async () => { const res = await createIssuedOrder({ status: "sent" }); expect("error" in res && res.error).toBe("empty_document"); }); }); describe("getIssuedOrder", () => { it("returns supplier, supplier_name and valid_transitions", async () => { const s = await makeSupplier(); const order = await mkIssued({ supplier_id: s.id }); const detail = await getIssuedOrder(order.id); expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o."); expect(detail?.supplier?.id).toBe(s.id); expect(detail?.valid_transitions).toContain("sent"); }); }); describe("deleteIssuedOrder", () => { it("deletes, cascades items, frees the latest number", async () => { const order = await mkIssued({ items: [{ description: "X", quantity: 1, unit_price: 1 }], }); // Finalize so the order has a real (consumed) number to free on delete. const finalized = await updateIssuedOrder(order.id, { status: "sent" }); const before = "po_number" in finalized ? finalized.po_number : null; expect(before).toBeTruthy(); await deleteIssuedOrder(order.id); expect( await prisma.issued_orders.findUnique({ where: { id: order.id } }), ).toBeNull(); expect( ( await prisma.issued_order_items.findMany({ where: { issued_order_id: order.id }, }) ).length, ).toBe(0); expect((await previewIssuedOrderNumber()).number).toBe(before); }); it("releases the number against the order's creation year, not the current year", async () => { const priorYear = new Date().getFullYear() - 1; // Allocate a real prior-year number (consumes that year's sequence: 0 -> 1) // so the PO number matches the prior year's current highest. const { number: poNumber } = await generateIssuedOrderNumber(priorYear); const order = await mkIssued({ po_number: poNumber }); // Backdate creation into the prior year so delete derives that year. await prisma.issued_orders.update({ where: { id: order.id }, data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) }, }); await deleteIssuedOrder(order.id); // The prior-year sequence must be decremented (1 -> 0). With the old // current-year bug it would stay at 1 (no current-year row to match). const seq = await prisma.number_sequences.findFirst({ where: { type: "issued_order", year: priorYear }, }); expect(seq?.last_number).toBe(0); }); }); describe("listIssuedOrders month filter", () => { it("returns only orders whose order_date is in the given month", async () => { const inMonth = await mkIssued({ order_date: "2026-03-15" }); const outMonth = await mkIssued({ order_date: "2026-04-15" }); const res = await listIssuedOrders({ page: 1, limit: 50, skip: 0, sort: "id", order: "desc", month: 3, year: 2026, }); const ids = res.data.map((o) => o.id); expect(ids).toContain(inMonth.id); expect(ids).not.toContain(outMonth.id); }); }); /* -------------------------------------------------------------------------- */ /* Route-level: suppliers lookup endpoint + supplier validation */ /* -------------------------------------------------------------------------- */ let app: ReturnType | null = null; let adminToken = ""; let adminUserId = 0; let adminFullName = ""; let lockerToken = ""; let lockerUserId = 0; let noPermToken = ""; let noPermUserId = 0; let noPermRoleId = 0; function generateToken(user: { id: number; username: string; roleName: string | null; }): string { return jwt.sign( { sub: user.id, username: user.username, role: user.roleName }, config.jwt.secret, { expiresIn: "15m" }, ); } beforeAll(async () => { app = Fastify({ logger: false }); await app.register(issuedOrdersRoutes, { prefix: "/api/admin/issued-orders", }); await app.register(issuedOrdersPdfRoutes, { prefix: "/api/admin/issued-orders-pdf", }); const admin = await prisma.users.findFirst({ where: { roles: { name: "admin" } }, }); if (!admin) throw new Error("Test setup: admin user not found"); adminToken = generateToken({ id: admin.id, username: admin.username, roleName: "admin", }); adminUserId = admin.id; adminFullName = `${admin.first_name} ${admin.last_name}`.trim(); // A SECOND user with edit rights (admin role → bypasses permission checks), // to exercise the 423 lock conflict + the locked_by enrichment on GET /:id. const locker = await prisma.users.create({ data: { username: "io_test_locker", email: "io_test_locker@test.local", password_hash: "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", first_name: "Druhý", last_name: "Editor", is_active: true, role_id: admin.role_id, }, }); lockerUserId = locker.id; lockerToken = generateToken({ id: locker.id, username: locker.username, roleName: "admin", }); // A role with NO orders permissions, to prove the lookup endpoint is gated. const noPermRole = await prisma.roles.create({ data: { name: "io_test_no_orders", display_name: "Test No Orders", description: "Test role without orders permissions", }, }); noPermRoleId = noPermRole.id; const noPermUser = await prisma.users.create({ data: { username: "io_test_noperm", email: "io_test_noperm@test.local", password_hash: "$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO", first_name: "No", last_name: "Orders", is_active: true, role_id: noPermRole.id, }, }); noPermUserId = noPermUser.id; noPermToken = generateToken({ id: noPermUser.id, username: noPermUser.username, roleName: noPermRole.name, }); }); afterAll(async () => { if (lockerUserId) await prisma.users.delete({ where: { id: lockerUserId } }).catch(() => {}); if (noPermUserId) await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {}); if (noPermRoleId) await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {}); if (app) await app.close(); }); describe("GET /api/admin/issued-orders/suppliers", () => { it("returns active suppliers only (with the picker fields)", async () => { const active = await makeSupplier({ name: "io_test_Aktivní dodavatel", ico: "12345678", }); const inactive = await makeSupplier({ name: "io_test_Neaktivní dodavatel", is_active: false, }); const res = await app!.inject({ method: "GET", url: "/api/admin/issued-orders/suppliers", headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.success).toBe(true); const ids = body.data.map((s: { id: number }) => s.id); expect(ids).toContain(active.id); expect(ids).not.toContain(inactive.id); const row = body.data.find((s: { id: number }) => s.id === active.id); expect(row.name).toBe("io_test_Aktivní dodavatel"); expect(row.ico).toBe("12345678"); // Lightweight lookup shape — all picker fields present (structured // address since the 2026-06 supplier customers-model split; the // email/phone columns were dropped in favour of custom_fields). expect(Object.keys(row).sort()).toEqual( [ "city", "country", "dic", "ico", "id", "name", "postal_code", "street", ].sort(), ); }); it("is denied (403) to a user with no orders permissions", async () => { const res = await app!.inject({ method: "GET", url: "/api/admin/issued-orders/suppliers", headers: { Authorization: `Bearer ${noPermToken}` }, }); expect(res.statusCode).toBe(403); expect(res.json().success).toBe(false); }); }); describe("POST /api/admin/issued-orders supplier validation", () => { it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => { const res = await app!.inject({ method: "POST", url: "/api/admin/issued-orders", headers: { Authorization: `Bearer ${adminToken}` }, payload: { supplier_id: 99999999 }, }); expect(res.statusCode).toBe(400); const body = res.json(); expect(body.success).toBe(false); expect(body.error).toBe("Dodavatel nenalezen"); }); }); describe("POST /api/admin/issued-orders legacy dropped fields", () => { it("silently ignores the removed flat-text keys (old clients keep working)", async () => { // delivery_terms/payment_terms/issued_by/notes were dropped from the // schema AND the table — a stale client still sending them must save // cleanly (non-strict Zod strips unknown keys) with the kept fields intact. const res = await app!.inject({ method: "POST", url: "/api/admin/issued-orders", headers: { Authorization: `Bearer ${adminToken}` }, payload: { notes: "

stará poznámka

", delivery_terms: "DAP, sklad odběratele", payment_terms: "splatnost 14 dní", issued_by: "Starý klient", order_text: "Objednáváme dle smlouvy:", }, }); expect(res.statusCode).toBe(201); const body = res.json(); expect(body.success).toBe(true); createdIds.push(body.data.id); const row = await prisma.issued_orders.findUnique({ where: { id: body.data.id }, }); expect(row).not.toBeNull(); expect(row!.order_text).toBe("Objednáváme dle smlouvy:"); }); }); describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { it("400s a field edit on a confirmed order with the explicit Czech message", async () => { const order = await mkIssued({ items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); const res = await app!.inject({ method: "PUT", url: `/api/admin/issued-orders/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, payload: { order_text: "pokus o úpravu" }, }); expect(res.statusCode).toBe(400); expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat"); }); it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => { // The IssuedOrderDetail transition flushes unsaved edits by sending the // full payload + status in ONE PUT while the order is still editable // (sent). The server must apply the items AND the transition together. const order = await mkIssued({ items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); await updateIssuedOrder(order.id, { status: "sent" }); const res = await app!.inject({ method: "PUT", url: `/api/admin/issued-orders/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, payload: { status: "confirmed", items: [ { description: "Flushnutá položka", quantity: 2, unit_price: 50, position: 0, }, ], }, }); expect(res.statusCode).toBe(200); const row = await prisma.issued_orders.findUnique({ where: { id: order.id }, include: { issued_order_items: true }, }); expect(row!.status).toBe("confirmed"); expect(row!.issued_order_items.map((i) => i.description)).toContain( "Flushnutá položka", ); }); it("a status-only transition payload keeps working (confirmed -> completed)", async () => { const order = await mkIssued({ items: [{ description: "Položka", quantity: 1, unit_price: 1 }], }); await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "confirmed" }); const res = await app!.inject({ method: "PUT", url: `/api/admin/issued-orders/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, payload: { status: "completed" }, }); expect(res.statusCode).toBe(200); const row = await prisma.issued_orders.findUnique({ where: { id: order.id }, }); expect(row!.status).toBe("completed"); }); }); describe("issued-order audit old/new values", () => { it("PUT writes an audit row carrying oldValues and newValues", async () => { const order = await mkIssued({ internal_notes: "před úpravou" }); const res = await app!.inject({ method: "PUT", url: `/api/admin/issued-orders/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, payload: { internal_notes: "po úpravě" }, }); expect(res.statusCode).toBe(200); const audit = await prisma.audit_logs.findFirst({ where: { entity_type: "issued_order", entity_id: order.id, action: "update", }, orderBy: { id: "desc" }, }); expect(audit).not.toBeNull(); expect(audit!.old_values).toBeTruthy(); expect(audit!.new_values).toBeTruthy(); expect(JSON.parse(audit!.old_values!).internal_notes).toBe("před úpravou"); expect(JSON.parse(audit!.new_values!).internal_notes).toBe("po úpravě"); // Unnumbered drafts read "koncept", never "null". expect(audit!.description).toContain("koncept"); expect(audit!.description).not.toContain("null"); }); it("DELETE writes an audit row carrying oldValues", async () => { const order = await mkIssued({ internal_notes: "ke smazání" }); const res = await app!.inject({ method: "DELETE", url: `/api/admin/issued-orders/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(res.statusCode).toBe(200); const audit = await prisma.audit_logs.findFirst({ where: { entity_type: "issued_order", entity_id: order.id, action: "delete", }, orderBy: { id: "desc" }, }); expect(audit).not.toBeNull(); expect(JSON.parse(audit!.old_values!).internal_notes).toBe("ke smazání"); expect(audit!.description).toContain("koncept"); }); }); describe("GET /api/admin/issued-orders-pdf/:id language default", () => { it("defaults to the order's language column; ?lang= is an explicit override", async () => { const order = await mkIssued({ language: "en", items: [{ description: "Lang test", quantity: 1, unit_price: 10 }], }); // Echo the rendered HTML through the htmlToPdf stub so the response body // is assertable (still no Puppeteer). Once-scoped: the module-level fake // ("%PDF-FAKE …") stays in place for the other tests. vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) => Buffer.from(html), ); const en = await app!.inject({ method: "GET", url: `/api/admin/issued-orders-pdf/${order.id}`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(en.statusCode).toBe(200); expect(en.rawPayload.toString()).toContain("PURCHASE ORDER No."); vi.mocked(htmlToPdf).mockImplementationOnce(async (html: string) => Buffer.from(html), ); const cs = await app!.inject({ method: "GET", url: `/api/admin/issued-orders-pdf/${order.id}?lang=cs`, headers: { Authorization: `Bearer ${adminToken}` }, }); expect(cs.statusCode).toBe(200); expect(cs.rawPayload.toString()).toContain("OBJEDNÁVKA č."); }); }); /* -------------------------------------------------------------------------- */ /* PDF render */ /* -------------------------------------------------------------------------- */ describe("renderIssuedOrderHtml", () => { const order = { po_number: "26720001", order_date: new Date("2026-06-09T12:00:00"), delivery_date: null, currency: "CZK", }; const items = [ { description: "Materiál", item_description: "Detailní popis položky", quantity: 2, unit: "ks", unit_price: 100, }, ]; const issuer = { name: "Jan Novák" }; // sklad_suppliers shape: structured address + ico/dic columns. const supplier = { name: "Dodavatel s.r.o.", ico: "12345678", dic: "CZ12345678", street: "Průmyslová 5", city: "Praha", postal_code: "190 00", country: "Česká republika", }; it("renders the PO number, items, and both party names (PO direction)", () => { const html = renderIssuedOrderHtml( order, items, supplier, { company_name: "Naše firma" }, "cs", issuer, ); expect(html).toContain("26720001"); expect(html).toContain("Materiál"); // Optional item sub-line. expect(html).toContain("Detailní popis položky"); // PO direction: the sklad_suppliers record is the Dodavatel (supplier), // our company is the Odběratel (buyer). expect(html).toContain("Dodavatel s.r.o."); expect(html).toContain("Naše firma"); expect(html).toContain("Dodavatel"); expect(html).toContain("Odběratel"); }); it("omits the items table and total when there are no items (sections-only order)", () => { const html = renderIssuedOrderHtml( order, [], supplier, { company_name: "Naše firma" }, "cs", issuer, [{ title: "Scope", title_cz: "Rozsah prací", content: "

Detail

" }], ); // The section renders… expect(html).toContain("Rozsah prací"); // …but the items table, the billing heading and the total row do not. expect(html).not.toContain('class="items"'); expect(html).not.toContain("Celkem bez DPH"); }); it("renders the structured supplier address plus IČO and DIČ", () => { const html = renderIssuedOrderHtml( order, items, supplier, null, "cs", issuer, ); // street / "PSČ Město" / country — one address line each. expect(html).toContain('
Průmyslová 5
'); expect(html).toContain('
190 00 Praha
'); expect(html).toContain('
Česká republika
'); expect(html).toContain("IČ: 12345678"); expect(html).toContain("DIČ: CZ12345678"); }); it("skips empty address parts and missing IČO/DIČ", () => { const html = renderIssuedOrderHtml( order, items, { name: "Jednořádkový", street: "Ulice 1", city: "Praha" }, null, "cs", issuer, ); expect(html).toContain('
Ulice 1
'); // City without PSČ renders alone, no stray separator. expect(html).toContain('
Praha
'); expect(html).not.toContain("IČ: "); expect(html).not.toContain("DIČ: "); }); it("escapes HTML in the order_text heading (no raw tags reach the PDF)", () => { const html = renderIssuedOrderHtml( { ...order, order_text: 'Text "X"' }, items, null, null, "cs", issuer, ); expect(html).not.toContain("", }, { title: "Only EN", title_cz: null, content: null }, ]); expect(html).toContain('class="scope-sections"'); // No separate page: no page-break wrapper, header NOT repeated // ( + the one page header = exactly 2 occurrences). expect(html).not.toContain("scope-page"); expect(html).not.toContain("page-break-before"); expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(2); // Sections flow INSIDE the content block, after the items/totals. expect(html.indexOf('class="scope-sections"')).toBeGreaterThan( html.indexOf("total-cell"), ); expect(html.indexOf('class="scope-sections"')).toBeLessThan( html.indexOf("/.invoice-content"), ); // cs → title_cz preferred when non-empty… expect(html).toContain("Rozsah CZ"); expect(html).not.toContain("Scope EN"); // …and falls back to the EN title when title_cz is empty. expect(html).toContain("Only EN"); expect(html).toContain("Obsah"); expect(html).not.toContain("<script>"); expect(html).not.toContain("alert(1)"); }); it("uses the EN title for lang=en", () => { const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer, [ { title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>x</p>" }, ]); expect(html).toContain("Scope EN"); expect(html).not.toContain("Rozsah CZ"); }); it("suppresses the sections block when every section is empty (tag-stripped)", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [ { title: "", title_cz: " ", content: "<p><br></p>" }, ]); expect(html).not.toContain('class="scope-sections"'); }); it("omits the sections block when there are no sections at all", () => { const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer); expect(html).not.toContain('class="scope-sections"'); }); });