From b91a1d2185d07bccd5e09fc3e9aa28ca14fa60ce Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:40:17 -0400 Subject: [PATCH] test: add AssetCategoryAttributeDef API tests 28 tests covering lib-level validation/CRUD and route-level HTTP behavior: malformed JSON, non-object body, invalid dataType, categoryId FK violation, duplicate (categoryId, key) rejected with a clean 400 (not 500), and same key allowed across two different categories (the "not globally unique" case), plus auth gating. --- tests/asset-category-attribute-defs.test.ts | 660 ++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 tests/asset-category-attribute-defs.test.ts diff --git a/tests/asset-category-attribute-defs.test.ts b/tests/asset-category-attribute-defs.test.ts new file mode 100644 index 0000000..2a99a5a --- /dev/null +++ b/tests/asset-category-attribute-defs.test.ts @@ -0,0 +1,660 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; +import { + createAssetCategoryAttributeDef, + listAssetCategoryAttributeDefs, + validateCreateAssetCategoryAttributeDefInput, +} from "@/lib/asset-category-attribute-defs"; +import { prisma } from "@/lib/prisma"; + +// Note: this file has two layers of coverage, mirroring +// tests/purchase-orders.test.ts: +// - lib-level tests below exercise src/lib/asset-category-attribute-defs.ts +// directly (validation, FK/unique error handling, list ordering). +// - route-level tests (further down) exercise +// src/app/api/asset-category-attribute-defs/route.ts over HTTP, including +// the auth() gate. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET, POST } from "@/app/api/asset-category-attribute-defs/route"; +import { auth } from "@/auth"; + +const mockedAuth = auth as unknown as Mock<() => Promise>; + +const testSession: Session = { + user: { id: "test-user-id" }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), +}; + +function postRequest(body: unknown): Request { + return new Request("http://localhost/api/asset-category-attribute-defs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// AssetCategoryAttributeDef.categoryId is a real FK to AssetCategory (see +// schema.prisma / src/lib/asset-category-attribute-defs.ts) — tests that +// need valid ids create real rows first and use their ids. Mirrors +// tests/purchase-orders.test.ts's createTestVendor()/createTestEmployee() +// helpers. +function createTestAssetCategory(overrides: { name?: string } = {}) { + return prisma.assetCategory.create({ + data: { + name: overrides.name ?? "Laptop", + valueGroup: "high_value", + }, + }); +} + +beforeEach(async () => { + // Each test starts from empty tables so listing/order/uniqueness + // assertions are deterministic. asset_category_attribute_defs must be + // cleared before asset_categories since it has an FK (ON DELETE RESTRICT) + // to it. + await prisma.assetCategoryAttributeDef.deleteMany(); + await prisma.assetCategory.deleteMany(); + // Default to a logged-in session — the lib-level tests below don't go + // through the route at all (so this is a no-op for them), and the + // route-level tests exercise business logic, not auth gating (that's + // covered by the "auth gating" describe block further down). + mockedAuth.mockResolvedValue(testSession); +}); + +describe("validateCreateAssetCategoryAttributeDefInput", () => { + it("accepts valid input", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toMatchObject({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + } + }); + + it("accepts each of the four fixed dataType values", () => { + for (const dataType of ["string", "number", "boolean", "date"] as const) { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: `key_${dataType}`, + label: "Label", + dataType, + required: false, + sortOrder: 0, + }); + expect(result.ok).toBe(true); + } + }); + + it("rejects a missing categoryId", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/categoryId/i); + } + }); + + it("rejects a categoryId that is not a valid UUID", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "not-a-uuid", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/categoryId/i); + } + }); + + it("rejects an empty key", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/key/i); + } + }); + + it("rejects an empty label", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "", + dataType: "number", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/label/i); + } + }); + + it("rejects an invalid dataType", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "float", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/dataType/i); + } + }); + + it("rejects a missing dataType", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + required: true, + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/dataType/i); + } + }); + + it("rejects a non-boolean required", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: "yes", + sortOrder: 1, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/required/i); + } + }); + + it("rejects a non-integer sortOrder", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1.5, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/sortOrder/i); + } + }); + + it("rejects a missing sortOrder", () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "11111111-1111-1111-1111-111111111111", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toMatch(/sortOrder/i); + } + }); +}); + +describe("createAssetCategoryAttributeDef", () => { + it("creates an attribute def for a real category", async () => { + const category = await createTestAssetCategory(); + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetCategoryAttributeDef(result.value); + + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value).toMatchObject({ + category_id: category.id, + key: "ram_gb", + label: "RAM (GB)", + data_type: "number", + required: true, + sort_order: 1, + }); + expect(typeof created.value.id).toBe("string"); + expect(created.value.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false); + }); + + it("returns a clean error when categoryId doesn't reference an existing category", async () => { + const result = validateCreateAssetCategoryAttributeDefInput({ + categoryId: "00000000-0000-0000-0000-000000000000", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetCategoryAttributeDef(result.value); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.error).toMatch(/categoryId/i); + expect(created.error).toMatch(/does not reference an existing asset category/i); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a duplicate key within the same category with a clean 400-style error", async () => { + const category = await createTestAssetCategory(); + const first = validateCreateAssetCategoryAttributeDefInput({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + expect(first.ok).toBe(true); + if (first.ok) { + const firstCreated = await createAssetCategoryAttributeDef(first.value); + expect(firstCreated.ok).toBe(true); + } + + const second = validateCreateAssetCategoryAttributeDefInput({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB) duplicate", + dataType: "number", + required: false, + sortOrder: 2, + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + + const created = await createAssetCategoryAttributeDef(second.value); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.error).toMatch(/key/i); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(1); + }); + + it("allows the same key across two different categories", async () => { + const laptop = await createTestAssetCategory({ name: "Laptop" }); + const monitor = await createTestAssetCategory({ name: "Màn hình" }); + + const first = validateCreateAssetCategoryAttributeDefInput({ + categoryId: laptop.id, + key: "weight_kg", + label: "Khối lượng (kg)", + dataType: "number", + required: false, + sortOrder: 1, + }); + expect(first.ok).toBe(true); + if (first.ok) { + const firstCreated = await createAssetCategoryAttributeDef(first.value); + expect(firstCreated.ok).toBe(true); + } + + const second = validateCreateAssetCategoryAttributeDefInput({ + categoryId: monitor.id, + key: "weight_kg", + label: "Khối lượng (kg)", + dataType: "number", + required: false, + sortOrder: 1, + }); + expect(second.ok).toBe(true); + if (!second.ok) return; + + const created = await createAssetCategoryAttributeDef(second.value); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.category_id).toBe(monitor.id); + expect(created.value.key).toBe("weight_kg"); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(2); + }); +}); + +describe("listAssetCategoryAttributeDefs", () => { + it("lists created attribute defs, newest first", async () => { + const category = await createTestAssetCategory(); + + const first = validateCreateAssetCategoryAttributeDefInput({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }); + expect(first.ok).toBe(true); + if (first.ok) await createAssetCategoryAttributeDef(first.value); + + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + + const second = validateCreateAssetCategoryAttributeDefInput({ + categoryId: category.id, + key: "cpu", + label: "CPU", + dataType: "string", + required: false, + sortOrder: 2, + }); + expect(second.ok).toBe(true); + if (second.ok) await createAssetCategoryAttributeDef(second.value); + + const records = await listAssetCategoryAttributeDefs(); + + expect(records).toHaveLength(2); + // newest first + expect(records[0]).toMatchObject({ key: "cpu" }); + expect(records[1]).toMatchObject({ key: "ram_gb" }); + }); + + it("returns an empty list when there are no attribute defs", async () => { + const records = await listAssetCategoryAttributeDefs(); + expect(records).toEqual([]); + }); +}); + +describe("POST /api/asset-category-attribute-defs", () => { + it("creates an attribute def on valid input", async () => { + const category = await createTestAssetCategory(); + const res = await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + + expect(body).toMatchObject({ + category_id: category.id, + key: "ram_gb", + label: "RAM (GB)", + data_type: "number", + required: true, + sort_order: 1, + }); + expect(typeof body.id).toBe("string"); + expect(body.id.length).toBeGreaterThan(0); + expect(Number.isNaN(Date.parse(body.created_at))).toBe(false); + }); + + it("rejects an invalid dataType", async () => { + const category = await createTestAssetCategory(); + const res = await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "float", + required: true, + sortOrder: 1, + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/dataType/i); + }); + + it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => { + const res = await POST( + postRequest({ + categoryId: "00000000-0000-0000-0000-000000000000", + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/categoryId/i); + expect(body.error).toMatch(/does not reference an existing asset category/i); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a duplicate (categoryId, key) with a clean 400, not a raw 500", async () => { + const category = await createTestAssetCategory(); + const first = await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }) + ); + expect(first.status).toBe(201); + + const second = await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB) duplicate", + dataType: "number", + required: false, + sortOrder: 2, + }) + ); + + expect(second.status).toBe(400); + const body = await second.json(); + expect(body.error).toMatch(/key/i); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(1); + }); + + it("allows the same key across two different categories via the route", async () => { + const laptop = await createTestAssetCategory({ name: "Laptop" }); + const monitor = await createTestAssetCategory({ name: "Màn hình" }); + + const first = await POST( + postRequest({ + categoryId: laptop.id, + key: "weight_kg", + label: "Khối lượng (kg)", + dataType: "number", + required: false, + sortOrder: 1, + }) + ); + expect(first.status).toBe(201); + + const second = await POST( + postRequest({ + categoryId: monitor.id, + key: "weight_kg", + label: "Khối lượng (kg)", + dataType: "number", + required: false, + sortOrder: 1, + }) + ); + expect(second.status).toBe(201); + + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(2); + }); + + it("rejects an invalid JSON body", async () => { + const req = new Request("http://localhost/api/asset-category-attribute-defs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not json", + }); + + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON/i); + }); + + it("rejects a non-object JSON body", async () => { + // Note: arrays are `typeof "object"` in JS, so they pass the route's + // object check (same behavior as /api/asset-categories) — use a JSON + // string primitive here to actually exercise the "not an object" branch. + const res = await POST(postRequest("not an object")); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/JSON object/i); + }); +}); + +describe("GET /api/asset-category-attribute-defs", () => { + it("lists created attribute defs, newest first", async () => { + const category = await createTestAssetCategory(); + await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }) + ); + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + await POST( + postRequest({ + categoryId: category.id, + key: "cpu", + label: "CPU", + dataType: "string", + required: false, + sortOrder: 2, + }) + ); + + const res = await GET(); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(2); + + // newest first + expect(body[0]).toMatchObject({ key: "cpu" }); + expect(body[1]).toMatchObject({ key: "ram_gb" }); + }); + + it("returns an empty list when there are no attribute defs", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("auth gating on /api/asset-category-attribute-defs", () => { + // Mirrors tests/asset-categories.test.ts's "auth gating" block — only + // checks session presence, per the flat permission model (no + // role/permission logic). + it("POST /api/asset-category-attribute-defs returns 401 without a session", async () => { + const category = await createTestAssetCategory(); + mockedAuth.mockResolvedValue(null); + + const res = await POST( + postRequest({ + categoryId: category.id, + key: "ram_gb", + label: "RAM (GB)", + dataType: "number", + required: true, + sortOrder: 1, + }) + ); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + + // And it must not have created anything. + const remaining = await prisma.assetCategoryAttributeDef.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("GET /api/asset-category-attribute-defs returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await GET(); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + }); +});