Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdf82be61 | ||
|
|
c9b595118a | ||
|
|
1c530a3162 | ||
|
|
c02235f381 |
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "asset_models" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"category_id" UUID NOT NULL,
|
||||||
|
"manufacturer" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"specs" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "asset_models_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "asset_models" ADD CONSTRAINT "asset_models_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -242,6 +242,13 @@ model AssetCategory {
|
|||||||
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[]
|
attributeDefs AssetCategoryAttributeDef[]
|
||||||
|
// Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)"
|
||||||
|
// in docs/asset-license-management.md. Only categories with
|
||||||
|
// valueGroup=high_value are ever actually referenced by an AssetModel (a
|
||||||
|
// business rule enforced in src/lib/asset-models.ts's createAssetModel(),
|
||||||
|
// not expressible as a Prisma-level constraint), but the relation itself
|
||||||
|
// is not restricted to high_value categories at the schema level.
|
||||||
|
assetModels AssetModel[]
|
||||||
|
|
||||||
@@map("asset_categories")
|
@@map("asset_categories")
|
||||||
}
|
}
|
||||||
@@ -293,6 +300,37 @@ model AssetCategoryAttributeDef {
|
|||||||
@@map("asset_category_attribute_defs")
|
@@map("asset_category_attribute_defs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sits between AssetCategory and AssetItem — see docs/
|
||||||
|
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the
|
||||||
|
// `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many
|
||||||
|
// AssetItem serials (AssetItem.modelId FK, not built in this task).
|
||||||
|
// `manufacturer`/`name` are deliberately two separate String fields (not
|
||||||
|
// merged into one), per the doc, to allow filtering/reporting by
|
||||||
|
// manufacturer alone (e.g. "Dell") without string-parsing a combined field.
|
||||||
|
// `categoryId` is a required FK to AssetCategory — the DB layer doesn't
|
||||||
|
// enforce that the referenced category is `high_value` (Prisma/Postgres FK
|
||||||
|
// constraints can't express that), that business rule is checked in
|
||||||
|
// src/lib/asset-models.ts's createAssetModel() via an explicit lookup
|
||||||
|
// before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is
|
||||||
|
// JSONB, defaulting to an empty object — the real per-category dynamic
|
||||||
|
// attribute *values* (whose *shape* is defined by
|
||||||
|
// AssetCategoryAttributeDef rows for this model's category). This task
|
||||||
|
// deliberately does NOT validate specs against the category's attribute
|
||||||
|
// defs (loose z.record(z.unknown()) validation only, see
|
||||||
|
// src/lib/asset-models.ts) — that dynamic cross-validation is a separate
|
||||||
|
// follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section.
|
||||||
|
model AssetModel {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
categoryId String @map("category_id") @db.Uuid
|
||||||
|
category AssetCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
manufacturer String
|
||||||
|
name String
|
||||||
|
specs Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@map("asset_models")
|
||||||
|
}
|
||||||
|
|
||||||
// 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,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import {
|
||||||
|
createAssetModel,
|
||||||
|
listAssetModels,
|
||||||
|
validateCreateAssetModelInput,
|
||||||
|
} from "@/lib/asset-models";
|
||||||
|
|
||||||
|
// Mirrors src/app/api/asset-category-attribute-defs/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, manufacturer, name, specs } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createAssetModel(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 assetModels = await listAssetModels();
|
||||||
|
return NextResponse.json(assetModels, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export interface AssetModelRecord {
|
||||||
|
id: string;
|
||||||
|
category_id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssetModelInput {
|
||||||
|
categoryId: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateAssetModelInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// Mirrors src/lib/asset-category-attribute-defs.ts's nonEmptyString helper.
|
||||||
|
function nonEmptyString(message: string) {
|
||||||
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||||
|
(value) => value.trim() !== "",
|
||||||
|
{ message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAssetModelSchema = z.object({
|
||||||
|
// FK to AssetCategory — only a UUID *format* check here, whether it
|
||||||
|
// references a real category (and whether that category is
|
||||||
|
// valueGroup=high_value) is left to createAssetModel()'s explicit lookup
|
||||||
|
// below — that's a business rule, not something pure Zod schema
|
||||||
|
// validation on this input alone can express. Mirrors
|
||||||
|
// src/lib/asset-category-attribute-defs.ts's categoryId 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"),
|
||||||
|
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.
|
||||||
|
specs: z
|
||||||
|
.record(z.unknown(), {
|
||||||
|
invalid_type_error: "specs must be a JSON object",
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) asset-model-creation input. categoryId must be a
|
||||||
|
* valid UUID string, manufacturer/name must be non-empty strings, specs
|
||||||
|
* must be a plain JSON object (or omitted, defaulting to {}). Whether
|
||||||
|
* categoryId references a real, high_value AssetCategory is checked in
|
||||||
|
* createAssetModel() below, not here — that requires a DB lookup.
|
||||||
|
*/
|
||||||
|
export function validateCreateAssetModelInput(input: {
|
||||||
|
categoryId?: unknown;
|
||||||
|
manufacturer?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
specs?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createAssetModelSchema.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;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Prisma.JsonValue;
|
||||||
|
createdAt: Date;
|
||||||
|
}): AssetModelRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
category_id: row.categoryId,
|
||||||
|
manufacturer: row.manufacturer,
|
||||||
|
name: row.name,
|
||||||
|
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAssetModelResult =
|
||||||
|
| { ok: true; value: AssetModelRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AssetModel. categoryId must reference a real AssetCategory
|
||||||
|
* (checked here via an explicit prisma.assetCategory.findUnique() lookup —
|
||||||
|
* not left to Prisma's P2003 FK-violation catch like other entities'
|
||||||
|
* simple FK checks, e.g. src/lib/asset-category-attribute-defs.ts's
|
||||||
|
* categoryId handling), because there's an additional business rule to
|
||||||
|
* enforce beyond "does this row exist": the referenced category's
|
||||||
|
* valueGroup must be `high_value` (per docs/asset-license-management.md's
|
||||||
|
* "Ràng buộc dữ liệu" section under "Cập nhật 2026-08-02 (2)") — a rule
|
||||||
|
* that can't be expressed as a Postgres FK constraint, so it can't be
|
||||||
|
* caught via a Prisma error code the way a plain missing-FK case can. If
|
||||||
|
* the category doesn't exist at all, or exists but is `low_value`, this
|
||||||
|
* returns a distinct {ok:false,error} for each case rather than a raw
|
||||||
|
* P2003/500.
|
||||||
|
*/
|
||||||
|
export async function createAssetModel(
|
||||||
|
input: CreateAssetModelInput
|
||||||
|
): Promise<CreateAssetModelResult> {
|
||||||
|
const category = await prisma.assetCategory.findUnique({
|
||||||
|
where: { id: input.categoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return { ok: false, error: "categoryId does not reference an existing asset category" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.valueGroup !== "high_value") {
|
||||||
|
return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.assetModel.create({
|
||||||
|
data: {
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
manufacturer: input.manufacturer,
|
||||||
|
name: input.name,
|
||||||
|
// input.specs is validated by Zod to be a plain JSON object
|
||||||
|
// (Record<string, unknown>), but Prisma's generated InputJsonValue
|
||||||
|
// type doesn't structurally accept an index-signature Record — cast
|
||||||
|
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||||
|
// values.
|
||||||
|
specs: input.specs as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssetModels(): Promise<AssetModelRecord[]> {
|
||||||
|
const rows = await prisma.assetModel.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createAssetModel,
|
||||||
|
listAssetModels,
|
||||||
|
validateCreateAssetModelInput,
|
||||||
|
} from "@/lib/asset-models";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Note: this file has two layers of coverage, mirroring
|
||||||
|
// tests/asset-category-attribute-defs.test.ts:
|
||||||
|
// - lib-level tests below exercise src/lib/asset-models.ts directly
|
||||||
|
// (validation, FK/business-rule error handling, list ordering).
|
||||||
|
// - route-level tests (further down) exercise
|
||||||
|
// src/app/api/asset-models/route.ts over HTTP, including the auth() gate,
|
||||||
|
// malformed JSON, and non-object bodies.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/asset-models/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-models", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetModel.categoryId is a real FK to AssetCategory, and the referenced
|
||||||
|
// category must be valueGroup=high_value (see docs/
|
||||||
|
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section). Tests
|
||||||
|
// that need valid ids create real rows first and use their ids. Mirrors
|
||||||
|
// tests/asset-category-attribute-defs.test.ts's createTestAssetCategory()
|
||||||
|
// helper.
|
||||||
|
function createTestAssetCategory(
|
||||||
|
overrides: { name?: string; valueGroup?: "high_value" | "low_value" } = {}
|
||||||
|
) {
|
||||||
|
return prisma.assetCategory.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Laptop",
|
||||||
|
valueGroup: overrides.valueGroup ?? "high_value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
await prisma.assetModel.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("validateCreateAssetModelInput", () => {
|
||||||
|
it("accepts valid input with a non-trivial specs object", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults specs to {} when omitted", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.specs).toEqual({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing categoryId", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = validateCreateAssetModelInput({
|
||||||
|
categoryId: "not-a-uuid",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/categoryId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty manufacturer", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/manufacturer/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs value that is not a plain object", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: "not an object",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/specs/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs value that is an array", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: ["not", "an", "object"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/specs/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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" });
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
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).toMatchObject({
|
||||||
|
category_id: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
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("defaults specs to {} in the created record when omitted", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
});
|
||||||
|
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({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
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(/categoryId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing asset category/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a distinct clean error when categoryId references an existing but low_value category", async () => {
|
||||||
|
const category = await createTestAssetCategory({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
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(/high_value/i);
|
||||||
|
// Distinct message from the "doesn't exist at all" case.
|
||||||
|
expect(created.error).not.toMatch(/does not reference an existing asset category/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAssetModels", () => {
|
||||||
|
it("lists created models, newest first", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
|
||||||
|
const first = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (first.ok) await createAssetModel(first.value);
|
||||||
|
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const second = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
});
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (second.ok) await createAssetModel(second.value);
|
||||||
|
|
||||||
|
const records = await listAssetModels();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
// newest first
|
||||||
|
expect(records[0]).toMatchObject({ name: "MacBook Pro 14" });
|
||||||
|
expect(records[1]).toMatchObject({ name: "Latitude 5420" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no models", async () => {
|
||||||
|
const records = await listAssetModels();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/asset-models", () => {
|
||||||
|
it("creates a model on valid input", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
category_id: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
expect(typeof body.id).toBe("string");
|
||||||
|
expect(body.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults specs to {} when omitted from the request body", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.specs).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing manufacturer", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/manufacturer/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",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a categoryId referencing an existing but low_value category, with a distinct 400", async () => {
|
||||||
|
const category = await createTestAssetCategory({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/high_value/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/asset-models", {
|
||||||
|
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-category-attribute-defs) —
|
||||||
|
// 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-models", () => {
|
||||||
|
it("lists created models, newest first", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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({ name: "MacBook Pro 14" });
|
||||||
|
expect(body[1]).toMatchObject({ name: "Latitude 5420" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no models", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/asset-models", () => {
|
||||||
|
// Mirrors tests/asset-category-attribute-defs.test.ts's "auth gating"
|
||||||
|
// block — only checks session presence, per the flat permission model (no
|
||||||
|
// role/permission logic).
|
||||||
|
it("POST /api/asset-models returns 401 without a session", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/asset-models 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