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