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 = {}) { 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 | 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"); }); });