Compare commits
5
Commits
0fdf82be61
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2037e6939e | ||
|
|
2a5792df67 | ||
|
|
d280279322 | ||
|
|
dc4c438ca9 | ||
|
|
bcd41e4ba3 |
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "asset_items" DROP COLUMN "model",
|
||||||
|
ADD COLUMN "model_id" UUID NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
+14
-1
@@ -97,10 +97,20 @@ enum AssetItemStatus {
|
|||||||
// a formal PO (e.g. historical stock predating the system). Set at
|
// a formal PO (e.g. historical stock predating the system). Set at
|
||||||
// intake/creation time only — no update endpoint for this field yet, same
|
// intake/creation time only — no update endpoint for this field yet, same
|
||||||
// precedent as License.vendorId being set-at-creation with no PATCH.
|
// precedent as License.vendorId being set-at-creation with no PATCH.
|
||||||
|
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
|
||||||
|
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
|
||||||
|
// bullet) — same precedent as Request.employeeId/License.vendorId (string
|
||||||
|
// -> FK once the referenced entity existed). No production `AssetItem` data
|
||||||
|
// existed at the time of this change, so this was a plain column-type
|
||||||
|
// migration, not a backfill. Required (not nullable) — every AssetItem must
|
||||||
|
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
|
||||||
|
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
|
||||||
|
// to avoid two sources of truth that could drift apart.
|
||||||
model AssetItem {
|
model AssetItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
serialNumber String @unique @map("serial_number")
|
serialNumber String @unique @map("serial_number")
|
||||||
model String
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
model AssetModel @relation(fields: [modelId], references: [id])
|
||||||
purchaseDate DateTime @map("purchase_date") @db.Date
|
purchaseDate DateTime @map("purchase_date") @db.Date
|
||||||
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||||
status AssetItemStatus @default(in_stock)
|
status AssetItemStatus @default(in_stock)
|
||||||
@@ -327,6 +337,9 @@ model AssetModel {
|
|||||||
name String
|
name String
|
||||||
specs Json @default("{}")
|
specs Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||||
|
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||||
|
assetItems AssetItem[]
|
||||||
|
|
||||||
@@map("asset_models")
|
@@map("asset_models")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||||
body as Record<string, unknown>;
|
body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetItemInput({
|
const validation = validateCreateAssetItemInput({
|
||||||
serialNumber,
|
serialNumber,
|
||||||
model,
|
modelId,
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
purchaseOrderId,
|
purchaseOrderId,
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+35
-19
@@ -7,7 +7,7 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
|||||||
export interface AssetItemRecord {
|
export interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
model: string;
|
model_id: string;
|
||||||
purchase_date: string;
|
purchase_date: string;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
@@ -17,7 +17,7 @@ export interface AssetItemRecord {
|
|||||||
|
|
||||||
export interface CreateAssetItemInput {
|
export interface CreateAssetItemInput {
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
model: string;
|
modelId: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate?: Date;
|
warrantyEndDate?: Date;
|
||||||
purchaseOrderId?: string | null;
|
purchaseOrderId?: string | null;
|
||||||
@@ -47,7 +47,15 @@ function dateString(message: string) {
|
|||||||
|
|
||||||
const createAssetItemSchema = z.object({
|
const createAssetItemSchema = z.object({
|
||||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||||
model: nonEmptyString("model is required and must be a non-empty string"),
|
// Required FK to AssetModel — replaced the free-text `model` String (see
|
||||||
|
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
|
||||||
|
// (2)" section). Only a UUID *format* check here, whether it references a
|
||||||
|
// real AssetModel is left to Prisma's own FK constraint at insert time
|
||||||
|
// (see createAssetItem()'s P2003 handling below). Mirrors
|
||||||
|
// src/lib/licenses.ts's vendorId handling.
|
||||||
|
modelId: z
|
||||||
|
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
||||||
|
.uuid("modelId is required and must be a valid UUID string"),
|
||||||
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
||||||
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
||||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||||
@@ -64,17 +72,17 @@ const createAssetItemSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates raw (untyped) asset-item-creation input. serialNumber/model
|
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||||
* must be non-empty strings, purchaseDate must be a valid date string,
|
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
||||||
* warrantyEndDate is optional but must be a valid date string if present.
|
* be a valid date string, warrantyEndDate is optional but must be a valid
|
||||||
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
|
* date string if present. purchaseOrderId is optional/nullable but must be
|
||||||
* status is not accepted here — new assets always start "in_stock" (same
|
* a valid UUID if present. status is not accepted here — new assets always
|
||||||
* pattern as Request always starting "Draft"), later states are set via
|
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
||||||
* other operations, not at creation.
|
* states are set via other operations, not at creation.
|
||||||
*/
|
*/
|
||||||
export function validateCreateAssetItemInput(input: {
|
export function validateCreateAssetItemInput(input: {
|
||||||
serialNumber?: unknown;
|
serialNumber?: unknown;
|
||||||
model?: unknown;
|
modelId?: unknown;
|
||||||
purchaseDate?: unknown;
|
purchaseDate?: unknown;
|
||||||
warrantyEndDate?: unknown;
|
warrantyEndDate?: unknown;
|
||||||
purchaseOrderId?: unknown;
|
purchaseOrderId?: unknown;
|
||||||
@@ -90,7 +98,7 @@ export function validateCreateAssetItemInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
model: string;
|
modelId: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate: Date | null;
|
warrantyEndDate: Date | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
@@ -100,7 +108,7 @@ function toRecord(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
serial_number: row.serialNumber,
|
serial_number: row.serialNumber,
|
||||||
model: row.model,
|
model_id: row.modelId,
|
||||||
purchase_date: row.purchaseDate.toISOString(),
|
purchase_date: row.purchaseDate.toISOString(),
|
||||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -119,10 +127,14 @@ export type CreateAssetItemResult =
|
|||||||
* caught here and translated into the same `{ok:false,error}` shape as
|
* caught here and translated into the same `{ok:false,error}` shape as
|
||||||
* input validation failures, rather than letting a raw Prisma error leak
|
* input validation failures, rather than letting a raw Prisma error leak
|
||||||
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
||||||
* handling for FK violations. If purchaseOrderId doesn't reference an
|
* handling for FK violations. Two separate FKs can fail here (modelId ->
|
||||||
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) —
|
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
|
||||||
* caught the same way, mirrors src/lib/purchase-orders.ts's
|
* error code (P2003) for either, so the FK constraint name in
|
||||||
* createPurchaseOrder() P2003 handling.
|
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||||
|
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
|
||||||
|
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
|
||||||
|
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||||
|
* same model.
|
||||||
*/
|
*/
|
||||||
export async function createAssetItem(
|
export async function createAssetItem(
|
||||||
input: CreateAssetItemInput
|
input: CreateAssetItemInput
|
||||||
@@ -131,7 +143,7 @@ export async function createAssetItem(
|
|||||||
const created = await prisma.assetItem.create({
|
const created = await prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
serialNumber: input.serialNumber,
|
serialNumber: input.serialNumber,
|
||||||
model: input.model,
|
modelId: input.modelId,
|
||||||
purchaseDate: input.purchaseDate,
|
purchaseDate: input.purchaseDate,
|
||||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
@@ -143,7 +155,11 @@ export async function createAssetItem(
|
|||||||
return { ok: false, error: "serialNumber already exists" };
|
return { ok: false, error: "serialNumber already exists" };
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||||
|
if (fieldName.includes("purchase_order")) {
|
||||||
|
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+105
-24
@@ -50,16 +50,52 @@ function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
|
||||||
|
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
|
||||||
|
// AssetCategory — tests that need a valid modelId create the full chain
|
||||||
|
// first and use the model's id. Mirrors tests/asset-models.test.ts's
|
||||||
|
// createTestAssetCategory() helper. Category is always high_value here —
|
||||||
|
// AssetModel only ever references high_value categories per
|
||||||
|
// src/lib/asset-models.ts's business rule.
|
||||||
|
function createTestAssetCategory(overrides: { name?: string } = {}) {
|
||||||
|
return prisma.assetCategory.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Laptop",
|
||||||
|
valueGroup: "high_value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
|
||||||
|
return prisma.assetModel.create({
|
||||||
|
data: {
|
||||||
|
categoryId: overrides.categoryId as string,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5440",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestModelId(): Promise<string> {
|
||||||
|
const category = await createTestAssetCategory();
|
||||||
|
const model = await createTestAssetModel({ categoryId: category.id });
|
||||||
|
return model.id;
|
||||||
|
}
|
||||||
|
|
||||||
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_items must be cleared before purchase_orders/
|
// deterministic. asset_items must be cleared before purchase_orders/
|
||||||
// vendors since asset_items has FKs (ON DELETE SET NULL to
|
// vendors/asset_models/asset_categories since asset_items has FKs to all
|
||||||
// purchase_orders) — deleteMany order avoids relying on that cascade
|
// of them — deleteMany order avoids relying on cascade behavior in tests.
|
||||||
// behavior in tests. purchase_orders must be cleared before vendors
|
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
|
||||||
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts).
|
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
|
||||||
|
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
|
||||||
|
// tests/asset-models.test.ts's cleanup order).
|
||||||
await prisma.assetItem.deleteMany();
|
await prisma.assetItem.deleteMany();
|
||||||
await prisma.purchaseOrder.deleteMany();
|
await prisma.purchaseOrder.deleteMany();
|
||||||
await prisma.vendor.deleteMany();
|
await prisma.vendor.deleteMany();
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise business
|
// Default to a logged-in session — the tests below exercise business
|
||||||
// logic, not auth gating (that's covered by the "auth gating" describe
|
// logic, not auth gating (that's covered by the "auth gating" describe
|
||||||
// block further down).
|
// block further down).
|
||||||
@@ -68,8 +104,9 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
describe("POST /api/assets", () => {
|
describe("POST /api/assets", () => {
|
||||||
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
|
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -77,7 +114,7 @@ describe("POST /api/assets", () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
serial_number: "SN-001",
|
serial_number: "SN-001",
|
||||||
model: "Dell Latitude 5440",
|
model_id: modelId,
|
||||||
status: "in_stock",
|
status: "in_stock",
|
||||||
warranty_end_date: null,
|
warranty_end_date: null,
|
||||||
});
|
});
|
||||||
@@ -88,10 +125,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with a warrantyEndDate", async () => {
|
it("creates an asset item with a warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-002",
|
serialNumber: "SN-002",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
warrantyEndDate: "2029-01-15",
|
warrantyEndDate: "2029-01-15",
|
||||||
})
|
})
|
||||||
@@ -103,24 +141,26 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty serialNumber", async () => {
|
it("rejects an empty serialNumber", async () => {
|
||||||
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
|
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/serialNumber/i);
|
expect(body.error).toMatch(/serialNumber/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty model", async () => {
|
it("rejects a missing modelId", async () => {
|
||||||
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" }));
|
const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/model/i);
|
expect(body.error).toMatch(/modelId/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid purchaseDate", async () => {
|
it("rejects an invalid purchaseDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" })
|
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -129,10 +169,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid warrantyEndDate", async () => {
|
it("rejects an invalid warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-005",
|
serialNumber: "SN-005",
|
||||||
model: "Dell",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
warrantyEndDate: "not-a-date",
|
warrantyEndDate: "not-a-date",
|
||||||
})
|
})
|
||||||
@@ -144,13 +185,14 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
|
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const vendor = await createTestVendor();
|
const vendor = await createTestVendor();
|
||||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||||
|
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-007",
|
serialNumber: "SN-007",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: purchaseOrder.id,
|
purchaseOrderId: purchaseOrder.id,
|
||||||
})
|
})
|
||||||
@@ -162,8 +204,9 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
|
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -172,10 +215,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-009",
|
serialNumber: "SN-009",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||||
})
|
})
|
||||||
@@ -192,10 +236,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-012",
|
serialNumber: "SN-012",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: "not-a-uuid",
|
purchaseOrderId: "not-a-uuid",
|
||||||
})
|
})
|
||||||
@@ -207,8 +252,9 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a duplicate serialNumber", async () => {
|
it("rejects a duplicate serialNumber", async () => {
|
||||||
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
|
await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
|
||||||
|
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -218,14 +264,48 @@ describe("POST /api/assets", () => {
|
|||||||
const remaining = await prisma.assetItem.findMany();
|
const remaining = await prisma.assetItem.findMany();
|
||||||
expect(remaining).toHaveLength(1);
|
expect(remaining).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a modelId that is not a valid UUID", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/modelId/i);
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetItem.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
serialNumber: "SN-014",
|
||||||
|
modelId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
purchaseDate: "2026-01-15",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/modelId/i);
|
||||||
|
expect(body.error).toMatch(/does not reference an existing asset model/i);
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetItem.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/assets", () => {
|
describe("GET /api/assets", () => {
|
||||||
it("lists created asset items, newest first", async () => {
|
it("lists created asset items, newest first", async () => {
|
||||||
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
|
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
|
||||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" }));
|
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -235,8 +315,8 @@ describe("GET /api/assets", () => {
|
|||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" });
|
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" });
|
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no asset items", async () => {
|
it("returns an empty list when there are no asset items", async () => {
|
||||||
@@ -252,10 +332,11 @@ describe("auth gating on /api/assets", () => {
|
|||||||
// session presence, per the flat permission model (no role/permission
|
// session presence, per the flat permission model (no role/permission
|
||||||
// logic).
|
// logic).
|
||||||
it("POST /api/assets returns 401 without a session", async () => {
|
it("POST /api/assets returns 401 without a session", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
Reference in New Issue
Block a user