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>
589 lines
22 KiB
TypeScript
589 lines
22 KiB
TypeScript
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,
|
|
previewOfferNumber,
|
|
} from "../services/numbering.service";
|
|
import {
|
|
createIssuedOrder,
|
|
updateIssuedOrder,
|
|
deleteIssuedOrder,
|
|
} from "../services/issued-orders.service";
|
|
import {
|
|
createInvoice,
|
|
updateInvoice,
|
|
deleteInvoice,
|
|
} from "../services/invoices.service";
|
|
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({
|
|
where: { type: { in: ["issued_order", "invoice", "offer"] } },
|
|
});
|
|
});
|
|
|
|
/** 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 */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("issued-order drafts — deferred numbering", () => {
|
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
|
const before = (await previewIssuedOrderNumber()).number;
|
|
const draft = await createIssuedOrder({}); // defaults to draft
|
|
if ("error" in draft)
|
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
|
issuedOrderIds.push(draft.id);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.po_number).toBeNull();
|
|
// Preview is unchanged → the sequence was not consumed.
|
|
const after = (await previewIssuedOrderNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
|
|
const expected = (await previewIssuedOrderNumber()).number;
|
|
const draft = await createIssuedOrder({});
|
|
if ("error" in draft)
|
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
|
issuedOrderIds.push(draft.id);
|
|
|
|
const res = await updateIssuedOrder(draft.id, { status: "sent" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.issued_orders.findUnique({
|
|
where: { id: draft.id },
|
|
});
|
|
expect(row?.status).toBe("sent");
|
|
expect(row?.po_number).toBe(expected);
|
|
expect("po_number" in res && res.po_number).toBe(expected);
|
|
});
|
|
|
|
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
|
|
const draft = await createIssuedOrder({});
|
|
if ("error" in draft)
|
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
|
issuedOrderIds.push(draft.id);
|
|
|
|
await updateIssuedOrder(draft.id, { status: "sent" });
|
|
const first = (
|
|
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
|
)?.po_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "issued_order" },
|
|
});
|
|
|
|
// draft -> sent is no longer valid (already sent); the number must not change.
|
|
// Drive a second finalize-style update that keeps status sent.
|
|
await updateIssuedOrder(draft.id, { status: "sent" });
|
|
const second = (
|
|
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
|
|
)?.po_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "issued_order" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
it("two drafts coexist with null numbers (no unique violation)", async () => {
|
|
const a = await createIssuedOrder({});
|
|
const b = await createIssuedOrder({});
|
|
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
|
|
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
|
|
issuedOrderIds.push(a.id, b.id);
|
|
expect(a.po_number).toBeNull();
|
|
expect(b.po_number).toBeNull();
|
|
});
|
|
|
|
it("deleting a draft releases nothing (no number was consumed)", async () => {
|
|
const before = (await previewIssuedOrderNumber()).number;
|
|
const draft = await createIssuedOrder({});
|
|
if ("error" in draft)
|
|
throw new Error(`createIssuedOrder failed: ${draft.error}`);
|
|
await deleteIssuedOrder(draft.id);
|
|
const after = (await previewIssuedOrderNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Invoices */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("invoice drafts — deferred numbering", () => {
|
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
|
const before = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.invoice_number).toBeNull();
|
|
const after = (await previewInvoiceNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
|
|
const expected = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
|
|
const res = await updateInvoice(draft.id, { status: "issued" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
|
|
expect(row?.status).toBe("issued");
|
|
expect(row?.invoice_number).toBe(expected);
|
|
expect("invoice_number" in res && res.invoice_number).toBe(expected);
|
|
});
|
|
|
|
it("finalizing twice does not re-number / double-consume", async () => {
|
|
const draft = await createInvoice({});
|
|
invoiceIds.push(draft.id);
|
|
|
|
await updateInvoice(draft.id, { status: "issued" });
|
|
const first = (
|
|
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
|
)?.invoice_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "invoice" },
|
|
});
|
|
|
|
// issued -> issued is a no-op status-wise; the number must stay put.
|
|
await updateInvoice(draft.id, { status: "issued" });
|
|
const second = (
|
|
await prisma.invoices.findUnique({ where: { id: draft.id } })
|
|
)?.invoice_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "invoice" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
it("two invoice drafts coexist with null numbers", async () => {
|
|
const a = await createInvoice({});
|
|
const b = await createInvoice({});
|
|
invoiceIds.push(a.id, b.id);
|
|
expect(a.invoice_number).toBeNull();
|
|
expect(b.invoice_number).toBeNull();
|
|
});
|
|
|
|
it("deleting a draft releases nothing", async () => {
|
|
const before = (await previewInvoiceNumber()).number;
|
|
const draft = await createInvoice({});
|
|
await deleteInvoice(draft.id);
|
|
const after = (await previewInvoiceNumber()).number;
|
|
expect(after).toBe(before);
|
|
});
|
|
});
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Offers */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
describe("offer drafts — deferred numbering", () => {
|
|
it("a draft has no number and does NOT consume the sequence", async () => {
|
|
const before = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
expect(draft.status).toBe("draft");
|
|
expect(draft.quotation_number).toBeNull();
|
|
const after = await previewOfferNumber();
|
|
expect(after).toBe(before);
|
|
});
|
|
|
|
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
|
|
const expected = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
|
|
const res = await updateOffer(draft.id, { status: "active" });
|
|
expect("error" in res).toBe(false);
|
|
|
|
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
|
|
expect(row?.status).toBe("active");
|
|
expect(row?.quotation_number).toBe(expected);
|
|
expect("quotation_number" in res && res.quotation_number).toBe(expected);
|
|
});
|
|
|
|
it("finalizing twice does not re-number / double-consume", async () => {
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
offerIds.push(draft.id);
|
|
|
|
await updateOffer(draft.id, { status: "active" });
|
|
const first = (
|
|
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
|
)?.quotation_number;
|
|
const seqAfterFirst = await prisma.number_sequences.findFirst({
|
|
where: { type: "offer" },
|
|
});
|
|
|
|
// active -> active is a no-op; the number must stay put.
|
|
await updateOffer(draft.id, { status: "active" });
|
|
const second = (
|
|
await prisma.quotations.findUnique({ where: { id: draft.id } })
|
|
)?.quotation_number;
|
|
const seqAfterSecond = await prisma.number_sequences.findFirst({
|
|
where: { type: "offer" },
|
|
});
|
|
|
|
expect(second).toBe(first);
|
|
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
|
|
});
|
|
|
|
it("two offer drafts coexist with null numbers", async () => {
|
|
const a = await createOffer({});
|
|
const b = await createOffer({});
|
|
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
|
|
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
|
|
offerIds.push(a.id, b.id);
|
|
expect(a.quotation_number).toBeNull();
|
|
expect(b.quotation_number).toBeNull();
|
|
});
|
|
|
|
it("an offer created already-finalized (status active) numbers immediately", async () => {
|
|
const expected = await previewOfferNumber();
|
|
const offer = await createOffer({ status: "active" });
|
|
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
|
|
offerIds.push(offer.id);
|
|
expect(offer.quotation_number).toBe(expected);
|
|
});
|
|
|
|
it("deleting a draft releases nothing", async () => {
|
|
const before = await previewOfferNumber();
|
|
const draft = await createOffer({});
|
|
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
|
|
await deleteOffer(draft.id);
|
|
const after = await previewOfferNumber();
|
|
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");
|
|
});
|
|
});
|