Files
app/src/__tests__/orders-list.test.ts
BOHA 7398cad466 feat(vat)!: remove VAT entirely from offers, orders and issued orders
Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.

- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
  issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
  dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
  {total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
  single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
  'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
  duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
  extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
  removed from OfferDetail/OrderDetail/IssuedOrderDetail/
  OrderConfirmationModal/ReceivedOrders manual-create; list footers read
  'Celkem bez DPH'; invoice-from-order prefill now takes the company default
  VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
  exact cs+en note text and VAT-free layout on all three PDFs

Invoices and received invoices keep their VAT handling unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:13:16 +02:00

284 lines
8.8 KiB
TypeScript

import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import ordersRoutes from "../routes/admin/orders";
import {
createOrder,
listOrders,
getOrder,
getOrderAttachment,
} from "../services/orders.service";
const createdOrderIds: number[] = [];
const createdCustomerIds: number[] = [];
afterEach(async () => {
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
createdOrderIds.length = 0;
createdCustomerIds.length = 0;
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
exchange_rate: 1,
};
function failResult(res: unknown): never {
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
}
async function makeCustomer() {
const c = await prisma.customers.create({
data: { name: "Odběratel a.s." },
});
createdCustomerIds.push(c.id);
return c;
}
const baseListParams = {
page: 1,
limit: 50,
skip: 0,
sort: "id",
order: "desc" as const,
};
describe("listOrders search filter", () => {
it("returns the order when search matches its order_number", async () => {
const customer = await makeCustomer();
const res = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const list = await listOrders({
...baseListParams,
search: res.order_number!,
});
expect(list.data.map((o) => o.id)).toContain(res.id);
});
it("excludes the order when search matches nothing", async () => {
const customer = await makeCustomer();
const res = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const list = await listOrders({
...baseListParams,
search: "ZZZ-NO-SUCH-ORDER-ZZZ",
});
expect(list.data.map((o) => o.id)).not.toContain(res.id);
});
});
describe("order attachment payload hygiene", () => {
// The PO attachment blob must be served ONLY by GET /:id/attachment —
// list/detail payloads carry attachment_name as the existence signal.
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
const TEST_ADMIN = "orders_list_test_admin";
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
let adminToken: string;
let adminUserId: number;
async function buildOrdersApp() {
const fastify = Fastify({ logger: false });
await fastify.register(cookie);
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
// multipart is registered inside ordersRoutes itself
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
return fastify;
}
beforeAll(async () => {
app = await buildOrdersApp();
// Clean up any leftover test user from a previous failed run
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const adminUser = await prisma.users.create({
data: {
username: TEST_ADMIN,
email: `${TEST_ADMIN}@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Orders",
last_name: "ListTest",
is_active: true,
role_id: adminRole.id,
},
});
adminUserId = adminUser.id;
adminToken = jwt.sign(
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
await prisma.users.deleteMany({ where: { id: adminUserId } });
await app.close();
});
/** One order WITH a stored PO attachment, one WITHOUT. */
async function makeOrderPair() {
const customer = await makeCustomer();
const withRes = await createOrder(
{ ...baseOrder, customer_id: customer.id, create_project: false },
PDF_BYTES,
"po-test.pdf",
);
if (!("id" in withRes)) failResult(withRes);
createdOrderIds.push(withRes.id!);
const withoutRes = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in withoutRes)) failResult(withoutRes);
createdOrderIds.push(withoutRes.id!);
return { withId: withRes.id!, withoutId: withoutRes.id! };
}
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
const { withId, withoutId } = await makeOrderPair();
const list = await listOrders(baseListParams);
const withRow = list.data.find((o) => o.id === withId);
const withoutRow = list.data.find((o) => o.id === withoutId);
expect(withRow).toBeTruthy();
expect(withoutRow).toBeTruthy();
expect("attachment_data" in withRow!).toBe(false);
expect("attachment_data" in withoutRow!).toBe(false);
expect(withRow!.attachment_name).toBe("po-test.pdf");
expect(withoutRow!.attachment_name).toBeNull();
});
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
const { withId, withoutId } = await makeOrderPair();
const withDetail = await getOrder(withId);
const withoutDetail = await getOrder(withoutId);
expect(withDetail).toBeTruthy();
expect(withoutDetail).toBeTruthy();
expect("attachment_data" in withDetail!).toBe(false);
expect("attachment_data" in withoutDetail!).toBe(false);
expect(withDetail!.attachment_name).toBe("po-test.pdf");
expect(withoutDetail!.attachment_name).toBeNull();
});
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
const { withId, withoutId } = await makeOrderPair();
const headers = { authorization: `Bearer ${adminToken}` };
const listRes = await app.inject({
method: "GET",
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
headers,
});
expect(listRes.statusCode).toBe(200);
const listJson = JSON.parse(listRes.body);
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
expect(listRes.body).not.toContain("attachment_data");
const detailRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}`,
headers,
});
expect(detailRes.statusCode).toBe(200);
expect(detailRes.body).not.toContain("attachment_data");
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
const attRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}/attachment`,
headers,
});
expect(attRes.statusCode).toBe(200);
expect(attRes.headers["content-type"]).toContain("application/pdf");
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
const noAttRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withoutId}/attachment`,
headers,
});
expect(noAttRes.statusCode).toBe(404);
});
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
const { withId, withoutId } = await makeOrderPair();
const att = await getOrderAttachment(withId);
expect(att).toBeTruthy();
expect(att!.filename).toBe("po-test.pdf");
expect(att!.data.equals(PDF_BYTES)).toBe(true);
expect(await getOrderAttachment(withoutId)).toBeNull();
});
});
describe("listOrders month filter", () => {
it("returns only orders whose created_at is in the given month", async () => {
const customer = await makeCustomer();
const res = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
// Pin created_at to a known month so the range filter is deterministic.
await prisma.orders.update({
where: { id: res.id },
data: { created_at: new Date(2026, 2, 15, 12, 0, 0) },
});
const inMonth = await listOrders({
...baseListParams,
month: 3,
year: 2026,
});
expect(inMonth.data.map((o) => o.id)).toContain(res.id);
const otherMonth = await listOrders({
...baseListParams,
month: 4,
year: 2026,
});
expect(otherMonth.data.map((o) => o.id)).not.toContain(res.id);
});
});