import { describe, it, expect, afterEach } from "vitest"; import prisma from "../config/database"; import { createProject } from "../services/projects.service"; import { createOrder, createOrderFromQuotation, deleteOrder, } from "../services/orders.service"; import { previewProjectNumber } from "../services/numbering.service"; const YEAR = new Date().getFullYear(); const createdProjectIds: number[] = []; const createdOrderIds: number[] = []; const createdQuotationIds: number[] = []; afterEach(async () => { for (const id of createdProjectIds) await prisma.projects.deleteMany({ where: { id } }); for (const id of createdOrderIds) await prisma.orders.deleteMany({ where: { id } }); for (const id of createdQuotationIds) await prisma.quotations.deleteMany({ where: { id } }); createdProjectIds.length = 0; createdOrderIds.length = 0; createdQuotationIds.length = 0; // Orders and projects now draw from SEPARATE sequences — reset both. await prisma.number_sequences.deleteMany({ where: { type: { in: ["shared", "project"] } }, }); }); /** * Read the current `last_number` of a (type, current-year) sequence row, or 0 * when the row doesn't exist yet (the first allocation creates it at 1). Used to * prove each generator advances its OWN row by exactly 1. */ async function seqLastNumber(type: "shared" | "project"): Promise { const row = await prisma.number_sequences.findFirst({ where: { type, year: YEAR }, select: { last_number: true }, }); return row?.last_number ?? 0; } const baseOrder = { status: "prijata" as const, currency: "CZK", language: "cs", exchange_rate: 1, }; /** * Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently * passed the test when a create failed) while still narrowing the union via the * `in` operator so the success-branch fields stay typed. */ function failResult(res: unknown): never { throw new Error(`expected a success result, got ${JSON.stringify(res)}`); } describe("createOrder (manual, optional linked project)", () => { it("creates an order AND a project, each with its OWN number, linked by FK", async () => { // Capture BOTH sequence counters before the create so we can prove each // generator advanced its OWN row (not that the project consumed the shared // counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe` // pass even if the project drew from `shared` — the deltas catch that. const sharedBefore = await seqLastNumber("shared"); const projectBefore = await seqLastNumber("project"); const res = await createOrder({ ...baseOrder, scope_title: "Ruční zakázka", create_project: true, }); if (!("id" in res)) failResult(res); createdOrderIds.push(res.id!); expect(res.project_id).toBeTruthy(); const project = await prisma.projects.findUnique({ where: { id: res.project_id! }, }); if (project) createdProjectIds.push(project.id); // The auto-created project now draws from the dedicated project sequence, // so its number is distinct from the order's shared number. They relate // only via the order_id FK, not by sharing a number. expect(project?.project_number).toBeTruthy(); expect(project?.project_number).not.toBe(res.order_number); expect(project?.order_id).toBe(res.id); // Sequence isolation: the order consumed exactly one `shared` number and // the project consumed exactly one `project` number — each generator // advanced its OWN row by 1. A regression where the project draws from the // `shared` counter would bump `shared` by 2 and leave `project` at 0. const sharedAfter = await seqLastNumber("shared"); const projectAfter = await seqLastNumber("project"); expect(sharedAfter - sharedBefore).toBe(1); expect(projectAfter - projectBefore).toBe(1); }); it("creates only the order when create_project=false", async () => { const res = await createOrder({ ...baseOrder, create_project: false }); if (!("id" in res)) failResult(res); createdOrderIds.push(res.id!); expect(res.project_id).toBeUndefined(); }); }); describe("createOrderFromQuotation (auto-project gets its OWN number)", () => { it("links a project whose number is from the project sequence, distinct from the order number", async () => { // Seed a minimal quotation (no items/sections needed — createOrderFrom // Quotation copies whatever is present and both arrays may be empty). const quotation = await prisma.quotations.create({ data: { quotation_number: `Q-FROMQ-${Date.now()}`, status: "active", currency: "CZK", language: "cs", }, }); createdQuotationIds.push(quotation.id); const res = await createOrderFromQuotation({ quotationId: quotation.id, customerOrderNumber: "PO-FROMQ", }); if (!("data" in res) || !res.data) failResult(res); const orderId = res.data.order_id; createdOrderIds.push(orderId); const order = await prisma.orders.findUnique({ where: { id: orderId } }); const project = await prisma.projects.findFirst({ where: { order_id: orderId }, }); if (project) createdProjectIds.push(project.id); // Primary flow: the auto-created project must get a non-empty number from // the dedicated `project` sequence (code 73), DISTINCT from the order's // shared number (code 71). Pre-split they shared one number. expect(project).toBeTruthy(); expect(project?.project_number).toBeTruthy(); expect(order?.order_number).toBeTruthy(); 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 reverts the source offer (stuck-ordered regression)", () => { it("returns the linked quotation to active with order_id cleared", async () => { const quotation = await prisma.quotations.create({ data: { quotation_number: `Q-REVERT-${Date.now()}`, status: "active", currency: "CZK", language: "cs", }, }); createdQuotationIds.push(quotation.id); const res = await createOrderFromQuotation({ quotationId: quotation.id }); if (!("data" in res) || !res.data) failResult(res); const project = await prisma.projects.findFirst({ where: { order_id: res.data.order_id }, }); if (project) createdProjectIds.push(project.id); const afterCreate = await prisma.quotations.findUnique({ where: { id: quotation.id }, }); expect(afterCreate!.status).toBe("ordered"); expect(afterCreate!.order_id).toBe(res.data.order_id); const del = await deleteOrder(res.data.order_id); if ("error" in del) failResult(del); // The offer must be orderable again — `ordered` with no order_id is a // dead end (the transition table only allows ordered -> invalidated). const afterDelete = await prisma.quotations.findUnique({ where: { id: quotation.id }, }); expect(afterDelete!.order_id).toBeNull(); expect(afterDelete!.status).toBe("active"); }); }); describe("deleteOrder releases the project number (release regression)", () => { it("frees the auto-created project's number back to the project sequence", async () => { // The project sequence is reset in afterEach, so the preview before the // create is the number createOrder will consume for the project. const previewBefore = await previewProjectNumber(); const res = await createOrder({ ...baseOrder, scope_title: "Smazatelná zakázka", create_project: true, }); if (!("id" in res)) failResult(res); const orderId = res.id!; expect(res.project_id).toBeTruthy(); const project = await prisma.projects.findUnique({ where: { id: res.project_id! }, }); expect(project?.project_number).toBe(previewBefore); // After the order delete, the project row is gone AND its number must have // been released (decremented) so the preview returns the freed number — not // a permanent gap. This is the fix #1 regression guard. const del = await deleteOrder(orderId); if ("error" in del) failResult(del); const gone = await prisma.projects.findUnique({ where: { id: res.project_id! }, }); expect(gone).toBeNull(); const previewAfter = await previewProjectNumber(); expect(previewAfter).toBe(previewBefore); }); }); describe("separate order vs project sequences (coherence)", () => { it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => { const o1 = await createOrder({ ...baseOrder, create_project: true }); if (!("id" in o1)) failResult(o1); createdOrderIds.push(o1.id!); if (o1.project_id) createdProjectIds.push(o1.project_id); const p = await createProject({ name: "Mezi-projekt", status: "aktivni" }); if (!("project_number" in p)) failResult(p); createdProjectIds.push(p.id); const o2 = await createOrder({ ...baseOrder, create_project: false }); if (!("id" in o2)) failResult(o2); createdOrderIds.push(o2.id!); // All three numbers must be present (non-null) AND distinct. Orders draw // from the `shared` sequence (code 71); projects from the dedicated // `project` sequence (code 73), so they never collide. expect(o1.order_number).toBeTruthy(); expect(p.project_number).toBeTruthy(); expect(o2.order_number).toBeTruthy(); expect( new Set([o1.order_number, p.project_number, o2.order_number]).size, ).toBe(3); }); }); describe("createProject (manual, standalone)", () => { it("assigns a project number and is not linked to an order", async () => { const result = await createProject({ name: "Test projekt", status: "aktivni", }); if (!("project_number" in result)) failResult(result); createdProjectIds.push(result.id); expect(typeof result.project_number).toBe("string"); expect(result.project_number!.length).toBeGreaterThan(0); expect(result.order_id).toBeNull(); }); it("gives consecutive standalone projects distinct numbers", async () => { const a = await createProject({ name: "Projekt A", status: "aktivni" }); const b = await createProject({ name: "Projekt B", status: "aktivni" }); if ("project_number" in a) createdProjectIds.push(a.id); if ("project_number" in b) createdProjectIds.push(b.id); expect("project_number" in a && "project_number" in b).toBe(true); if ("project_number" in a && "project_number" in b) { expect(a.project_number).not.toBe(b.project_number); } }); }); describe("createOrder with price line item + attachment", () => { it("stores a single line item with the entered price", async () => { const res = await createOrder({ ...baseOrder, create_project: false, items: [ { description: "Práce", quantity: 1, unit_price: 12345, is_included_in_total: true, }, ], }); if (!("id" in res)) failResult(res); createdOrderIds.push(res.id!); const items = await prisma.order_items.findMany({ where: { order_id: res.id }, }); expect(items.length).toBe(1); expect(Number(items[0].unit_price)).toBe(12345); }); it("stores an uploaded PO attachment with the exact bytes", async () => { const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí"); const res = await createOrder( { ...baseOrder, create_project: false }, buf, "po.pdf", ); if (!("id" in res)) failResult(res); createdOrderIds.push(res.id!); const order = await prisma.orders.findUnique({ where: { id: res.id }, select: { attachment_name: true, attachment_data: true }, }); expect(order?.attachment_name).toBe("po.pdf"); // Verify the stored content/size, not just truthiness: round-trip the // bytes and compare length + value against what we uploaded. expect(order?.attachment_data).toBeTruthy(); const stored = Buffer.from(order!.attachment_data!); expect(stored.length).toBe(buf.length); expect(stored.equals(buf)).toBe(true); }); });