- projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny, terminal->aktivni reopen), tolerant of legacy statuses; getProject returns valid_transitions; updateProject validates transitions (invalid_transition token -> Czech 400 in the route). - NEW project->order cascade, transactional with the project update: finishing/ cancelling a project completes/cancels its linked received order — only while the order is still open (never resurrects storno, never cancels completed); reopen never cascades. Direct tx write, no service recursion; cascaded order change gets its own audit row. - orders.service: reopen edges (dokoncena/stornovana -> v_realizaci); syncProjectStatus is reopen-aware (reopen skips project sync) and returns the cascaded projects for per-project audit rows in the route. - orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana' (every storno PUT through the route 400ed with a raw English Zod message; latent until now because no UI sent it) + route-level regression test. - NEW project-order-status-sync.test.ts: 15 tests — both cascade directions, guards, reopen-no-cascade, legacy tolerance, forward-sync regression. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
309 lines
9.8 KiB
TypeScript
309 lines
9.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("PUT /:id accepts the canonical storno token through the Zod layer", async () => {
|
|
// Regression: UpdateOrderSchema carried a phantom "zrusena" enum member
|
|
// until 2026-07, so { status: "stornovana" } 400ed at parseBody with a
|
|
// raw English Zod message. Service-level tests bypass the schema — this
|
|
// must stay a route-level test.
|
|
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 put = await app.inject({
|
|
method: "PUT",
|
|
url: `/api/admin/orders/${res.id}`,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
payload: { status: "stornovana" },
|
|
});
|
|
expect(put.statusCode).toBe(200);
|
|
const row = await prisma.orders.findUnique({ where: { id: res.id! } });
|
|
expect(row!.status).toBe("stornovana");
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|