From f7b9566b04d90208c44262d1d6515e676865347c Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:38:06 -0400 Subject: [PATCH 1/4] feat: add AssetCategoryAttributeDef Prisma model + migration Per-category dynamic attribute definitions (key/label/dataType/required/ sortOrder), FK to AssetCategory, composite unique (categoryId, key) so the same key is allowed across different categories but not duplicated within one. dataType is a new closed Prisma enum (AssetAttributeDataType), a dev-defined technical set distinct from AssetCategoryValueGroup. --- .../migration.sql | 22 ++++++++ prisma/schema.prisma | 56 +++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql diff --git a/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql b/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql new file mode 100644 index 0000000..cf3657e --- /dev/null +++ b/prisma/migrations/20260803003747_add_asset_category_attribute_def/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "AssetAttributeDataType" AS ENUM ('string', 'number', 'boolean', 'date'); + +-- CreateTable +CREATE TABLE "asset_category_attribute_defs" ( + "id" UUID NOT NULL, + "category_id" UUID NOT NULL, + "key" TEXT NOT NULL, + "label" TEXT NOT NULL, + "data_type" "AssetAttributeDataType" NOT NULL, + "required" BOOLEAN NOT NULL, + "sort_order" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "asset_category_attribute_defs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "asset_category_attribute_defs_category_id_key_key" ON "asset_category_attribute_defs"("category_id", "key"); + +-- AddForeignKey +ALTER TABLE "asset_category_attribute_defs" ADD CONSTRAINT "asset_category_attribute_defs_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ef20bf..e5f45f3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -237,14 +237,62 @@ enum AssetCategoryValueGroup { // (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value // set. model AssetCategory { - id String @id @default(uuid()) @db.Uuid - name String - valueGroup AssetCategoryValueGroup @map("value_group") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.Uuid + name String + valueGroup AssetCategoryValueGroup @map("value_group") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + attributeDefs AssetCategoryAttributeDef[] @@map("asset_categories") } +// Fixed, closed set of 4 values — a DEV-defined technical set (which Zod +// validator to build dynamically for AssetModel.specs at write time: string +// -> z.string(), number -> z.number(), boolean -> z.boolean(), date -> +// z.coerce.date()). Deliberately a different kind of "fixed set" than +// AssetCategoryValueGroup above: that one is a closed business classification +// IT admins never extend; this one is a closed set of *primitive types* the +// app's dynamic-attribute machinery knows how to handle. Still modeled as a +// native Postgres enum per the same codebase convention (RequestType / +// AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String + +// hand-rolled check. +enum AssetAttributeDataType { + string + number + boolean + date +} + +// Per-category dynamic attribute definitions — see docs/ +// asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm +// AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row +// in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a +// category cares about (e.g. category "Laptop" -> key `ram_gb`, label +// "RAM (GB)", dataType number); the actual *values* live in +// AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns +// its own independent set of attribute defs — deliberately NOT a shared +// attribute library across categories (stakeholder explicitly rejected that +// design to keep things simple at current scale), so the same `key` string +// existing under two different categoryId rows is normal, not a collision — +// only unique *within* a category, hence the composite unique below (not a +// plain @unique on key alone). `sortOrder` is the display order on the +// dynamic form, no uniqueness constraint of its own (ties/gaps are fine, +// it's just a sort key). +model AssetCategoryAttributeDef { + id String @id @default(uuid()) @db.Uuid + categoryId String @map("category_id") @db.Uuid + category AssetCategory @relation(fields: [categoryId], references: [id]) + key String + label String + dataType AssetAttributeDataType @map("data_type") + required Boolean + sortOrder Int @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@unique([categoryId, key]) + @@map("asset_category_attribute_defs") +} + // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. From 123cbce2bc099ffa10889b2e9ef1b64527c26e5d Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:38:41 -0400 Subject: [PATCH 2/4] feat: add AssetCategoryAttributeDef validation + CRUD (src/lib/asset-category-attribute-defs.ts) Zod schema + validate function, Prisma-backed CRUD following the {ok,value}|{ok,error} pattern from the start. createAssetCategoryAttributeDef() catches P2002 for the composite (categoryId, key) unique constraint (mirrors createAssetItem()'s serialNumber handling) and P2003 for the categoryId FK (mirrors createPurchaseOrder()). --- src/lib/asset-category-attribute-defs.ts | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/lib/asset-category-attribute-defs.ts diff --git a/src/lib/asset-category-attribute-defs.ts b/src/lib/asset-category-attribute-defs.ts new file mode 100644 index 0000000..fce942d --- /dev/null +++ b/src/lib/asset-category-attribute-defs.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export type AssetAttributeDataType = "string" | "number" | "boolean" | "date"; + +export interface AssetCategoryAttributeDefRecord { + id: string; + category_id: string; + key: string; + label: string; + data_type: AssetAttributeDataType; + required: boolean; + sort_order: number; + created_at: string; +} + +export interface CreateAssetCategoryAttributeDefInput { + categoryId: string; + key: string; + label: string; + dataType: AssetAttributeDataType; + required: boolean; + sortOrder: number; +} + +export type ValidationResult = + | { ok: true; value: CreateAssetCategoryAttributeDefInput } + | { ok: false; error: string }; + +// Mirrors src/lib/asset-categories.ts's nonEmptyString helper. +function nonEmptyString(message: string) { + return z.string({ required_error: message, invalid_type_error: message }).refine( + (value) => value.trim() !== "", + { message } + ); +} + +const createAssetCategoryAttributeDefSchema = z.object({ + // FK to AssetCategory — only a UUID *format* check here, whether it + // references a real category is left to Prisma's own FK constraint at + // insert time (see createAssetCategoryAttributeDef()'s P2003 handling + // below). Mirrors src/lib/purchase-orders.ts's vendorId handling. + categoryId: z + .string({ required_error: "categoryId is required and must be a valid UUID string" }) + .uuid("categoryId is required and must be a valid UUID string"), + // Slug-style, e.g. "ram_gb" — only unique *within* a category (composite + // unique (categoryId, key) at the DB layer), not globally. Just requires + // non-empty here; the DB-level uniqueness is enforced by + // createAssetCategoryAttributeDef()'s P2002 handling below. + key: nonEmptyString("key is required and must be a non-empty string"), + label: nonEmptyString("label is required and must be a non-empty string"), + // Fixed 4-value set (see docs/asset-license-management.md's "Cập nhật + // 2026-08-02 (3)" section) — a DEV-defined closed technical set, mirrors + // src/lib/asset-categories.ts's valueGroup z.enum() pattern. + dataType: z.enum(["string", "number", "boolean", "date"], { + errorMap: () => ({ + message: "dataType is required and must be one of 'string', 'number', 'boolean', 'date'", + }), + }), + required: z.boolean({ + required_error: "required is required and must be a boolean", + invalid_type_error: "required is required and must be a boolean", + }), + sortOrder: z.number({ + required_error: "sortOrder is required and must be an integer", + invalid_type_error: "sortOrder is required and must be an integer", + }) + .int("sortOrder is required and must be an integer"), +}); + +/** + * Validates raw (untyped) asset-category-attribute-def-creation input. + * categoryId must be a valid UUID string, key/label must be non-empty + * strings, dataType must be one of the four fixed enum values, required + * must be a boolean, sortOrder must be an integer. Whether categoryId + * references a real AssetCategory, and whether (categoryId, key) is + * actually unique, are both left to Prisma's constraints at insert time + * (see createAssetCategoryAttributeDef() below). + */ +export function validateCreateAssetCategoryAttributeDefInput(input: { + categoryId?: unknown; + key?: unknown; + label?: unknown; + dataType?: unknown; + required?: unknown; + sortOrder?: unknown; +}): ValidationResult { + const result = createAssetCategoryAttributeDefSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues[0]?.message ?? "Invalid input"; + return { ok: false, error: message }; + } + return { ok: true, value: result.data }; +} + +function toRecord(row: { + id: string; + categoryId: string; + key: string; + label: string; + dataType: AssetAttributeDataType; + required: boolean; + sortOrder: number; + createdAt: Date; +}): AssetCategoryAttributeDefRecord { + return { + id: row.id, + category_id: row.categoryId, + key: row.key, + label: row.label, + data_type: row.dataType, + required: row.required, + sort_order: row.sortOrder, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreateAssetCategoryAttributeDefResult = + | { ok: true; value: AssetCategoryAttributeDefRecord } + | { ok: false; error: string }; + +/** + * Creates an AssetCategoryAttributeDef. If (categoryId, key) collides with + * an existing row, Prisma raises P2002 (composite unique constraint + * violation) — caught here and translated into a clean {ok:false,error}, + * same pattern as createAssetItem()'s serialNumber P2002 handling in + * src/lib/assets.ts. Note this is a *composite* uniqueness (unique within a + * category, not globally) — the same `key` under a *different* categoryId + * is expected to succeed, per docs/asset-license-management.md's "Cập nhật + * 2026-08-02 (3)" section. If categoryId doesn't reference an existing + * AssetCategory, Prisma raises P2003 (FK constraint violation) — caught the + * same way, mirrors src/lib/purchase-orders.ts's createPurchaseOrder() P2003 + * handling. + */ +export async function createAssetCategoryAttributeDef( + input: CreateAssetCategoryAttributeDefInput +): Promise { + try { + const created = await prisma.assetCategoryAttributeDef.create({ + data: { + categoryId: input.categoryId, + key: input.key, + label: input.label, + dataType: input.dataType, + required: input.required, + sortOrder: input.sortOrder, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return { ok: false, error: "key already exists for this category" }; + } + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "categoryId does not reference an existing asset category" }; + } + throw error; + } +} + +export async function listAssetCategoryAttributeDefs(): Promise< + AssetCategoryAttributeDefRecord[] +> { + const rows = await prisma.assetCategoryAttributeDef.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} From 59425a159b2fa06863dc49eb2acc947493680a5a Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:38:46 -0400 Subject: [PATCH 3/4] feat: add AssetCategoryAttributeDef API routes POST/GET /api/asset-category-attribute-defs, auth-gated, thin route mirroring src/app/api/asset-categories/route.ts. --- .../asset-category-attribute-defs/route.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/app/api/asset-category-attribute-defs/route.ts diff --git a/src/app/api/asset-category-attribute-defs/route.ts b/src/app/api/asset-category-attribute-defs/route.ts new file mode 100644 index 0000000..297ea31 --- /dev/null +++ b/src/app/api/asset-category-attribute-defs/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { + createAssetCategoryAttributeDef, + listAssetCategoryAttributeDefs, + validateCreateAssetCategoryAttributeDefInput, +} from "@/lib/asset-category-attribute-defs"; + +// Mirrors src/app/api/asset-categories/route.ts's pattern — middleware.ts +// excludes /api from route protection, so this checks the session itself. +// Flat permission model (CLAUDE.md): only "logged in or not", no role check. +export async function POST(request: Request): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + + if (typeof body !== "object" || body === null) { + return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); + } + + const { categoryId, key, label, dataType, required, sortOrder } = + body as Record; + const validation = validateCreateAssetCategoryAttributeDefInput({ + categoryId, + key, + label, + dataType, + required, + sortOrder, + }); + + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createAssetCategoryAttributeDef(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} + +export async function GET(): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const assetCategoryAttributeDefs = await listAssetCategoryAttributeDefs(); + return NextResponse.json(assetCategoryAttributeDefs, { status: 200 }); +} From b91a1d2185d07bccd5e09fc3e9aa28ca14fa60ce Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:40:17 -0400 Subject: [PATCH 4/4] 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" }); + }); +});