Files
app/src/__tests__/issued-orders.test.ts
BOHA db7a5c3d15 feat(suppliers)!: full customers model - structured address + custom fields
The dodavatele modal is now an exact mirror of the customers modal
(minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme,
ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md).

Two migrations on sklad_suppliers:
- structured street/city/postal_code/country replace the free-text
  address blob (best-effort split: street / PSC regex / city)
- custom_fields LONGTEXT replaces contact_person/email/phone/notes;
  existing values are preserved as labeled custom fields

The encode/decode of the custom_fields blob is shared with customers
via the new utils/custom-fields.ts. The PO PDF supplier block renders
the structured address + custom-field lines like the customer block;
the supplier picker/detail selects and types follow. Legacy clients
sending the dropped keys are silently stripped (tested). Suppliers
list shows Ulice/Mesto instead of the dropped contact columns; search
covers name/ico/city.

BREAKING CHANGE: sklad_suppliers.address, contact_person, email,
phone, notes columns dropped (data migrated); deploy must run
prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:22 +02:00

1289 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" });
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({});
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({});
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");
});
});
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<typeof Fastify> | 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: "<p>stará poznámka</p>",
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({});
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("a status-only transition payload keeps working (confirmed -> completed)", async () => {
const order = await mkIssued({});
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("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('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain('<div class="address-line">Česká republika</div>');
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('<div class="address-line">Ulice 1</div>');
// City without PSČ renders alone, no stray separator.
expect(html).toContain('<div class="address-line">Praha</div>');
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: '<script>alert(1)</script>Text "X"' },
items,
null,
null,
"cs",
issuer,
);
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
it("renders the custom order_text heading when set, default when not", () => {
const custom = renderIssuedOrderHtml(
{ ...order, order_text: "Objednáváme dle nabídky č. 123:" },
items,
null,
null,
"cs",
issuer,
);
expect(custom).toContain("Objednáváme dle nabídky č. 123:");
expect(custom).not.toContain("Objednáváme si u Vás:");
const fallback = renderIssuedOrderHtml(
order,
items,
null,
null,
"cs",
issuer,
);
expect(fallback).toContain("Objednáváme si u Vás:");
});
it("never renders VAT columns or notices; totals NET with 'Celkem bez DPH'", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// No VAT anywhere — the PO is not a tax document.
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// Line total = netto (2 × 100), formatted with the per-currency symbol.
expect(html).toContain('<td class="right total-cell">200,00 Kč</td>');
expect(html).toContain("Celkem bez DPH");
// No Mezisoučet row (it would duplicate the grand total) and no
// prices-excl.-VAT notice (user removed it — the total label suffices).
expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Ceny jsou uvedeny bez DPH");
});
it("renders the English total label for lang=en, no VAT columns or notice", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer);
expect(html).toContain("Total excl. VAT");
expect(html).not.toContain("VAT%");
expect(html).not.toContain("Prices are exclusive of VAT");
});
it("amounts carry the currency symbol; the 'Částky jsou uvedeny v' note is gone", () => {
const cz = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Unit price, line total and grand total all go through formatCurrency.
expect(cz).toContain("100,00 Kč");
expect(cz).toContain("200,00 Kč");
expect(cz).not.toContain("Částky jsou uvedeny v");
expect(cz).not.toContain("currency-note");
const en = renderIssuedOrderHtml(
{ ...order, currency: "EUR" },
items,
null,
null,
"en",
issuer,
);
expect(en).toContain("200,00 €");
expect(en).not.toContain("Amounts are in");
});
it("body carries NO footer — Vystavil moved to the Puppeteer footer template", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// The issuer prints in the per-page footerTemplate, never in the body.
expect(html).not.toContain("Jan Novák");
expect(html).not.toContain("Vystavil:");
expect(html).not.toContain("invoice-footer");
expect(html).not.toContain("E-mail:");
expect(html).not.toContain("Schválil:");
});
it("buildIssuedOrderPdfFooter: Vystavil left, page counter centered, per language", () => {
const cs = buildIssuedOrderPdfFooter("cs", "Jan Novák");
expect(cs).toContain("Vystavil:");
expect(cs).toContain("Jan Novák");
expect(cs).toContain('Strana <span class="pageNumber"></span>');
expect(cs).toContain('z <span class="totalPages"></span>');
const en = buildIssuedOrderPdfFooter("en", "Jan Novák");
expect(en).toContain("Issued by:");
expect(en).toContain('Page <span class="pageNumber"></span>');
expect(en).toContain('of <span class="totalPages"></span>');
// Escaping: a malicious issuer name must not break out of the template.
const evil = buildIssuedOrderPdfFooter("cs", '<img src=x onerror="1">');
expect(evil).not.toContain("<img");
});
});
/* -------------------------------------------------------------------------- */
/* Sections (rich-text CZ/EN, mirrors offers' scope_sections) */
/* -------------------------------------------------------------------------- */
describe("CreateIssuedOrderSchema sections", () => {
it("accepts sections with DB-aligned 500-char titles and coerces position", () => {
const longTitle = "T".repeat(400); // >255 — the old offers cap under-shot
const ok = CreateIssuedOrderSchema.safeParse({
sections: [
{ title: longTitle, title_cz: "Rozsah", content: "<p>x</p>" },
{ title: null, position: "3" },
],
});
expect(ok.success).toBe(true);
if (ok.success) {
expect(ok.data.sections![0].title).toBe(longTitle);
expect(ok.data.sections![1].position).toBe(3);
}
const bad = CreateIssuedOrderSchema.safeParse({
sections: [{ title: "T".repeat(501) }],
});
expect(bad.success).toBe(false);
});
});
describe("issued-order sections (service)", () => {
it("creates sections ordered by position and maps them on getIssuedOrder", async () => {
const order = await mkIssued({
sections: [
{ title: "Scope EN", title_cz: "Rozsah CZ", content: "<p>Obsah A</p>" },
{ title: "Second", content: "<p>Obsah B</p>" },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(2);
expect(detail!.sections[0].title).toBe("Scope EN");
expect(detail!.sections[0].title_cz).toBe("Rozsah CZ");
expect(detail!.sections[0].position).toBe(0);
expect(detail!.sections[1].title).toBe("Second");
expect(detail!.sections[1].position).toBe(1);
});
it("full-replaces sections on update and cascades them on delete", async () => {
const order = await mkIssued({
sections: [
{ title: "A", content: "<p>a</p>" },
{ title: "B", content: "<p>b</p>" },
],
});
await updateIssuedOrder(order.id, {
sections: [
{ title_cz: "Jediná sekce", content: "<p>Nové</p>", position: 5 },
],
});
const detail = await getIssuedOrder(order.id);
expect(detail?.sections.length).toBe(1);
expect(detail!.sections[0].title_cz).toBe("Jediná sekce");
expect(detail!.sections[0].position).toBe(5);
await deleteIssuedOrder(order.id);
expect(
(
await prisma.issued_order_sections.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
});
});
describe("updateIssuedOrder atomicity (single transaction)", () => {
it("rolls back the header write when the items replace fails", async () => {
const order = await mkIssued({
internal_notes: "původní",
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
// issued_order_items.description is VarChar(500) — 600 chars makes the
// in-tx createMany fail at the DB, which must roll back the header update
// (the old two-transaction shape left the header committed: torn write).
const tooLong = "x".repeat(600);
await expect(
updateIssuedOrder(order.id, {
internal_notes: "změněno",
items: [{ description: tooLong, quantity: 1, unit_price: 1 }],
}),
).rejects.toThrow();
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.internal_notes).toBe("původní");
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");
});
});
/* -------------------------------------------------------------------------- */
/* PO number uniqueness + preview collision-advance */
/* -------------------------------------------------------------------------- */
describe("po_number uniqueness", () => {
it("returns a clean 409 for an explicit duplicate po_number, not a 500", async () => {
await mkIssued({ po_number: "IODUP1" });
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { po_number: "IODUP1" },
});
expect(res.statusCode).toBe(409);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Číslo objednávky je již použito");
});
it("preview advances past a number already used by an existing order", async () => {
const p = await previewIssuedOrderNumber();
const order = await mkIssued({ po_number: p.number });
expect(order.po_number).toBe(p.number);
const p2 = await previewIssuedOrderNumber();
expect(p2.number).not.toBe(p.number);
});
});
/* -------------------------------------------------------------------------- */
/* Edit locking (mirrors offers/quotations.ts — 10s lock + heartbeat) */
/* -------------------------------------------------------------------------- */
describe("issued-order edit locking", () => {
const lockUrl = (id: number, action: string) =>
`/api/admin/issued-orders/${id}/${action}`;
it("locks for the first editor; a second user gets 423 with the holder's name", async () => {
const order = await mkIssued({});
const r1 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(r1.statusCode).toBe(200);
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(423);
expect(r2.json().error).toContain("Objednávku právě upravuje");
expect(r2.json().error).toContain(adminFullName);
});
it("re-locking by the SAME holder succeeds (refresh, not conflict)", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const again = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(again.statusCode).toBe(200);
});
it("heartbeat extends the lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// Age the lock close to expiry, then heartbeat — locked_at must be fresh.
const aged = new Date(Date.now() - 9000);
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: aged },
});
const hb = await app!.inject({
method: "POST",
url: lockUrl(order.id, "heartbeat"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(hb.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(Date.now() - new Date(row!.locked_at!).getTime()).toBeLessThan(5000);
});
it("unlock releases the lock so another user can take it", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
const ul = await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(ul.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBeNull();
expect(row!.locked_at).toBeNull();
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
});
it("unlock by a non-holder does NOT release someone else's lock", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "unlock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(adminUserId);
});
it("a stale lock (>30s without heartbeat) is takeable by another user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
await prisma.issued_orders.update({
where: { id: order.id },
data: { locked_at: new Date(Date.now() - 31000) },
});
const r2 = await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(r2.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.locked_by).toBe(lockerUserId);
});
it("GET /:id enriches locked_by ONLY for the other user", async () => {
const order = await mkIssued({});
await app!.inject({
method: "POST",
url: lockUrl(order.id, "lock"),
headers: { Authorization: `Bearer ${adminToken}` },
});
// The holder sees no lock banner (locked_by null).
const asHolder = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(asHolder.statusCode).toBe(200);
expect(asHolder.json().data.locked_by).toBeNull();
// The other user sees who holds the lock (offers' enrichment shape).
const asOther = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${lockerToken}` },
});
expect(asOther.statusCode).toBe(200);
const lockedBy = asOther.json().data.locked_by;
expect(lockedBy).not.toBeNull();
expect(lockedBy.user_id).toBe(adminUserId);
expect(lockedBy.full_name).toBe(adminFullName);
expect(typeof lockedBy.username).toBe("string");
});
});
/* -------------------------------------------------------------------------- */
/* GET /:id/file — archived PDF with live-render fallback */
/* -------------------------------------------------------------------------- */
describe("GET /api/admin/issued-orders/:id/file", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("404s for a draft without a po_number", async () => {
const order = await mkIssued({});
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("serves the archived PDF bytes inline on an archive hit", async () => {
const order = await mkIssued({ po_number: "IOTESTPOHIT" });
const bytes = Buffer.from("%PDF-ARCHIVED issued");
const readSpy = vi
.spyOn(nasOrdersManager, "readIssuedByNumber")
.mockReturnValue({ data: bytes, fileName: "IOTESTPOHIT.pdf" });
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
expect(res.headers["content-disposition"]).toContain("inline");
expect(res.headers["content-disposition"]).toContain(
"Objednavka-IOTESTPOHIT.pdf",
);
expect(res.rawPayload.equals(bytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith("IOTESTPOHIT");
});
it("falls back to a live render and re-archives on an archive miss", async () => {
const order = await mkIssued({
po_number: "IOTESTPOMISS",
order_date: "2026-03-15",
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
sections: [{ title_cz: "Rozsah", content: "<p>Obsah</p>" }],
});
vi.spyOn(nasOrdersManager, "readIssuedByNumber").mockReturnValue(null);
vi.spyOn(nasOrdersManager, "isConfigured").mockReturnValue(true);
const cleanSpy = vi
.spyOn(nasOrdersManager, "cleanIssued")
.mockImplementation(() => {});
const saveSpy = vi
.spyOn(nasOrdersManager, "saveIssuedPdf")
.mockReturnValue("Vydané/2026/03/IOTESTPOMISS.pdf");
const res = await app!.inject({
method: "GET",
url: `/api/admin/issued-orders/${order.id}/file`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toContain("application/pdf");
// The stubbed htmlToPdf output is served (live render, no Puppeteer).
expect(res.rawPayload.toString()).toContain("%PDF-FAKE");
// The fresh PDF was archived to the NAS under the order_date year/month.
expect(cleanSpy).toHaveBeenCalledWith("IOTESTPOMISS");
expect(saveSpy).toHaveBeenCalledWith(
"IOTESTPOMISS",
2026,
3,
expect.any(Buffer),
);
});
});
/* -------------------------------------------------------------------------- */
/* PDF — sections scope page */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml sections (scope page)", () => {
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: null,
quantity: 1,
unit: null,
unit_price: 100,
},
];
const issuer = { name: "Jan Novák" };
it("renders sections INLINE after the items (no new page, no repeated header); cs prefers title_cz, sanitizes content", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer, [
{
title: "Scope EN",
title_cz: "Rozsah CZ",
content: "<p>Obsah</p><script>alert(1)</script>",
},
{ title: "Only EN", title_cz: null, content: null },
]);
expect(html).toContain('class="scope-sections"');
// No separate page: no page-break wrapper, header NOT repeated
// (<title> + 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"');
});
});