Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d280279322 | ||
|
|
dc4c438ca9 |
+67
-6
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export interface AssetModelRecord {
|
export interface AssetModelRecord {
|
||||||
@@ -42,12 +43,14 @@ const createAssetModelSchema = z.object({
|
|||||||
.uuid("categoryId is required and must be a valid UUID string"),
|
.uuid("categoryId is required and must be a valid UUID string"),
|
||||||
manufacturer: nonEmptyString("manufacturer is required and must be a non-empty 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"),
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
// Deliberately LOOSE for this task — just "a plain JSON object" (no
|
// Deliberately LOOSE at this stage — just "a plain JSON object" (no
|
||||||
// arrays, no primitives at the top level). Does NOT validate keys/types
|
// arrays, no primitives at the top level). The category-specific dynamic
|
||||||
// dynamically against this model's category's AssetCategoryAttributeDef
|
// shape (which keys are allowed, which are required, and their types) is
|
||||||
// rows — that's a separate follow-up task per docs/
|
// validated separately in createAssetModel() below, via a runtime Zod
|
||||||
// asset-license-management.md's "Cập nhật 2026-08-02 (3)" section, out of
|
// schema built from that category's AssetCategoryAttributeDef rows — it
|
||||||
// scope here. Defaults to {} when omitted, matching the DB column default.
|
// 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
|
specs: z
|
||||||
.record(z.unknown(), {
|
.record(z.unknown(), {
|
||||||
invalid_type_error: "specs must be a JSON object",
|
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<string, z.ZodTypeAny> = {};
|
||||||
|
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 =
|
export type CreateAssetModelResult =
|
||||||
| { ok: true; value: AssetModelRecord }
|
| { ok: true; value: AssetModelRecord }
|
||||||
| { ok: false; error: string };
|
| { 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" };
|
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({
|
const created = await prisma.assetModel.create({
|
||||||
data: {
|
data: {
|
||||||
categoryId: input.categoryId,
|
categoryId: input.categoryId,
|
||||||
|
|||||||
+232
-1
@@ -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 () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from empty tables so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic. asset_models must be cleared before asset_categories
|
// 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.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategoryAttributeDef.deleteMany();
|
||||||
await prisma.assetCategory.deleteMany();
|
await prisma.assetCategory.deleteMany();
|
||||||
// Default to a logged-in session — the lib-level tests below don't go
|
// 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
|
// through the route at all (so this is a no-op for them), and the
|
||||||
@@ -181,6 +210,18 @@ describe("validateCreateAssetModelInput", () => {
|
|||||||
describe("createAssetModel", () => {
|
describe("createAssetModel", () => {
|
||||||
it("creates a model for a real high_value category, with a non-trivial specs object", async () => {
|
it("creates a model for a real high_value category, with a non-trivial specs object", async () => {
|
||||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
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({
|
const result = validateCreateAssetModelInput({
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
manufacturer: "Dell",
|
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", () => {
|
describe("listAssetModels", () => {
|
||||||
it("lists created models, newest first", async () => {
|
it("lists created models, newest first", async () => {
|
||||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
@@ -306,6 +525,18 @@ describe("listAssetModels", () => {
|
|||||||
describe("POST /api/asset-models", () => {
|
describe("POST /api/asset-models", () => {
|
||||||
it("creates a model on valid input", async () => {
|
it("creates a model on valid input", async () => {
|
||||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
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(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
categoryId: category.id,
|
categoryId: category.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user