diff --git a/src/lib/asset-models.ts b/src/lib/asset-models.ts index 5b5e2e0..ccd7f7c 100644 --- a/src/lib/asset-models.ts +++ b/src/lib/asset-models.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { Prisma } from "@prisma/client"; +import type { AssetAttributeDataType } from "./asset-category-attribute-defs"; import { prisma } from "./prisma"; export interface AssetModelRecord { @@ -42,12 +43,14 @@ const createAssetModelSchema = z.object({ .uuid("categoryId is required and must be a valid UUID string"), manufacturer: nonEmptyString("manufacturer is required and must be a non-empty string"), name: nonEmptyString("name is required and must be a non-empty string"), - // Deliberately LOOSE for this task — just "a plain JSON object" (no - // arrays, no primitives at the top level). Does NOT validate keys/types - // dynamically against this model's category's AssetCategoryAttributeDef - // rows — that's a separate follow-up task per docs/ - // asset-license-management.md's "Cập nhật 2026-08-02 (3)" section, out of - // scope here. Defaults to {} when omitted, matching the DB column default. + // Deliberately LOOSE at this stage — just "a plain JSON object" (no + // arrays, no primitives at the top level). The category-specific dynamic + // shape (which keys are allowed, which are required, and their types) is + // validated separately in createAssetModel() below, via a runtime Zod + // schema built from that category's AssetCategoryAttributeDef rows — it + // can't be done here because that requires a DB lookup keyed on + // categoryId, which this pure-input-shape schema doesn't have access to. + // Defaults to {} when omitted, matching the DB column default. specs: z .record(z.unknown(), { invalid_type_error: "specs must be a JSON object", @@ -95,6 +98,43 @@ function toRecord(row: { }; } +// Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf +// type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)" +// section: string -> z.string(), number -> z.number(), boolean -> +// z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date +// *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type). +function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny { + switch (dataType) { + case "string": + return z.string(); + case "number": + return z.number(); + case "boolean": + return z.boolean(); + case "date": + return z.coerce.date(); + } +} + +/** + * Builds a Zod object schema AT RUNTIME from a category's + * AssetCategoryAttributeDef rows: one shape entry per attribute def (typed + * per zodTypeForDataType() above), optional when `required` is false via + * `.optional()`, and `.strict()` on the overall object so any `specs` key + * not defined for this category is rejected (no unknown keys allowed), per + * the doc section referenced above. + */ +function buildSpecsSchema( + attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[] +): z.ZodTypeAny { + const shape: Record = {}; + for (const def of attributeDefs) { + const leaf = zodTypeForDataType(def.dataType); + shape[def.key] = def.required ? leaf : leaf.optional(); + } + return z.object(shape).strict(); +} + export type CreateAssetModelResult = | { ok: true; value: AssetModelRecord } | { ok: false; error: string }; @@ -129,6 +169,27 @@ export async function createAssetModel( return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" }; } + // Dynamic specs validation, per docs/asset-license-management.md's + // "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from + // this category's AssetCategoryAttributeDef rows, and validate the + // incoming specs object against it. Deliberately done here (after the + // category existence/high_value checks above), not inside + // validateCreateAssetModelInput()'s static schema, because it needs a DB + // lookup keyed on categoryId. + const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({ + where: { categoryId: input.categoryId }, + }); + const specsSchema = buildSpecsSchema(attributeDefs); + const specsResult = specsSchema.safeParse(input.specs); + if (!specsResult.success) { + const message = specsResult.error.issues[0]?.message ?? "Invalid specs"; + const path = specsResult.error.issues[0]?.path.join("."); + return { + ok: false, + error: path ? `specs.${path}: ${message}` : `specs: ${message}`, + }; + } + const created = await prisma.assetModel.create({ data: { categoryId: input.categoryId, diff --git a/tests/asset-models.test.ts b/tests/asset-models.test.ts index 463e30b..a364a11 100644 --- a/tests/asset-models.test.ts +++ b/tests/asset-models.test.ts @@ -52,11 +52,40 @@ function createTestAssetCategory( }); } +// Helper for the dynamic-specs-validation tests below: creates an +// AssetCategoryAttributeDef row directly via Prisma (no need to go through +// src/lib/asset-category-attribute-defs.ts's validation for test setup). +// Mirrors tests/asset-category-attribute-defs.test.ts's +// createTestAssetCategory() helper pattern. +function createTestAttributeDef(overrides: { + categoryId: string; + key: string; + label?: string; + dataType: "string" | "number" | "boolean" | "date"; + required: boolean; + sortOrder?: number; +}) { + return prisma.assetCategoryAttributeDef.create({ + data: { + categoryId: overrides.categoryId, + key: overrides.key, + label: overrides.label ?? overrides.key, + dataType: overrides.dataType, + required: overrides.required, + sortOrder: overrides.sortOrder ?? 0, + }, + }); +} + beforeEach(async () => { // Each test starts from empty tables so listing/order assertions are // deterministic. asset_models must be cleared before asset_categories - // since it has an FK (ON DELETE RESTRICT) to it. + // since it has an FK (ON DELETE RESTRICT) to it. asset_category_attribute_defs + // also has an FK (ON DELETE RESTRICT) to asset_categories, so it must be + // cleared before asset_categories too — mirrors tests/ + // asset-category-attribute-defs.test.ts's cleanup order. await prisma.assetModel.deleteMany(); + 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 @@ -181,6 +210,18 @@ describe("validateCreateAssetModelInput", () => { describe("createAssetModel", () => { it("creates a model for a real high_value category, with a non-trivial specs object", async () => { const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + await createTestAttributeDef({ + categoryId: category.id, + key: "cpu", + dataType: "string", + required: true, + }); const result = validateCreateAssetModelInput({ categoryId: category.id, manufacturer: "Dell", @@ -266,6 +307,184 @@ describe("createAssetModel", () => { }); }); +// Dynamic specs validation, per docs/asset-license-management.md's "Cập +// nhật 2026-08-02 (3)" section: createAssetModel() builds a Zod schema at +// runtime from the category's AssetCategoryAttributeDef rows and validates +// `specs` against it — unknown keys rejected, required keys enforced, and +// each key's value type-checked per its dataType. +describe("createAssetModel: dynamic specs validation", () => { + it("accepts specs that supply all required keys with correct types", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + await createTestAttributeDef({ + categoryId: category.id, + key: "cpu", + dataType: "string", + required: true, + }); + await createTestAttributeDef({ + categoryId: category.id, + key: "has_touchscreen", + dataType: "boolean", + required: true, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: { ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.specs).toEqual({ ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false }); + }); + + it("rejects specs missing a required key with a clean 400-style error", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: {}, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.error).toMatch(/ram_gb/i); + + const remaining = await prisma.assetModel.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a specs value of the wrong type for its dataType (string where number is expected)", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: { ram_gb: "16" }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.error).toMatch(/ram_gb/i); + + const remaining = await prisma.assetModel.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("rejects a specs key not defined as an AssetCategoryAttributeDef for that category", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: { ram_gb: 16, unknown_key: "surprise" }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(false); + if (created.ok) return; + expect(created.error).toMatch(/specs/i); + + const remaining = await prisma.assetModel.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("accepts specs that omit an optional (required=false) attribute def key", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + await createTestAttributeDef({ + categoryId: category.id, + key: "weight_kg", + dataType: "number", + required: false, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: { ram_gb: 16 }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.specs).toEqual({ ram_gb: 16 }); + }); + + it("accepts a valid date string for a date-typed attribute def", async () => { + const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "warranty_end", + dataType: "date", + required: true, + }); + + const result = validateCreateAssetModelInput({ + categoryId: category.id, + manufacturer: "Dell", + name: "Latitude 5420", + specs: { warranty_end: "2027-01-01" }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const created = await createAssetModel(result.value); + expect(created.ok).toBe(true); + if (!created.ok) return; + expect(created.value.specs).toEqual({ warranty_end: "2027-01-01" }); + }); +}); + describe("listAssetModels", () => { it("lists created models, newest first", async () => { const category = await createTestAssetCategory({ valueGroup: "high_value" }); @@ -306,6 +525,18 @@ describe("listAssetModels", () => { describe("POST /api/asset-models", () => { it("creates a model on valid input", async () => { const category = await createTestAssetCategory({ valueGroup: "high_value" }); + await createTestAttributeDef({ + categoryId: category.id, + key: "ram_gb", + dataType: "number", + required: true, + }); + await createTestAttributeDef({ + categoryId: category.id, + key: "cpu", + dataType: "string", + required: true, + }); const res = await POST( postRequest({ categoryId: category.id,