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: ReturnTypestará 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('