Compare commits
4
Commits
b8b1709a31
...
aaab8fd9b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaab8fd9b7 | ||
|
|
63eb3df33d | ||
|
|
20ec7a11df | ||
|
|
69fa00012b |
@@ -0,0 +1,12 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetCategoryValueGroup" AS ENUM ('high_value', 'low_value');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "asset_categories" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"value_group" "AssetCategoryValueGroup" NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "asset_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -216,6 +216,35 @@ model PurchaseOrder {
|
||||
@@map("purchase_orders")
|
||||
}
|
||||
|
||||
// Fixed, closed set of 2 values tied to the domain (see docs/
|
||||
// asset-license-management.md's "## Phân loại tài sản: High-value vs
|
||||
// Low-value/Consumable" section) — native Postgres enum, same rationale as
|
||||
// RequestType/AssetItemStatus/LicenseType above. Determines downstream
|
||||
// rules for entities that will attach to a category later (not built in
|
||||
// this task): AssetModel.categoryId must point to a `high_value` category,
|
||||
// ConsumableItem.categoryId must point to a `low_value` category.
|
||||
enum AssetCategoryValueGroup {
|
||||
high_value
|
||||
low_value
|
||||
}
|
||||
|
||||
// Asset classification catalog — see docs/asset-license-management.md's
|
||||
// "## Phân loại tài sản" section and the `AssetCategory` row in the "Data
|
||||
// model (sơ bộ)" table. Standalone entity, no FKs (same starting shape as
|
||||
// Vendor before it grew relations) — `AssetModel` and
|
||||
// `AssetCategoryAttributeDef` will attach to this via `categoryId` in
|
||||
// later tasks (not this one). IT admin defines the concrete categories
|
||||
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
|
||||
// set.
|
||||
model AssetCategory {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
valueGroup AssetCategoryValueGroup @map("value_group")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("asset_categories")
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
createAssetCategory,
|
||||
listAssetCategories,
|
||||
validateCreateAssetCategoryInput,
|
||||
} from "@/lib/asset-categories";
|
||||
|
||||
// Mirrors src/app/api/licenses/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 { name, valueGroup } = body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetCategoryInput({ name, valueGroup });
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createAssetCategory(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 assetCategories = await listAssetCategories();
|
||||
return NextResponse.json(assetCategories, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type AssetCategoryValueGroup = "high_value" | "low_value";
|
||||
|
||||
export interface AssetCategoryRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
value_group: AssetCategoryValueGroup;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAssetCategoryInput {
|
||||
name: string;
|
||||
valueGroup: AssetCategoryValueGroup;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreateAssetCategoryInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Mirrors src/lib/vendors.ts's nonEmptyString helper.
|
||||
function nonEmptyString(message: string) {
|
||||
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||
(value) => value.trim() !== "",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
const createAssetCategorySchema = z.object({
|
||||
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||
// Fixed 2-value set (see docs/asset-license-management.md's "## Phân
|
||||
// loại tài sản" section) — mirrors src/lib/licenses.ts's licenseType
|
||||
// z.enum() pattern.
|
||||
valueGroup: z.enum(["high_value", "low_value"], {
|
||||
errorMap: () => ({
|
||||
message: "valueGroup is required and must be either 'high_value' or 'low_value'",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) asset-category-creation input. name must be a
|
||||
* non-empty string, valueGroup must be one of the two fixed enum values
|
||||
* (per the "## Phân loại tài sản" spec in docs/asset-license-management.md).
|
||||
*/
|
||||
export function validateCreateAssetCategoryInput(input: {
|
||||
name?: unknown;
|
||||
valueGroup?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetCategorySchema.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;
|
||||
name: string;
|
||||
valueGroup: AssetCategoryValueGroup;
|
||||
createdAt: Date;
|
||||
}): AssetCategoryRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
value_group: row.valueGroup,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateAssetCategoryResult =
|
||||
| { ok: true; value: AssetCategoryRecord }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates an AssetCategory. No unique constraint on any field, no FKs
|
||||
* either (see docs/asset-license-management.md's "## Phân loại tài sản"
|
||||
* spec — standalone entity, same starting shape as Vendor before it grew
|
||||
* relations) — so there's no known Prisma error code to catch/translate
|
||||
* here today. Still returns the `{ok,value}|{ok,error}` shape (rather than
|
||||
* a bare Promise<AssetCategoryRecord> like createVendor()) to match the
|
||||
* convention used across the newer entities (AssetItem/License) — see the
|
||||
* code-reviewer finding on createLicense() in CLAUDE.md's "Trạng thái hiện
|
||||
* tại": adding this shape up front avoids a silent 500 if a unique
|
||||
* constraint is added later (e.g. on `name`) without remembering to also
|
||||
* add P2002 handling here.
|
||||
*/
|
||||
export async function createAssetCategory(
|
||||
input: CreateAssetCategoryInput
|
||||
): Promise<CreateAssetCategoryResult> {
|
||||
const created = await prisma.assetCategory.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
valueGroup: input.valueGroup,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
}
|
||||
|
||||
export async function listAssetCategories(): Promise<AssetCategoryRecord[]> {
|
||||
const rows = await prisma.assetCategory.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import type { Session } from "next-auth";
|
||||
import {
|
||||
createAssetCategory,
|
||||
listAssetCategories,
|
||||
validateCreateAssetCategoryInput,
|
||||
} from "@/lib/asset-categories";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Note: this file has two layers of coverage, mirroring tests/vendors.test.ts:
|
||||
// - lib-level tests below exercise src/lib/asset-categories.ts directly
|
||||
// (validation, valueGroup enum, list ordering).
|
||||
// - route-level tests (further down) exercise
|
||||
// src/app/api/asset-categories/route.ts over HTTP, including the auth()
|
||||
// gate — mirrors tests/vendors.test.ts's @/auth mocking pattern.
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
import { GET, POST } from "@/app/api/asset-categories/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-categories", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from an empty table so listing/order assertions are
|
||||
// deterministic.
|
||||
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("validateCreateAssetCategoryInput", () => {
|
||||
it("accepts a valid high_value category", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Laptop",
|
||||
valueGroup: "high_value",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toMatchObject({ name: "Laptop", valueGroup: "high_value" });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid low_value category", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Cáp HDMI",
|
||||
valueGroup: "low_value",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value).toMatchObject({ name: "Cáp HDMI", valueGroup: "low_value" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "",
|
||||
valueGroup: "high_value",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing name", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
valueGroup: "high_value",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/name/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid valueGroup", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Laptop",
|
||||
valueGroup: "medium_value",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/valueGroup/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing valueGroup", () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Laptop",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/valueGroup/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAssetCategory", () => {
|
||||
it("creates a high_value category", async () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Laptop",
|
||||
valueGroup: "high_value",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetCategory(result.value);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value).toMatchObject({ name: "Laptop", value_group: "high_value" });
|
||||
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("creates a low_value category", async () => {
|
||||
const result = validateCreateAssetCategoryInput({
|
||||
name: "Cáp HDMI",
|
||||
valueGroup: "low_value",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetCategory(result.value);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAssetCategories", () => {
|
||||
it("lists created categories, newest first", async () => {
|
||||
const first = validateCreateAssetCategoryInput({ name: "Cáp HDMI", valueGroup: "low_value" });
|
||||
expect(first.ok).toBe(true);
|
||||
if (first.ok) await createAssetCategory(first.value);
|
||||
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
const second = validateCreateAssetCategoryInput({ name: "Laptop", valueGroup: "high_value" });
|
||||
expect(second.ok).toBe(true);
|
||||
if (second.ok) await createAssetCategory(second.value);
|
||||
|
||||
const records = await listAssetCategories();
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
// newest first
|
||||
expect(records[0]).toMatchObject({ name: "Laptop" });
|
||||
expect(records[1]).toMatchObject({ name: "Cáp HDMI" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no categories", async () => {
|
||||
const records = await listAssetCategories();
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/asset-categories", () => {
|
||||
it("creates a high_value category on valid input", async () => {
|
||||
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({ name: "Laptop", value_group: "high_value" });
|
||||
expect(typeof body.id).toBe("string");
|
||||
expect(body.id.length).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a low_value category on valid input", async () => {
|
||||
const res = await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
|
||||
});
|
||||
|
||||
it("rejects an empty name", async () => {
|
||||
const res = await POST(postRequest({ name: "", valueGroup: "high_value" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/name/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid valueGroup", async () => {
|
||||
const res = await POST(postRequest({ name: "Laptop", valueGroup: "medium_value" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/valueGroup/i);
|
||||
});
|
||||
|
||||
it("rejects a missing valueGroup", async () => {
|
||||
const res = await POST(postRequest({ name: "Laptop" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/valueGroup/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/asset-categories", {
|
||||
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/vendors) — 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-categories", () => {
|
||||
it("lists created categories, newest first", async () => {
|
||||
await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||
|
||||
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: "Laptop" });
|
||||
expect(body[1]).toMatchObject({ name: "Cáp HDMI" });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no categories", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth gating on /api/asset-categories", () => {
|
||||
// Mirrors tests/vendors.test.ts's "auth gating" block — only checks
|
||||
// session presence, per the flat permission model (no role/permission
|
||||
// logic).
|
||||
it("POST /api/asset-categories returns 401 without a session", async () => {
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||
|
||||
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.assetCategory.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("GET /api/asset-categories 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