Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
failed deficit line rolls back the surplus corrective receipt — retries
no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
(in-transaction, clamped at 0)
High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
(Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
hours against its own balance (mirrors createLeave)
Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
(recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
@db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
/items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
settings.company|settings.system; Settings System tab no longer clobbers
Firma numbering patterns; draft invoices hide the dead PDF button;
dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
audit-log + invoice month buckets day-shift fixes
Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.
~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1323 lines
46 KiB
TypeScript
1323 lines
46 KiB
TypeScript
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("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({});
|
||
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({});
|
||
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("<script>");
|
||
});
|
||
|
||
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"');
|
||
});
|
||
});
|