feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).
Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
the PO template's red style (shared DocumentSectionSchema, one-transaction
create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
/file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
number previews (also invoice previews), PUT returns assigned po_number
Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
follows the table; order creation only from active offers) +
valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
dates, ints for positions); audit old/new values + koncept fallbacks;
list id tiebreaks; stats include trimmed; NAS delete dedupe
Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
document number (form field removed), one form layout/readonly convention,
view-permission opens read-only everywhere (issued's editable-for-viewers
hole closed), server-driven transition buttons, dirty guard + Enter submit
on both, useApiMutation everywhere (new opt-in envelope mode), fixed
infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
numbers on offers, shared hardened per-row PDF flow (spinner, double-click
guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
states, query-lib cleanups (["offers","customers"] key, typed list rows,
shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
PDF language now comes from the document column
+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import {
|
||||
previewIssuedOrderNumber,
|
||||
previewInvoiceNumber,
|
||||
@@ -19,23 +22,36 @@ import {
|
||||
createOffer,
|
||||
updateOffer,
|
||||
deleteOffer,
|
||||
invalidateOffer,
|
||||
getOffer,
|
||||
listOffers,
|
||||
} from "../services/offers.service";
|
||||
import quotationsRoutes from "../routes/admin/quotations";
|
||||
|
||||
// Track every row we create so the suite leaves the (real) app_test DB clean.
|
||||
const issuedOrderIds: number[] = [];
|
||||
const invoiceIds: number[] = [];
|
||||
const offerIds: number[] = [];
|
||||
const customerIds: number[] = [];
|
||||
const linkedOrderIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of issuedOrderIds)
|
||||
await prisma.issued_orders.deleteMany({ where: { id } });
|
||||
for (const id of invoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
|
||||
for (const id of linkedOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
for (const id of offerIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of customerIds)
|
||||
await prisma.customers.deleteMany({ where: { id } });
|
||||
issuedOrderIds.length = 0;
|
||||
invoiceIds.length = 0;
|
||||
linkedOrderIds.length = 0;
|
||||
offerIds.length = 0;
|
||||
customerIds.length = 0;
|
||||
// Reset every sequence this suite may have touched so preview values are
|
||||
// stable across tests and we don't leak consumed numbers into other files.
|
||||
await prisma.number_sequences.deleteMany({
|
||||
@@ -43,6 +59,14 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** createOffer + narrow the token union + track cleanup. */
|
||||
async function mkOffer(body: Record<string, unknown> = {}) {
|
||||
const res = await createOffer(body);
|
||||
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
|
||||
offerIds.push(res.id);
|
||||
return res;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issued orders */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -283,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — status transition table (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer status transitions (service)", () => {
|
||||
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await updateOffer(draft.id, { status: "ordered" });
|
||||
expect("error" in res && res.error).toBe("invalid_transition");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
});
|
||||
|
||||
it("allows active -> invalidated via update", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
|
||||
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const rejected = await invalidateOffer(draft.id);
|
||||
expect("error" in rejected && rejected.error).toBe("invalid_transition");
|
||||
|
||||
const ordered = await mkOffer({ status: "ordered" });
|
||||
const ok = await invalidateOffer(ordered.id);
|
||||
expect("error" in ok).toBe(false);
|
||||
|
||||
// invalidated is terminal — a second invalidate is rejected too.
|
||||
const again = await invalidateOffer(ordered.id);
|
||||
expect("error" in again && again.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("getOffer exposes valid_transitions computed from the current status", async () => {
|
||||
const draft = await mkOffer({});
|
||||
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
|
||||
|
||||
const active = await mkOffer({ status: "active" });
|
||||
expect((await getOffer(active.id))?.valid_transitions).toEqual([
|
||||
"ordered",
|
||||
"invalidated",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — editable-state guard (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer editable-state guard (service)", () => {
|
||||
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
|
||||
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
|
||||
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.project_code).toBe("PŮVODNÍ");
|
||||
});
|
||||
|
||||
it("rejects a field edit on an invalidated offer", async () => {
|
||||
const offer = await mkOffer({ status: "invalidated" });
|
||||
const res = await updateOffer(offer.id, { scope_title: "X" });
|
||||
expect("error" in res && res.error).toBe("not_editable");
|
||||
});
|
||||
|
||||
it("still allows the status-only ordered -> invalidated payload", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await updateOffer(offer.id, { status: "invalidated" });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.status).toBe("invalidated");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — customer FK handling (service) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("offer customer FK handling (service)", () => {
|
||||
async function mkCustomer() {
|
||||
const c = await prisma.customers.create({
|
||||
data: { name: "drafts_test_zákazník" },
|
||||
});
|
||||
customerIds.push(c.id);
|
||||
return c;
|
||||
}
|
||||
|
||||
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
|
||||
const res = await createOffer({ customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("update rejects a nonexistent customer with a clean token", async () => {
|
||||
const offer = await mkOffer({});
|
||||
const res = await updateOffer(offer.id, { customer_id: 99999999 });
|
||||
expect("error" in res && res.error).toBe("customer_not_found");
|
||||
});
|
||||
|
||||
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
|
||||
const customer = await mkCustomer();
|
||||
const offer = await mkOffer({ customer_id: customer.id });
|
||||
expect(offer.customer_id).toBe(customer.id);
|
||||
|
||||
const res = await updateOffer(offer.id, { customer_id: null });
|
||||
expect("error" in res).toBe(false);
|
||||
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
|
||||
expect(row!.customer_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — deterministic list ordering */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("listOffers deterministic ordering", () => {
|
||||
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
|
||||
const MARK = `TIEBREAK-${Date.now()}`;
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const o = await mkOffer({ project_code: MARK });
|
||||
ids.push(o.id);
|
||||
}
|
||||
// created_at is second-precision; pin all three to the SAME timestamp so
|
||||
// only the id tiebreak can order them.
|
||||
await prisma.quotations.updateMany({
|
||||
where: { id: { in: ids } },
|
||||
data: { created_at: new Date("2026-01-15T10:00:00") },
|
||||
});
|
||||
|
||||
const base = { page: 1, limit: 10, skip: 0, search: MARK };
|
||||
const desc = await listOffers({
|
||||
...base,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
});
|
||||
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => b - a),
|
||||
);
|
||||
|
||||
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
|
||||
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Offers — route-level hardening (Czech messages + status codes) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
});
|
||||
if (!admin) throw new Error("Test setup: admin user not found");
|
||||
adminToken = jwt.sign(
|
||||
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: "15m" },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
describe("offers routes — hardening (HTTP)", () => {
|
||||
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
|
||||
|
||||
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
|
||||
const offer = await mkOffer({ status: "ordered" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "ZMĚNA" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
|
||||
});
|
||||
|
||||
it("PUT rejects draft -> ordered with the transition message", async () => {
|
||||
const draft = await mkOffer({});
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { status: "ordered" },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe(
|
||||
'Neplatný přechod stavu z "draft" na "ordered"',
|
||||
);
|
||||
});
|
||||
|
||||
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { items: [{ description: "x".repeat(501) }] },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
|
||||
const res = await app!.inject({
|
||||
method: "POST",
|
||||
url: "/api/admin/offers",
|
||||
headers: auth(),
|
||||
payload: { customer_id: 99999999 },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toBe("Zákazník nenalezen");
|
||||
});
|
||||
|
||||
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
|
||||
const offer = await mkOffer({ status: "active" });
|
||||
const order = await prisma.orders.create({
|
||||
data: {
|
||||
order_number: `DRAFTSLINK-${Date.now()}`,
|
||||
quotation_id: offer.id,
|
||||
},
|
||||
});
|
||||
linkedOrderIds.push(order.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/admin/offers/${offer.id}`,
|
||||
headers: auth(),
|
||||
});
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().error).toBe(
|
||||
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
|
||||
);
|
||||
// The offer survived the rejected delete.
|
||||
expect(
|
||||
await prisma.quotations.findUnique({ where: { id: offer.id } }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
|
||||
const draft = await mkOffer({ project_code: "PŘED" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/offers/${draft.id}`,
|
||||
headers: auth(),
|
||||
payload: { project_code: "PO" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const audit = await prisma.audit_logs.findFirst({
|
||||
where: {
|
||||
entity_type: "quotation",
|
||||
entity_id: draft.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!).project_code).toBe("PŘED");
|
||||
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
|
||||
// Unnumbered drafts read "koncept", never "null".
|
||||
expect(audit!.description).toContain("koncept");
|
||||
expect(audit!.description).not.toContain("null");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "jsonwebtoken";
|
||||
import prisma from "../config/database";
|
||||
@@ -19,7 +27,18 @@ import {
|
||||
type IssuedOrderInput,
|
||||
} from "../services/issued-orders.service";
|
||||
import issuedOrdersRoutes from "../routes/admin/issued-orders";
|
||||
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
|
||||
import issuedOrdersPdfRoutes, {
|
||||
renderIssuedOrderHtml,
|
||||
} 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" } });
|
||||
@@ -186,15 +205,18 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
expect("error" in bad && bad.error).toBe("invalid_transition");
|
||||
});
|
||||
|
||||
it("locks items once confirmed", async () => {
|
||||
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" });
|
||||
await updateIssuedOrder(order.id, {
|
||||
// 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 },
|
||||
});
|
||||
@@ -202,6 +224,18 @@ describe("updateIssuedOrder status transitions", () => {
|
||||
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 });
|
||||
@@ -289,6 +323,10 @@ describe("listIssuedOrders month filter", () => {
|
||||
|
||||
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;
|
||||
@@ -310,6 +348,9 @@ beforeAll(async () => {
|
||||
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" } },
|
||||
@@ -320,6 +361,29 @@ beforeAll(async () => {
|
||||
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({
|
||||
@@ -351,6 +415,8 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (lockerUserId)
|
||||
await prisma.users.delete({ where: { id: lockerUserId } }).catch(() => {});
|
||||
if (noPermUserId)
|
||||
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
|
||||
if (noPermRoleId)
|
||||
@@ -415,6 +481,127 @@ describe("POST /api/admin/issued-orders supplier validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: { notes: "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({ notes: "před úpravou" });
|
||||
const res = await app!.inject({
|
||||
method: "PUT",
|
||||
url: `/api/admin/issued-orders/${order.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
payload: { 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!).notes).toBe("před úpravou");
|
||||
expect(JSON.parse(audit!.new_values!).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({ 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!).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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -537,8 +724,8 @@ describe("renderIssuedOrderHtml", () => {
|
||||
// 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).
|
||||
expect(html).toContain('<td class="right total-cell">200,00</td>');
|
||||
// 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).
|
||||
@@ -553,6 +740,26 @@ describe("renderIssuedOrderHtml", () => {
|
||||
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("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
// Footer: Vystavil <name> from authData. No e-mail line.
|
||||
@@ -563,3 +770,448 @@ describe("renderIssuedOrderHtml", () => {
|
||||
expect(html).not.toContain("Schválil:");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 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({
|
||||
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, {
|
||||
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!.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",
|
||||
notes: null,
|
||||
delivery_terms: null,
|
||||
payment_terms: null,
|
||||
issued_by: null,
|
||||
};
|
||||
const items = [
|
||||
{
|
||||
description: "Materiál",
|
||||
item_description: null,
|
||||
quantity: 1,
|
||||
unit: null,
|
||||
unit_price: 100,
|
||||
},
|
||||
];
|
||||
const issuer = { name: "Jan Novák" };
|
||||
|
||||
it("renders the scope page: cs prefers title_cz, sanitizes content, repeats the PO header", () => {
|
||||
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-page"');
|
||||
// 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)");
|
||||
// The PO header block repeats on the scope page: <title> + main page
|
||||
// header + scope page header = 3 occurrences (2 without the scope page).
|
||||
expect(html.split("OBJEDNÁVKA č. 26720001").length - 1).toBe(3);
|
||||
});
|
||||
|
||||
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 whole page 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-page"');
|
||||
});
|
||||
|
||||
it("omits the scope page when there are no sections at all", () => {
|
||||
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
|
||||
expect(html).not.toContain('class="scope-page"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,46 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
|
||||
expect(project?.project_number).not.toBe(order?.order_number);
|
||||
expect(project?.order_id).toBe(orderId);
|
||||
});
|
||||
|
||||
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
|
||||
const draft = await prisma.quotations.create({
|
||||
data: { status: "draft", currency: "CZK", language: "cs" },
|
||||
});
|
||||
createdQuotationIds.push(draft.id);
|
||||
|
||||
const res = await createOrderFromQuotation({ quotationId: draft.id });
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "draft"');
|
||||
}
|
||||
// The offer is untouched — still a numberless draft.
|
||||
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
||||
expect(row!.status).toBe("draft");
|
||||
expect(row!.quotation_number).toBeNull();
|
||||
expect(row!.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
|
||||
const invalidated = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-INVAL-${Date.now()}`,
|
||||
status: "invalidated",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(invalidated.id);
|
||||
|
||||
const res = await createOrderFromQuotation({
|
||||
quotationId: invalidated.id,
|
||||
});
|
||||
expect("error" in res).toBe(true);
|
||||
if ("error" in res) {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.error).toContain('ve stavu "invalidated"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteOrder releases the project number (release regression)", () => {
|
||||
|
||||
@@ -5,6 +5,8 @@ import prisma from "../config/database";
|
||||
import { config } from "../config/env";
|
||||
import { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
|
||||
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||
import { createInvoice } from "../services/invoices.service";
|
||||
|
||||
// Offers, received orders (their confirmation PDF) and issued orders are NOT
|
||||
// tax documents — VAT must never appear on them. These tests pin that contract
|
||||
@@ -73,6 +75,26 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||
);
|
||||
// Grand total stays 200,00 — the excluded line doesn't count.
|
||||
expect(html).toContain("200,00 CZK");
|
||||
});
|
||||
|
||||
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
|
||||
const html = renderOrderConfirmationHtml(
|
||||
order,
|
||||
[
|
||||
{
|
||||
description: "Velká položka",
|
||||
quantity: 2,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
null,
|
||||
"cs",
|
||||
"Jan",
|
||||
);
|
||||
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
|
||||
expect(html).toContain("2 000,00 CZK");
|
||||
expect(html).not.toContain("1 199,00 CZK");
|
||||
});
|
||||
});
|
||||
@@ -84,10 +106,12 @@ describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
|
||||
let app: ReturnType<typeof Fastify> | null = null;
|
||||
let adminToken = "";
|
||||
const createdQuotationIds: number[] = [];
|
||||
const createdInvoiceIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||
|
||||
const admin = await prisma.users.findFirst({
|
||||
where: { roles: { name: "admin" } },
|
||||
@@ -103,6 +127,8 @@ beforeAll(async () => {
|
||||
afterAll(async () => {
|
||||
for (const id of createdQuotationIds)
|
||||
await prisma.quotations.deleteMany({ where: { id } });
|
||||
for (const id of createdInvoiceIds)
|
||||
await prisma.invoices.deleteMany({ where: { id } });
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
@@ -144,4 +170,78 @@ describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
|
||||
expect(html).not.toContain("Mezisoučet");
|
||||
expect(html).not.toContain("Celkem k úhradě");
|
||||
});
|
||||
|
||||
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: {
|
||||
quotation_number: `Q-FRACQTY-${Date.now()}`,
|
||||
status: "active",
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
quotation_items: {
|
||||
create: [
|
||||
{
|
||||
description: "Půldenní sazba",
|
||||
quantity: 1.5,
|
||||
unit: "ks",
|
||||
unit_price: 1000,
|
||||
is_included_in_total: true,
|
||||
position: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
createdQuotationIds.push(quotation.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/offers-pdf/${quotation.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
|
||||
// decimals elsewhere (covered by the previous test's qty 2).
|
||||
expect(html).toContain("1,50 / ks");
|
||||
expect(html).not.toContain(">2 / ks<");
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Invoice PDF route — post-pdf-shared-extraction render marker */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
|
||||
it("still renders the invoice HTML after the shared-helper extraction", async () => {
|
||||
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
|
||||
const invoice = await createInvoice({
|
||||
status: "draft",
|
||||
currency: "CZK",
|
||||
apply_vat: true,
|
||||
vat_rate: 21,
|
||||
items: [
|
||||
{
|
||||
description: "PDF-SHARED-MARKER",
|
||||
quantity: 2,
|
||||
unit_price: 1000,
|
||||
vat_rate: 21,
|
||||
},
|
||||
],
|
||||
});
|
||||
createdInvoiceIds.push(invoice.id);
|
||||
|
||||
const res = await app!.inject({
|
||||
method: "GET",
|
||||
url: `/api/admin/invoices-pdf/${invoice.id}`,
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const html = res.body;
|
||||
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
|
||||
expect(html).toContain("PDF-SHARED-MARKER");
|
||||
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
|
||||
expect(html).toContain("2 000,00");
|
||||
expect(html).toContain("Celkem k úhradě");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user