feat: wire dynamic specs validation into AssetModel creation

This commit is contained in:
toanhoang
2026-08-02 20:58:19 -04:00
parent bcd41e4ba3
commit dc4c438ca9
2 changed files with 299 additions and 7 deletions
+232 -1
View File
@@ -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,