diff --git a/prisma/migrations/20260803004634_add_asset_model/migration.sql b/prisma/migrations/20260803004634_add_asset_model/migration.sql new file mode 100644 index 0000000..7a0694b --- /dev/null +++ b/prisma/migrations/20260803004634_add_asset_model/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e5f45f3..4e13dfd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -242,6 +242,13 @@ model AssetCategory { valueGroup AssetCategoryValueGroup @map("value_group") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) 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") } @@ -293,6 +300,37 @@ model AssetCategoryAttributeDef { @@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, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. diff --git a/src/app/api/asset-models/route.ts b/src/app/api/asset-models/route.ts new file mode 100644 index 0000000..38a5380 --- /dev/null +++ b/src/app/api/asset-models/route.ts @@ -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 { + 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; + 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 { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const assetModels = await listAssetModels(); + return NextResponse.json(assetModels, { status: 200 }); +} diff --git a/src/lib/asset-models.ts b/src/lib/asset-models.ts new file mode 100644 index 0000000..5b5e2e0 --- /dev/null +++ b/src/lib/asset-models.ts @@ -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; + created_at: string; +} + +export interface CreateAssetModelInput { + categoryId: string; + manufacturer: string; + name: string; + specs: Record; +} + +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) ?? {}, + 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 { + 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), 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 { + const rows = await prisma.assetModel.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} diff --git a/tests/asset-models.test.ts b/tests/asset-models.test.ts new file mode 100644 index 0000000..463e30b --- /dev/null +++ b/tests/asset-models.test.ts @@ -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>; + +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" }); + }); +});