Compare commits
4
Commits
065c8cb59e
...
b91a1d2185
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b91a1d2185 | ||
|
|
59425a159b | ||
|
|
123cbce2bc | ||
|
|
f7b9566b04 |
@@ -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;
|
||||||
@@ -241,10 +241,58 @@ model AssetCategory {
|
|||||||
name String
|
name String
|
||||||
valueGroup AssetCategoryValueGroup @map("value_group")
|
valueGroup AssetCategoryValueGroup @map("value_group")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
attributeDefs AssetCategoryAttributeDef[]
|
||||||
|
|
||||||
@@map("asset_categories")
|
@@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,
|
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||||
|
|||||||
@@ -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<NextResponse> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetCategoryAttributeDefs = await listAssetCategoryAttributeDefs();
|
||||||
|
return NextResponse.json(assetCategoryAttributeDefs, { status: 200 });
|
||||||
|
}
|
||||||
@@ -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<CreateAssetCategoryAttributeDefResult> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<Session | null>>;
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user