28 tests covering lib-level validation/CRUD and route-level HTTP behavior: malformed JSON, non-object body, invalid dataType, categoryId FK violation, duplicate (categoryId, key) rejected with a clean 400 (not 500), and same key allowed across two different categories (the "not globally unique" case), plus auth gating.
661 lines
19 KiB
TypeScript
661 lines
19 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Mock } from "vitest";
|
|
import type { Session } from "next-auth";
|
|
import {
|
|
createAssetCategoryAttributeDef,
|
|
listAssetCategoryAttributeDefs,
|
|
validateCreateAssetCategoryAttributeDefInput,
|
|
} from "@/lib/asset-category-attribute-defs";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
// Note: this file has two layers of coverage, mirroring
|
|
// tests/purchase-orders.test.ts:
|
|
// - lib-level tests below exercise src/lib/asset-category-attribute-defs.ts
|
|
// directly (validation, FK/unique error handling, list ordering).
|
|
// - route-level tests (further down) exercise
|
|
// src/app/api/asset-category-attribute-defs/route.ts over HTTP, including
|
|
// the auth() gate.
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
|
|
import { GET, POST } from "@/app/api/asset-category-attribute-defs/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-category-attribute-defs", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// AssetCategoryAttributeDef.categoryId is a real FK to AssetCategory (see
|
|
// schema.prisma / src/lib/asset-category-attribute-defs.ts) — tests that
|
|
// need valid ids create real rows first and use their ids. Mirrors
|
|
// tests/purchase-orders.test.ts's createTestVendor()/createTestEmployee()
|
|
// helpers.
|
|
function createTestAssetCategory(overrides: { name?: string } = {}) {
|
|
return prisma.assetCategory.create({
|
|
data: {
|
|
name: overrides.name ?? "Laptop",
|
|
valueGroup: "high_value",
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
// Each test starts from empty tables so listing/order/uniqueness
|
|
// assertions are deterministic. asset_category_attribute_defs must be
|
|
// cleared before asset_categories since it has an FK (ON DELETE RESTRICT)
|
|
// to it.
|
|
await prisma.assetCategoryAttributeDef.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("validateCreateAssetCategoryAttributeDefInput", () => {
|
|
it("accepts valid input", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.value).toMatchObject({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("accepts each of the four fixed dataType values", () => {
|
|
for (const dataType of ["string", "number", "boolean", "date"] as const) {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: `key_${dataType}`,
|
|
label: "Label",
|
|
dataType,
|
|
required: false,
|
|
sortOrder: 0,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects a missing categoryId", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
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 = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "not-a-uuid",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/categoryId/i);
|
|
}
|
|
});
|
|
|
|
it("rejects an empty key", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/key/i);
|
|
}
|
|
});
|
|
|
|
it("rejects an empty label", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/label/i);
|
|
}
|
|
});
|
|
|
|
it("rejects an invalid dataType", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "float",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/dataType/i);
|
|
}
|
|
});
|
|
|
|
it("rejects a missing dataType", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/dataType/i);
|
|
}
|
|
});
|
|
|
|
it("rejects a non-boolean required", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: "yes",
|
|
sortOrder: 1,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/required/i);
|
|
}
|
|
});
|
|
|
|
it("rejects a non-integer sortOrder", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1.5,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/sortOrder/i);
|
|
}
|
|
});
|
|
|
|
it("rejects a missing sortOrder", () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/sortOrder/i);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("createAssetCategoryAttributeDef", () => {
|
|
it("creates an attribute def for a real category", async () => {
|
|
const category = await createTestAssetCategory();
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
const created = await createAssetCategoryAttributeDef(result.value);
|
|
|
|
expect(created.ok).toBe(true);
|
|
if (!created.ok) return;
|
|
expect(created.value).toMatchObject({
|
|
category_id: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
data_type: "number",
|
|
required: true,
|
|
sort_order: 1,
|
|
});
|
|
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("returns a clean error when categoryId doesn't reference an existing category", async () => {
|
|
const result = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: "00000000-0000-0000-0000-000000000000",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
const created = await createAssetCategoryAttributeDef(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.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a duplicate key within the same category with a clean 400-style error", async () => {
|
|
const category = await createTestAssetCategory();
|
|
const first = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
if (first.ok) {
|
|
const firstCreated = await createAssetCategoryAttributeDef(first.value);
|
|
expect(firstCreated.ok).toBe(true);
|
|
}
|
|
|
|
const second = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB) duplicate",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 2,
|
|
});
|
|
expect(second.ok).toBe(true);
|
|
if (!second.ok) return;
|
|
|
|
const created = await createAssetCategoryAttributeDef(second.value);
|
|
expect(created.ok).toBe(false);
|
|
if (created.ok) return;
|
|
expect(created.error).toMatch(/key/i);
|
|
|
|
const remaining = await prisma.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(1);
|
|
});
|
|
|
|
it("allows the same key across two different categories", async () => {
|
|
const laptop = await createTestAssetCategory({ name: "Laptop" });
|
|
const monitor = await createTestAssetCategory({ name: "Màn hình" });
|
|
|
|
const first = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: laptop.id,
|
|
key: "weight_kg",
|
|
label: "Khối lượng (kg)",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 1,
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
if (first.ok) {
|
|
const firstCreated = await createAssetCategoryAttributeDef(first.value);
|
|
expect(firstCreated.ok).toBe(true);
|
|
}
|
|
|
|
const second = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: monitor.id,
|
|
key: "weight_kg",
|
|
label: "Khối lượng (kg)",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 1,
|
|
});
|
|
expect(second.ok).toBe(true);
|
|
if (!second.ok) return;
|
|
|
|
const created = await createAssetCategoryAttributeDef(second.value);
|
|
expect(created.ok).toBe(true);
|
|
if (!created.ok) return;
|
|
expect(created.value.category_id).toBe(monitor.id);
|
|
expect(created.value.key).toBe("weight_kg");
|
|
|
|
const remaining = await prisma.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("listAssetCategoryAttributeDefs", () => {
|
|
it("lists created attribute defs, newest first", async () => {
|
|
const category = await createTestAssetCategory();
|
|
|
|
const first = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
if (first.ok) await createAssetCategoryAttributeDef(first.value);
|
|
|
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
|
|
const second = validateCreateAssetCategoryAttributeDefInput({
|
|
categoryId: category.id,
|
|
key: "cpu",
|
|
label: "CPU",
|
|
dataType: "string",
|
|
required: false,
|
|
sortOrder: 2,
|
|
});
|
|
expect(second.ok).toBe(true);
|
|
if (second.ok) await createAssetCategoryAttributeDef(second.value);
|
|
|
|
const records = await listAssetCategoryAttributeDefs();
|
|
|
|
expect(records).toHaveLength(2);
|
|
// newest first
|
|
expect(records[0]).toMatchObject({ key: "cpu" });
|
|
expect(records[1]).toMatchObject({ key: "ram_gb" });
|
|
});
|
|
|
|
it("returns an empty list when there are no attribute defs", async () => {
|
|
const records = await listAssetCategoryAttributeDefs();
|
|
expect(records).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("POST /api/asset-category-attribute-defs", () => {
|
|
it("creates an attribute def on valid input", async () => {
|
|
const category = await createTestAssetCategory();
|
|
const res = await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
|
|
expect(body).toMatchObject({
|
|
category_id: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
data_type: "number",
|
|
required: true,
|
|
sort_order: 1,
|
|
});
|
|
expect(typeof body.id).toBe("string");
|
|
expect(body.id.length).toBeGreaterThan(0);
|
|
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
|
});
|
|
|
|
it("rejects an invalid dataType", async () => {
|
|
const category = await createTestAssetCategory();
|
|
const res = await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "float",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/dataType/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",
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
|
|
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.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a duplicate (categoryId, key) with a clean 400, not a raw 500", async () => {
|
|
const category = await createTestAssetCategory();
|
|
const first = await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
expect(first.status).toBe(201);
|
|
|
|
const second = await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB) duplicate",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 2,
|
|
})
|
|
);
|
|
|
|
expect(second.status).toBe(400);
|
|
const body = await second.json();
|
|
expect(body.error).toMatch(/key/i);
|
|
|
|
const remaining = await prisma.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(1);
|
|
});
|
|
|
|
it("allows the same key across two different categories via the route", async () => {
|
|
const laptop = await createTestAssetCategory({ name: "Laptop" });
|
|
const monitor = await createTestAssetCategory({ name: "Màn hình" });
|
|
|
|
const first = await POST(
|
|
postRequest({
|
|
categoryId: laptop.id,
|
|
key: "weight_kg",
|
|
label: "Khối lượng (kg)",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
expect(first.status).toBe(201);
|
|
|
|
const second = await POST(
|
|
postRequest({
|
|
categoryId: monitor.id,
|
|
key: "weight_kg",
|
|
label: "Khối lượng (kg)",
|
|
dataType: "number",
|
|
required: false,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
expect(second.status).toBe(201);
|
|
|
|
const remaining = await prisma.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(2);
|
|
});
|
|
|
|
it("rejects an invalid JSON body", async () => {
|
|
const req = new Request("http://localhost/api/asset-category-attribute-defs", {
|
|
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-categories) — 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-category-attribute-defs", () => {
|
|
it("lists created attribute defs, newest first", async () => {
|
|
const category = await createTestAssetCategory();
|
|
await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "cpu",
|
|
label: "CPU",
|
|
dataType: "string",
|
|
required: false,
|
|
sortOrder: 2,
|
|
})
|
|
);
|
|
|
|
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({ key: "cpu" });
|
|
expect(body[1]).toMatchObject({ key: "ram_gb" });
|
|
});
|
|
|
|
it("returns an empty list when there are no attribute defs", async () => {
|
|
const res = await GET();
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("auth gating on /api/asset-category-attribute-defs", () => {
|
|
// Mirrors tests/asset-categories.test.ts's "auth gating" block — only
|
|
// checks session presence, per the flat permission model (no
|
|
// role/permission logic).
|
|
it("POST /api/asset-category-attribute-defs returns 401 without a session", async () => {
|
|
const category = await createTestAssetCategory();
|
|
mockedAuth.mockResolvedValue(null);
|
|
|
|
const res = await POST(
|
|
postRequest({
|
|
categoryId: category.id,
|
|
key: "ram_gb",
|
|
label: "RAM (GB)",
|
|
dataType: "number",
|
|
required: true,
|
|
sortOrder: 1,
|
|
})
|
|
);
|
|
|
|
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.assetCategoryAttributeDef.findMany();
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it("GET /api/asset-category-attribute-defs 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" });
|
|
});
|
|
});
|