Compare commits
30
Commits
7b5ab70596
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2037e6939e | ||
|
|
2a5792df67 | ||
|
|
d280279322 | ||
|
|
dc4c438ca9 | ||
|
|
bcd41e4ba3 | ||
|
|
0fdf82be61 | ||
|
|
c9b595118a | ||
|
|
1c530a3162 | ||
|
|
c02235f381 | ||
|
|
d3536b60ad | ||
|
|
b91a1d2185 | ||
|
|
59425a159b | ||
|
|
123cbce2bc | ||
|
|
f7b9566b04 | ||
|
|
065c8cb59e | ||
|
|
aaab8fd9b7 | ||
|
|
63eb3df33d | ||
|
|
20ec7a11df | ||
|
|
69fa00012b | ||
|
|
b8b1709a31 | ||
|
|
65232fc62c | ||
|
|
3dd1b22828 | ||
|
|
abec58d239 | ||
|
|
fb52f473a5 | ||
|
|
a6d7c7b302 | ||
|
|
4d12885500 | ||
|
|
2b3c39a291 | ||
|
|
ad4b5d7c2f | ||
|
|
cc98ca4d51 | ||
|
|
58235112f0 |
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "purchase_orders" (
|
||||
"id" UUID NOT NULL,
|
||||
"vendor_id" UUID NOT NULL,
|
||||
"order_date" TIMESTAMPTZ(6) NOT NULL,
|
||||
"expected_date" TIMESTAMPTZ(6) NOT NULL,
|
||||
"received_at" TIMESTAMPTZ(6),
|
||||
"received_by" UUID,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "asset_items" ADD COLUMN "purchase_order_id" UUID;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "licenses" ADD COLUMN "purchase_order_id" UUID;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "requests" ADD COLUMN "purchase_order_id" UUID;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "requests" ADD CONSTRAINT "requests_purchase_order_id_fkey" FOREIGN KEY ("purchase_order_id") REFERENCES "purchase_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -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")
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetAttributeDataType" AS ENUM ('string', 'number', 'boolean', 'date');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "asset_category_attribute_defs" (
|
||||
"id" UUID NOT NULL,
|
||||
"category_id" UUID NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"data_type" "AssetAttributeDataType" NOT NULL,
|
||||
"required" BOOLEAN NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "asset_category_attribute_defs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "asset_category_attribute_defs_category_id_key_key" ON "asset_category_attribute_defs"("category_id", "key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "asset_category_attribute_defs" ADD CONSTRAINT "asset_category_attribute_defs_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
|
||||
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "asset_items" DROP COLUMN "model",
|
||||
ADD COLUMN "model_id" UUID NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
+207
-11
@@ -40,6 +40,15 @@ model Request {
|
||||
// plain string (not an enum) until more states exist — see
|
||||
// src/lib/requests.ts's RequestStatus type.
|
||||
status String @default("Draft")
|
||||
// Nullable FK to PurchaseOrder — see docs/asset-license-management.md's
|
||||
// "## PurchaseOrder (gộp Receipt)" Traceability bullet. Semantically the
|
||||
// DEMAND side, unlike AssetItem.purchaseOrderId/License.purchaseOrderId
|
||||
// (supply-side intake): this records "which approved Request got grouped
|
||||
// into this PO", not "which PO did this item come in through". Shape is
|
||||
// identical to those two though — nullable FK, set at creation time only,
|
||||
// no update endpoint for this field yet, same P2003-on-insert handling.
|
||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("requests")
|
||||
@@ -58,6 +67,9 @@ model Employee {
|
||||
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
|
||||
reports Employee[] @relation("EmployeeManager")
|
||||
requests Request[]
|
||||
// Back-relation for PurchaseOrder.receivedById — an Employee can be the
|
||||
// receiver on multiple POs over time.
|
||||
purchaseOrdersReceived PurchaseOrder[]
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("employees")
|
||||
@@ -75,18 +87,35 @@ enum AssetItemStatus {
|
||||
|
||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
||||
// (sơ bộ)" table (serial number, model, ngày mua, bảo hành, trạng thái).
|
||||
// No Vendor/Assignment relation yet — those entities don't exist yet
|
||||
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now,
|
||||
// same starting shape as Employee before Request grew an FK to it.
|
||||
// warrantyEndDate is nullable: not every asset has known warranty terms at
|
||||
// intake time (smallest sensible assumption — doesn't block creation).
|
||||
// No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
|
||||
// dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
|
||||
// warranty terms at intake time (smallest sensible assumption — doesn't
|
||||
// block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
|
||||
// docs/asset-license-management.md's "## PurchaseOrder (gộp Receipt)"
|
||||
// Traceability bullet: supply-side traceability ("which PO did this asset
|
||||
// item come in through"). Nullable because not every AssetItem goes through
|
||||
// a formal PO (e.g. historical stock predating the system). Set at
|
||||
// intake/creation time only — no update endpoint for this field yet, same
|
||||
// precedent as License.vendorId being set-at-creation with no PATCH.
|
||||
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
|
||||
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
|
||||
// bullet) — same precedent as Request.employeeId/License.vendorId (string
|
||||
// -> FK once the referenced entity existed). No production `AssetItem` data
|
||||
// existed at the time of this change, so this was a plain column-type
|
||||
// migration, not a backfill. Required (not nullable) — every AssetItem must
|
||||
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
|
||||
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
|
||||
// to avoid two sources of truth that could drift apart.
|
||||
model AssetItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
serialNumber String @unique @map("serial_number")
|
||||
model String
|
||||
modelId String @map("model_id") @db.Uuid
|
||||
model AssetModel @relation(fields: [modelId], references: [id])
|
||||
purchaseDate DateTime @map("purchase_date") @db.Date
|
||||
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||
status AssetItemStatus @default(in_stock)
|
||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("asset_items")
|
||||
@@ -110,7 +139,13 @@ enum LicenseType {
|
||||
// at creation), not a computed value — it will only ever be set to 0 here
|
||||
// since Assignment (the thing that would consume seats) doesn't exist yet.
|
||||
// `expiryDate` is nullable: perpetual licenses never expire, only
|
||||
// subscription licenses usually have one.
|
||||
// subscription licenses usually have one. `purchaseOrderId` is a nullable FK
|
||||
// to PurchaseOrder — see docs/asset-license-management.md's "## PurchaseOrder
|
||||
// (gộp Receipt)" Traceability bullet: supply-side traceability ("which PO
|
||||
// did this license come in through"). Nullable because not every License
|
||||
// goes through a formal PO. Mirrors AssetItem.purchaseOrderId's just-merged
|
||||
// pattern exactly — same nullable-FK shape, set at creation time only, no
|
||||
// update endpoint for this field yet.
|
||||
model License {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -120,6 +155,8 @@ model License {
|
||||
seatsUsed Int @default(0) @map("seats_used")
|
||||
expiryDate DateTime? @map("expiry_date") @db.Date
|
||||
licenseType LicenseType @map("license_type")
|
||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("licenses")
|
||||
@@ -130,10 +167,10 @@ model License {
|
||||
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
|
||||
// from (e.g. "FPT Trading"), a different concept that isn't always the same
|
||||
// company. `licenses` is the back-relation from the License.vendor ->
|
||||
// License.vendorId retrofit (same doc section) — PurchaseOrder is a future
|
||||
// FK from this entity, not built yet. taxCode/notes are nullable — not
|
||||
// every vendor record has a known VN tax code or extra notes at creation
|
||||
// time.
|
||||
// License.vendorId retrofit (same doc section). `purchaseOrders` is the
|
||||
// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp
|
||||
// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every
|
||||
// vendor record has a known VN tax code or extra notes at creation time.
|
||||
model Vendor {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -144,10 +181,169 @@ model Vendor {
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
licenses License[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
|
||||
@@map("vendors")
|
||||
}
|
||||
|
||||
// Purchasing document — see docs/asset-license-management.md's "##
|
||||
// PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what
|
||||
// would otherwise be a separate `Receipt` entity: receiving is just part of
|
||||
// this row's own state (`receivedAt`/`receivedBy`), not a separate table to
|
||||
// reconcile against. No `status` field — status is inferred from linked
|
||||
// `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid
|
||||
// two sources of truth. No cost/price fields — stakeholder said explicitly
|
||||
// PurchaseOrder doesn't need to track money, only "which asset/license
|
||||
// belongs to which PO". Receiving is all-or-nothing per PO (no partial
|
||||
// receipt tracking) — a deliberate simplification, see doc for the
|
||||
// stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both
|
||||
// nullable and must be co-null/co-set together (both-null = not yet
|
||||
// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a
|
||||
// DB constraint, same approach as other cross-field invariants in this
|
||||
// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet).
|
||||
// `assetItems` is the back-relation from the AssetItem.purchaseOrderId
|
||||
// retrofit (supply-side traceability, same doc section). `licenses` is the
|
||||
// back-relation from the License.purchaseOrderId retrofit (same doc
|
||||
// section, same rationale). `requests` is the back-relation from the
|
||||
// Request.purchaseOrderId retrofit — the DEMAND side ("which approved
|
||||
// Request got grouped into this PO"), as opposed to assetItems/licenses
|
||||
// which are the SUPPLY side (intake traceability) — see Request model's
|
||||
// comment above for the distinction.
|
||||
model PurchaseOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
vendorId String @map("vendor_id") @db.Uuid
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
orderDate DateTime @map("order_date") @db.Timestamptz(6)
|
||||
expectedDate DateTime @map("expected_date") @db.Timestamptz(6)
|
||||
receivedAt DateTime? @map("received_at") @db.Timestamptz(6)
|
||||
receivedById String? @map("received_by") @db.Uuid
|
||||
receivedBy Employee? @relation(fields: [receivedById], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
assetItems AssetItem[]
|
||||
licenses License[]
|
||||
requests Request[]
|
||||
|
||||
@@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)
|
||||
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")
|
||||
}
|
||||
|
||||
// Fixed, closed set of 4 values — a DEV-defined technical set (which Zod
|
||||
// validator to build dynamically for AssetModel.specs at write time: string
|
||||
// -> z.string(), number -> z.number(), boolean -> z.boolean(), date ->
|
||||
// z.coerce.date()). Deliberately a different kind of "fixed set" than
|
||||
// AssetCategoryValueGroup above: that one is a closed business classification
|
||||
// IT admins never extend; this one is a closed set of *primitive types* the
|
||||
// app's dynamic-attribute machinery knows how to handle. Still modeled as a
|
||||
// native Postgres enum per the same codebase convention (RequestType /
|
||||
// AssetItemStatus / LicenseType / AssetCategoryValueGroup), not a String +
|
||||
// hand-rolled check.
|
||||
enum AssetAttributeDataType {
|
||||
string
|
||||
number
|
||||
boolean
|
||||
date
|
||||
}
|
||||
|
||||
// Per-category dynamic attribute definitions — see docs/
|
||||
// asset-license-management.md's "Cập nhật 2026-08-02 (3): thêm
|
||||
// AssetCategoryAttributeDef" section and the `AssetCategoryAttributeDef` row
|
||||
// in the "Data model (sơ bộ)" table. Defines the *shape* of attributes a
|
||||
// category cares about (e.g. category "Laptop" -> key `ram_gb`, label
|
||||
// "RAM (GB)", dataType number); the actual *values* live in
|
||||
// AssetModel.specs (JSONB), not built in this task. Each AssetCategory owns
|
||||
// its own independent set of attribute defs — deliberately NOT a shared
|
||||
// attribute library across categories (stakeholder explicitly rejected that
|
||||
// design to keep things simple at current scale), so the same `key` string
|
||||
// existing under two different categoryId rows is normal, not a collision —
|
||||
// only unique *within* a category, hence the composite unique below (not a
|
||||
// plain @unique on key alone). `sortOrder` is the display order on the
|
||||
// dynamic form, no uniqueness constraint of its own (ties/gaps are fine,
|
||||
// it's just a sort key).
|
||||
model AssetCategoryAttributeDef {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
categoryId String @map("category_id") @db.Uuid
|
||||
category AssetCategory @relation(fields: [categoryId], references: [id])
|
||||
key String
|
||||
label String
|
||||
dataType AssetAttributeDataType @map("data_type")
|
||||
required Boolean
|
||||
sortOrder Int @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([categoryId, key])
|
||||
@@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)
|
||||
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||
assetItems AssetItem[]
|
||||
|
||||
@@map("asset_models")
|
||||
}
|
||||
|
||||
// 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,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
createAssetCategoryAttributeDef,
|
||||
listAssetCategoryAttributeDefs,
|
||||
validateCreateAssetCategoryAttributeDefInput,
|
||||
} from "@/lib/asset-category-attribute-defs";
|
||||
|
||||
// Mirrors src/app/api/asset-categories/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, key, label, dataType, required, sortOrder } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetCategoryAttributeDefInput({
|
||||
categoryId,
|
||||
key,
|
||||
label,
|
||||
dataType,
|
||||
required,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createAssetCategoryAttributeDef(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 assetCategoryAttributeDefs = await listAssetCategoryAttributeDefs();
|
||||
return NextResponse.json(assetCategoryAttributeDefs, { status: 200 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -22,12 +22,14 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { serialNumber, model, purchaseDate, warrantyEndDate } = body as Record<string, unknown>;
|
||||
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetItemInput({
|
||||
serialNumber,
|
||||
model,
|
||||
modelId,
|
||||
purchaseDate,
|
||||
warrantyEndDate,
|
||||
purchaseOrderId,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
|
||||
@@ -22,13 +22,15 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { name, vendorId, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
||||
const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreateLicenseInput({
|
||||
name,
|
||||
vendorId,
|
||||
seatsTotal,
|
||||
expiryDate,
|
||||
licenseType,
|
||||
purchaseOrderId,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import {
|
||||
createPurchaseOrder,
|
||||
listPurchaseOrders,
|
||||
validateCreatePurchaseOrderInput,
|
||||
} from "@/lib/purchase-orders";
|
||||
|
||||
// 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 { vendorId, orderDate, expectedDate, receivedAt, receivedById } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreatePurchaseOrderInput({
|
||||
vendorId,
|
||||
orderDate,
|
||||
expectedDate,
|
||||
receivedAt,
|
||||
receivedById,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createPurchaseOrder(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 purchaseOrders = await listPurchaseOrders();
|
||||
return NextResponse.json(purchaseOrders, { status: 200 });
|
||||
}
|
||||
@@ -24,8 +24,8 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { employee_id, type, reason } = body as Record<string, unknown>;
|
||||
const validation = validateCreateRequestInput({ employee_id, type, reason });
|
||||
const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
|
||||
const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
|
||||
@@ -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,169 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export type AssetAttributeDataType = "string" | "number" | "boolean" | "date";
|
||||
|
||||
export interface AssetCategoryAttributeDefRecord {
|
||||
id: string;
|
||||
category_id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
data_type: AssetAttributeDataType;
|
||||
required: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAssetCategoryAttributeDefInput {
|
||||
categoryId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: AssetAttributeDataType;
|
||||
required: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreateAssetCategoryAttributeDefInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Mirrors src/lib/asset-categories.ts's nonEmptyString helper.
|
||||
function nonEmptyString(message: string) {
|
||||
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||
(value) => value.trim() !== "",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
|
||||
const createAssetCategoryAttributeDefSchema = z.object({
|
||||
// FK to AssetCategory — only a UUID *format* check here, whether it
|
||||
// references a real category is left to Prisma's own FK constraint at
|
||||
// insert time (see createAssetCategoryAttributeDef()'s P2003 handling
|
||||
// below). Mirrors src/lib/purchase-orders.ts's vendorId 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"),
|
||||
// Slug-style, e.g. "ram_gb" — only unique *within* a category (composite
|
||||
// unique (categoryId, key) at the DB layer), not globally. Just requires
|
||||
// non-empty here; the DB-level uniqueness is enforced by
|
||||
// createAssetCategoryAttributeDef()'s P2002 handling below.
|
||||
key: nonEmptyString("key is required and must be a non-empty string"),
|
||||
label: nonEmptyString("label is required and must be a non-empty string"),
|
||||
// Fixed 4-value set (see docs/asset-license-management.md's "Cập nhật
|
||||
// 2026-08-02 (3)" section) — a DEV-defined closed technical set, mirrors
|
||||
// src/lib/asset-categories.ts's valueGroup z.enum() pattern.
|
||||
dataType: z.enum(["string", "number", "boolean", "date"], {
|
||||
errorMap: () => ({
|
||||
message: "dataType is required and must be one of 'string', 'number', 'boolean', 'date'",
|
||||
}),
|
||||
}),
|
||||
required: z.boolean({
|
||||
required_error: "required is required and must be a boolean",
|
||||
invalid_type_error: "required is required and must be a boolean",
|
||||
}),
|
||||
sortOrder: z.number({
|
||||
required_error: "sortOrder is required and must be an integer",
|
||||
invalid_type_error: "sortOrder is required and must be an integer",
|
||||
})
|
||||
.int("sortOrder is required and must be an integer"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) asset-category-attribute-def-creation input.
|
||||
* categoryId must be a valid UUID string, key/label must be non-empty
|
||||
* strings, dataType must be one of the four fixed enum values, required
|
||||
* must be a boolean, sortOrder must be an integer. Whether categoryId
|
||||
* references a real AssetCategory, and whether (categoryId, key) is
|
||||
* actually unique, are both left to Prisma's constraints at insert time
|
||||
* (see createAssetCategoryAttributeDef() below).
|
||||
*/
|
||||
export function validateCreateAssetCategoryAttributeDefInput(input: {
|
||||
categoryId?: unknown;
|
||||
key?: unknown;
|
||||
label?: unknown;
|
||||
dataType?: unknown;
|
||||
required?: unknown;
|
||||
sortOrder?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetCategoryAttributeDefSchema.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;
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: AssetAttributeDataType;
|
||||
required: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
}): AssetCategoryAttributeDefRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
category_id: row.categoryId,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
data_type: row.dataType,
|
||||
required: row.required,
|
||||
sort_order: row.sortOrder,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export type CreateAssetCategoryAttributeDefResult =
|
||||
| { ok: true; value: AssetCategoryAttributeDefRecord }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates an AssetCategoryAttributeDef. If (categoryId, key) collides with
|
||||
* an existing row, Prisma raises P2002 (composite unique constraint
|
||||
* violation) — caught here and translated into a clean {ok:false,error},
|
||||
* same pattern as createAssetItem()'s serialNumber P2002 handling in
|
||||
* src/lib/assets.ts. Note this is a *composite* uniqueness (unique within a
|
||||
* category, not globally) — the same `key` under a *different* categoryId
|
||||
* is expected to succeed, per docs/asset-license-management.md's "Cập nhật
|
||||
* 2026-08-02 (3)" section. If categoryId doesn't reference an existing
|
||||
* AssetCategory, Prisma raises P2003 (FK constraint violation) — caught the
|
||||
* same way, mirrors src/lib/purchase-orders.ts's createPurchaseOrder() P2003
|
||||
* handling.
|
||||
*/
|
||||
export async function createAssetCategoryAttributeDef(
|
||||
input: CreateAssetCategoryAttributeDefInput
|
||||
): Promise<CreateAssetCategoryAttributeDefResult> {
|
||||
try {
|
||||
const created = await prisma.assetCategoryAttributeDef.create({
|
||||
data: {
|
||||
categoryId: input.categoryId,
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
dataType: input.dataType,
|
||||
required: input.required,
|
||||
sortOrder: input.sortOrder,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return { ok: false, error: "key already exists for this category" };
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
return { ok: false, error: "categoryId does not reference an existing asset category" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssetCategoryAttributeDefs(): Promise<
|
||||
AssetCategoryAttributeDefRecord[]
|
||||
> {
|
||||
const rows = await prisma.assetCategoryAttributeDef.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
|
||||
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 at this stage — just "a plain JSON object" (no
|
||||
// arrays, no primitives at the top level). The category-specific dynamic
|
||||
// shape (which keys are allowed, which are required, and their types) is
|
||||
// validated separately in createAssetModel() below, via a runtime Zod
|
||||
// schema built from that category's AssetCategoryAttributeDef rows — it
|
||||
// can't be done here because that requires a DB lookup keyed on
|
||||
// categoryId, which this pure-input-shape schema doesn't have access to.
|
||||
// Defaults to {} when omitted, matching the DB column default.
|
||||
specs: z
|
||||
.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(),
|
||||
};
|
||||
}
|
||||
|
||||
// Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf
|
||||
// type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)"
|
||||
// section: string -> z.string(), number -> z.number(), boolean ->
|
||||
// z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date
|
||||
// *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type).
|
||||
function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny {
|
||||
switch (dataType) {
|
||||
case "string":
|
||||
return z.string();
|
||||
case "number":
|
||||
return z.number();
|
||||
case "boolean":
|
||||
return z.boolean();
|
||||
case "date":
|
||||
return z.coerce.date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Zod object schema AT RUNTIME from a category's
|
||||
* AssetCategoryAttributeDef rows: one shape entry per attribute def (typed
|
||||
* per zodTypeForDataType() above), optional when `required` is false via
|
||||
* `.optional()`, and `.strict()` on the overall object so any `specs` key
|
||||
* not defined for this category is rejected (no unknown keys allowed), per
|
||||
* the doc section referenced above.
|
||||
*/
|
||||
function buildSpecsSchema(
|
||||
attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[]
|
||||
): z.ZodTypeAny {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
for (const def of attributeDefs) {
|
||||
const leaf = zodTypeForDataType(def.dataType);
|
||||
shape[def.key] = def.required ? leaf : leaf.optional();
|
||||
}
|
||||
return z.object(shape).strict();
|
||||
}
|
||||
|
||||
export type CreateAssetModelResult =
|
||||
| { 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" };
|
||||
}
|
||||
|
||||
// Dynamic specs validation, per docs/asset-license-management.md's
|
||||
// "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from
|
||||
// this category's AssetCategoryAttributeDef rows, and validate the
|
||||
// incoming specs object against it. Deliberately done here (after the
|
||||
// category existence/high_value checks above), not inside
|
||||
// validateCreateAssetModelInput()'s static schema, because it needs a DB
|
||||
// lookup keyed on categoryId.
|
||||
const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({
|
||||
where: { categoryId: input.categoryId },
|
||||
});
|
||||
const specsSchema = buildSpecsSchema(attributeDefs);
|
||||
const specsResult = specsSchema.safeParse(input.specs);
|
||||
if (!specsResult.success) {
|
||||
const message = specsResult.error.issues[0]?.message ?? "Invalid specs";
|
||||
const path = specsResult.error.issues[0]?.path.join(".");
|
||||
return {
|
||||
ok: false,
|
||||
error: path ? `specs.${path}: ${message}` : `specs: ${message}`,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await prisma.assetModel.create({
|
||||
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);
|
||||
}
|
||||
+54
-14
@@ -7,18 +7,20 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
||||
export interface AssetItemRecord {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
model: string;
|
||||
model_id: string;
|
||||
purchase_date: string;
|
||||
warranty_end_date: string | null;
|
||||
status: AssetItemStatus;
|
||||
purchase_order_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAssetItemInput {
|
||||
serialNumber: string;
|
||||
model: string;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
warrantyEndDate?: Date;
|
||||
purchaseOrderId?: string | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -45,24 +47,45 @@ function dateString(message: string) {
|
||||
|
||||
const createAssetItemSchema = z.object({
|
||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||
model: nonEmptyString("model is required and must be a non-empty string"),
|
||||
// Required FK to AssetModel — replaced the free-text `model` String (see
|
||||
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
|
||||
// (2)" section). Only a UUID *format* check here, whether it references a
|
||||
// real AssetModel is left to Prisma's own FK constraint at insert time
|
||||
// (see createAssetItem()'s P2003 handling below). Mirrors
|
||||
// src/lib/licenses.ts's vendorId handling.
|
||||
modelId: z
|
||||
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
||||
.uuid("modelId is required and must be a valid UUID string"),
|
||||
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
||||
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||
// every AssetItem goes through a formal PO). Only a UUID *format* check
|
||||
// here, whether it references a real PurchaseOrder is left to Prisma's
|
||||
// own FK constraint at insert time (see createAssetItem()'s P2003
|
||||
// handling below). Mirrors src/lib/purchase-orders.ts's receivedById
|
||||
// optional().nullable() pattern.
|
||||
purchaseOrderId: z
|
||||
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
|
||||
.uuid("purchaseOrderId must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) asset-item-creation input. serialNumber/model
|
||||
* must be non-empty strings, purchaseDate must be a valid date string,
|
||||
* warrantyEndDate is optional but must be a valid date string if present.
|
||||
* status is not accepted here — new assets always start "in_stock" (same
|
||||
* pattern as Request always starting "Draft"), later states are set via
|
||||
* other operations, not at creation.
|
||||
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
||||
* be a valid date string, warrantyEndDate is optional but must be a valid
|
||||
* date string if present. purchaseOrderId is optional/nullable but must be
|
||||
* a valid UUID if present. status is not accepted here — new assets always
|
||||
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
||||
* states are set via other operations, not at creation.
|
||||
*/
|
||||
export function validateCreateAssetItemInput(input: {
|
||||
serialNumber?: unknown;
|
||||
model?: unknown;
|
||||
modelId?: unknown;
|
||||
purchaseDate?: unknown;
|
||||
warrantyEndDate?: unknown;
|
||||
purchaseOrderId?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetItemSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -75,19 +98,21 @@ export function validateCreateAssetItemInput(input: {
|
||||
function toRecord(row: {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
model: string;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
warrantyEndDate: Date | null;
|
||||
status: AssetItemStatus;
|
||||
purchaseOrderId: string | null;
|
||||
createdAt: Date;
|
||||
}): AssetItemRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
serial_number: row.serialNumber,
|
||||
model: row.model,
|
||||
model_id: row.modelId,
|
||||
purchase_date: row.purchaseDate.toISOString(),
|
||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||
status: row.status,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -102,7 +127,14 @@ export type CreateAssetItemResult =
|
||||
* caught here and translated into the same `{ok:false,error}` shape as
|
||||
* input validation failures, rather than letting a raw Prisma error leak
|
||||
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
||||
* handling for FK violations.
|
||||
* handling for FK violations. Two separate FKs can fail here (modelId ->
|
||||
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
|
||||
* error code (P2003) for either, so the FK constraint name in
|
||||
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
|
||||
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
|
||||
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||
* same model.
|
||||
*/
|
||||
export async function createAssetItem(
|
||||
input: CreateAssetItemInput
|
||||
@@ -111,9 +143,10 @@ export async function createAssetItem(
|
||||
const created = await prisma.assetItem.create({
|
||||
data: {
|
||||
serialNumber: input.serialNumber,
|
||||
model: input.model,
|
||||
modelId: input.modelId,
|
||||
purchaseDate: input.purchaseDate,
|
||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
@@ -121,6 +154,13 @@ export async function createAssetItem(
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return { ok: false, error: "serialNumber already exists" };
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||
if (fieldName.includes("purchase_order")) {
|
||||
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
||||
}
|
||||
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-5
@@ -12,6 +12,7 @@ export interface LicenseRecord {
|
||||
seats_used: number;
|
||||
expiry_date: string | null;
|
||||
license_type: LicenseType;
|
||||
purchase_order_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ export interface CreateLicenseInput {
|
||||
seatsTotal: number;
|
||||
expiryDate?: Date | null;
|
||||
licenseType: LicenseType;
|
||||
purchaseOrderId?: string | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -65,6 +67,17 @@ const createLicenseSchema = z.object({
|
||||
message: "licenseType is required and must be either 'perpetual' or 'subscription'",
|
||||
}),
|
||||
}),
|
||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||
// every License goes through a formal PO). Only a UUID *format* check
|
||||
// here, whether it references a real PurchaseOrder is left to Prisma's
|
||||
// own FK constraint at insert time (see createLicense()'s P2003 handling
|
||||
// below). Mirrors src/lib/assets.ts's purchaseOrderId
|
||||
// optional().nullable() pattern exactly.
|
||||
purchaseOrderId: z
|
||||
.string({ invalid_type_error: "purchaseOrderId must be a valid UUID string" })
|
||||
.uuid("purchaseOrderId must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -75,6 +88,7 @@ const createLicenseSchema = z.object({
|
||||
* seatsUsed is not accepted here — new licenses always start at 0 (same
|
||||
* pattern as AssetItem always starting "in_stock"), seats are only ever
|
||||
* consumed via other operations (once Assignment exists), not at creation.
|
||||
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
|
||||
*/
|
||||
export function validateCreateLicenseInput(input: {
|
||||
name?: unknown;
|
||||
@@ -82,6 +96,7 @@ export function validateCreateLicenseInput(input: {
|
||||
seatsTotal?: unknown;
|
||||
expiryDate?: unknown;
|
||||
licenseType?: unknown;
|
||||
purchaseOrderId?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createLicenseSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -99,6 +114,7 @@ function toRecord(row: {
|
||||
seatsUsed: number;
|
||||
expiryDate: Date | null;
|
||||
licenseType: LicenseType;
|
||||
purchaseOrderId: string | null;
|
||||
createdAt: Date;
|
||||
}): LicenseRecord {
|
||||
return {
|
||||
@@ -109,6 +125,7 @@ function toRecord(row: {
|
||||
seats_used: row.seatsUsed,
|
||||
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
||||
license_type: row.licenseType,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -118,11 +135,13 @@ export type CreateLicenseResult =
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates a License. If vendorId doesn't reference an existing Vendor,
|
||||
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
||||
* translated into the same `{ok:false,error}` shape as input validation
|
||||
* failures, rather than letting a raw Prisma error leak to the API layer.
|
||||
* Mirrors src/lib/requests.ts's createRequest() pattern for employeeId.
|
||||
* Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
|
||||
* purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
|
||||
* (P2003) for either, so the FK constraint name in `error.meta.field_name`
|
||||
* is used to tell them apart and return a targeted message, rather than a
|
||||
* raw Prisma error leaking to the API layer. Mirrors
|
||||
* src/lib/purchase-orders.ts's createPurchaseOrder() pattern for
|
||||
* disambiguating between two FKs on the same model.
|
||||
*/
|
||||
export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
|
||||
try {
|
||||
@@ -133,11 +152,16 @@ export async function createLicense(input: CreateLicenseInput): Promise<CreateLi
|
||||
seatsTotal: input.seatsTotal,
|
||||
expiryDate: input.expiryDate ?? null,
|
||||
licenseType: input.licenseType,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||
if (fieldName.includes("purchase_order")) {
|
||||
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
||||
}
|
||||
return { ok: false, error: "vendorId does not reference an existing vendor" };
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export interface PurchaseOrderRecord {
|
||||
id: string;
|
||||
vendor_id: string;
|
||||
order_date: string;
|
||||
expected_date: string;
|
||||
received_at: string | null;
|
||||
received_by: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreatePurchaseOrderInput {
|
||||
vendorId: string;
|
||||
orderDate: Date;
|
||||
expectedDate: Date;
|
||||
receivedAt?: Date | null;
|
||||
receivedById?: string | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; value: CreatePurchaseOrderInput }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Mirrors src/lib/licenses.ts's dateString helper — accepts any string
|
||||
// Date.parse() can handle and transforms it into a Date for Prisma. Used
|
||||
// here for orderDate/expectedDate/receivedAt, which are all Timestamptz
|
||||
// (not just Date) columns, so datetime strings (not just "2026-07-31") are
|
||||
// also accepted.
|
||||
function dateString(message: string) {
|
||||
return z
|
||||
.string({ required_error: message, invalid_type_error: message })
|
||||
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
|
||||
.transform((value) => new Date(value));
|
||||
}
|
||||
|
||||
const createPurchaseOrderSchema = z
|
||||
.object({
|
||||
// FK to Vendor — only a UUID *format* check here, whether it references
|
||||
// a real vendor is left to Prisma's own FK constraint at insert time
|
||||
// (see createPurchaseOrder()'s P2003 handling below). Mirrors
|
||||
// src/lib/licenses.ts's vendorId handling.
|
||||
vendorId: z
|
||||
.string({ required_error: "vendorId is required and must be a valid UUID string" })
|
||||
.uuid("vendorId is required and must be a valid UUID string"),
|
||||
orderDate: dateString("orderDate is required and must be a valid date string"),
|
||||
expectedDate: dateString("expectedDate is required and must be a valid date string"),
|
||||
// Both nullable — null means "not yet received". Co-nullability with
|
||||
// receivedById is enforced by the .refine() below, not here.
|
||||
receivedAt: dateString("receivedAt must be a valid date string").optional().nullable(),
|
||||
receivedById: z
|
||||
.string({ invalid_type_error: "receivedById must be a valid UUID string" })
|
||||
.uuid("receivedById must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
})
|
||||
.refine(
|
||||
(value) => {
|
||||
const hasReceivedAt = value.receivedAt !== undefined && value.receivedAt !== null;
|
||||
const hasReceivedById = value.receivedById !== undefined && value.receivedById !== null;
|
||||
return hasReceivedAt === hasReceivedById;
|
||||
},
|
||||
{
|
||||
message: "receivedAt and receivedById must both be set or both be unset",
|
||||
// Attach the error to both fields so it surfaces regardless of which
|
||||
// one the caller looks at first.
|
||||
path: ["receivedAt"],
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) purchase-order-creation input. vendorId must be a
|
||||
* valid UUID string, orderDate/expectedDate must be valid date strings,
|
||||
* receivedAt/receivedById are optional/nullable but must both be set or
|
||||
* both be unset (co-nullability invariant — receiving a PO always records
|
||||
* both "when" and "who" together). No status field is accepted — PurchaseOrder
|
||||
* deliberately has no stored status (see schema.prisma comment), it's
|
||||
* inferred from linked Requests.
|
||||
*/
|
||||
export function validateCreatePurchaseOrderInput(input: {
|
||||
vendorId?: unknown;
|
||||
orderDate?: unknown;
|
||||
expectedDate?: unknown;
|
||||
receivedAt?: unknown;
|
||||
receivedById?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createPurchaseOrderSchema.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;
|
||||
vendorId: string;
|
||||
orderDate: Date;
|
||||
expectedDate: Date;
|
||||
receivedAt: Date | null;
|
||||
receivedById: string | null;
|
||||
createdAt: Date;
|
||||
}): PurchaseOrderRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
vendor_id: row.vendorId,
|
||||
order_date: row.orderDate.toISOString(),
|
||||
expected_date: row.expectedDate.toISOString(),
|
||||
received_at: row.receivedAt ? row.receivedAt.toISOString() : null,
|
||||
received_by: row.receivedById,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export type CreatePurchaseOrderResult =
|
||||
| { ok: true; value: PurchaseOrderRecord }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates a PurchaseOrder. Two separate FKs can fail here (vendorId ->
|
||||
* Vendor, receivedById -> Employee) — Prisma raises the same error code
|
||||
* (P2003) for either, so the FK constraint name in `error.meta.field_name`
|
||||
* is used to tell them apart and return a targeted message, rather than a
|
||||
* raw Prisma error leaking to the API layer. Mirrors
|
||||
* src/lib/licenses.ts's createLicense() pattern, extended for the
|
||||
* two-FK case.
|
||||
*/
|
||||
export async function createPurchaseOrder(
|
||||
input: CreatePurchaseOrderInput
|
||||
): Promise<CreatePurchaseOrderResult> {
|
||||
try {
|
||||
const created = await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
vendorId: input.vendorId,
|
||||
orderDate: input.orderDate,
|
||||
expectedDate: input.expectedDate,
|
||||
receivedAt: input.receivedAt ?? null,
|
||||
receivedById: input.receivedById ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||
if (fieldName.includes("received_by")) {
|
||||
return { ok: false, error: "receivedById does not reference an existing employee" };
|
||||
}
|
||||
return { ok: false, error: "vendorId does not reference an existing vendor" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPurchaseOrders(): Promise<PurchaseOrderRecord[]> {
|
||||
const rows = await prisma.purchaseOrder.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
+29
-5
@@ -16,6 +16,7 @@ export interface RequestRecord {
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: RequestStatus;
|
||||
purchase_order_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
|
||||
employee_id: string;
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
purchase_order_id?: string | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
|
||||
message: "type is required and must be exactly 'asset' or 'license'",
|
||||
}),
|
||||
}),
|
||||
// Nullable/optional FK to PurchaseOrder — see schema.prisma's comment on
|
||||
// Request.purchaseOrderId for the demand-vs-supply-side distinction from
|
||||
// AssetItem/License's purchaseOrderId. Only a UUID *format* check here,
|
||||
// whether it references a real PurchaseOrder is left to Prisma's own FK
|
||||
// constraint at insert time (see createRequest()'s P2003 handling below).
|
||||
// Mirrors src/lib/assets.ts's purchaseOrderId optional().nullable()
|
||||
// pattern exactly.
|
||||
purchase_order_id: z
|
||||
.string({ invalid_type_error: "purchase_order_id must be a valid UUID string" })
|
||||
.uuid("purchase_order_id must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -66,6 +80,7 @@ export function validateCreateRequestInput(input: {
|
||||
employee_id?: unknown;
|
||||
type?: unknown;
|
||||
reason?: unknown;
|
||||
purchase_order_id?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createRequestSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -81,6 +96,7 @@ function toRecord(row: {
|
||||
type: RequestType;
|
||||
reason: string;
|
||||
status: string;
|
||||
purchaseOrderId: string | null;
|
||||
createdAt: Date;
|
||||
}): RequestRecord {
|
||||
return {
|
||||
@@ -89,6 +105,7 @@ function toRecord(row: {
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
status: row.status as RequestStatus,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -98,11 +115,13 @@ export type CreateRequestResult =
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Creates a Request. If employee_id doesn't reference an existing Employee,
|
||||
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
||||
* translated into the same `{ok:false,error}` shape as input validation
|
||||
* failures, rather than letting a raw Prisma error leak to the API layer.
|
||||
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId.
|
||||
* Creates a Request. Two separate FKs can fail here (employee_id ->
|
||||
* Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
|
||||
* error code (P2003) for either, so the FK constraint name in
|
||||
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
|
||||
* src/lib/licenses.ts's createLicense() pattern for disambiguating between
|
||||
* two FKs on the same model.
|
||||
*/
|
||||
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
|
||||
try {
|
||||
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
|
||||
employeeId: input.employee_id,
|
||||
type: input.type,
|
||||
reason: input.reason,
|
||||
purchaseOrderId: input.purchase_order_id ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||
if (fieldName.includes("purchase_order")) {
|
||||
return { ok: false, error: "purchase_order_id does not reference an existing purchase order" };
|
||||
}
|
||||
return { ok: false, error: "employee_id does not reference an existing employee" };
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,660 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,731 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper for the dynamic-specs-validation tests below: creates an
|
||||
// AssetCategoryAttributeDef row directly via Prisma (no need to go through
|
||||
// src/lib/asset-category-attribute-defs.ts's validation for test setup).
|
||||
// Mirrors tests/asset-category-attribute-defs.test.ts's
|
||||
// createTestAssetCategory() helper pattern.
|
||||
function createTestAttributeDef(overrides: {
|
||||
categoryId: string;
|
||||
key: string;
|
||||
label?: string;
|
||||
dataType: "string" | "number" | "boolean" | "date";
|
||||
required: boolean;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
return prisma.assetCategoryAttributeDef.create({
|
||||
data: {
|
||||
categoryId: overrides.categoryId,
|
||||
key: overrides.key,
|
||||
label: overrides.label ?? overrides.key,
|
||||
dataType: overrides.dataType,
|
||||
required: overrides.required,
|
||||
sortOrder: overrides.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// 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. asset_category_attribute_defs
|
||||
// also has an FK (ON DELETE RESTRICT) to asset_categories, so it must be
|
||||
// cleared before asset_categories too — mirrors tests/
|
||||
// asset-category-attribute-defs.test.ts's cleanup order.
|
||||
await prisma.assetModel.deleteMany();
|
||||
await prisma.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("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" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "cpu",
|
||||
dataType: "string",
|
||||
required: true,
|
||||
});
|
||||
const result = validateCreateAssetModelInput({
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// Dynamic specs validation, per docs/asset-license-management.md's "Cập
|
||||
// nhật 2026-08-02 (3)" section: createAssetModel() builds a Zod schema at
|
||||
// runtime from the category's AssetCategoryAttributeDef rows and validates
|
||||
// `specs` against it — unknown keys rejected, required keys enforced, and
|
||||
// each key's value type-checked per its dataType.
|
||||
describe("createAssetModel: dynamic specs validation", () => {
|
||||
it("accepts specs that supply all required keys with correct types", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "cpu",
|
||||
dataType: "string",
|
||||
required: true,
|
||||
});
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "has_touchscreen",
|
||||
dataType: "boolean",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: { ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.specs).toEqual({ ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false });
|
||||
});
|
||||
|
||||
it("rejects specs missing a required key with a clean 400-style error", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: {},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/ram_gb/i);
|
||||
|
||||
const remaining = await prisma.assetModel.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a specs value of the wrong type for its dataType (string where number is expected)", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: { ram_gb: "16" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/ram_gb/i);
|
||||
|
||||
const remaining = await prisma.assetModel.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a specs key not defined as an AssetCategoryAttributeDef for that category", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: { ram_gb: 16, unknown_key: "surprise" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/specs/i);
|
||||
|
||||
const remaining = await prisma.assetModel.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts specs that omit an optional (required=false) attribute def key", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "weight_kg",
|
||||
dataType: "number",
|
||||
required: false,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: { ram_gb: 16 },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.specs).toEqual({ ram_gb: 16 });
|
||||
});
|
||||
|
||||
it("accepts a valid date string for a date-typed attribute def", async () => {
|
||||
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "warranty_end",
|
||||
dataType: "date",
|
||||
required: true,
|
||||
});
|
||||
|
||||
const result = validateCreateAssetModelInput({
|
||||
categoryId: category.id,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5420",
|
||||
specs: { warranty_end: "2027-01-01" },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createAssetModel(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.specs).toEqual({ warranty_end: "2027-01-01" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAssetModels", () => {
|
||||
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" });
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "ram_gb",
|
||||
dataType: "number",
|
||||
required: true,
|
||||
});
|
||||
await createTestAttributeDef({
|
||||
categoryId: category.id,
|
||||
key: "cpu",
|
||||
dataType: "string",
|
||||
required: true,
|
||||
});
|
||||
const res = await POST(
|
||||
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" });
|
||||
});
|
||||
});
|
||||
+194
-18
@@ -24,10 +24,78 @@ function postRequest(body: unknown): Request {
|
||||
});
|
||||
}
|
||||
|
||||
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
|
||||
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
||||
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
|
||||
// first and use their ids. Mirrors tests/purchase-orders.test.ts's
|
||||
// createTestVendor() helper.
|
||||
function createTestVendor(overrides: { name?: string } = {}) {
|
||||
return prisma.vendor.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Vendor",
|
||||
contactEmail: "[email protected]",
|
||||
contactPhone: "0281234567",
|
||||
address: "123 Test Street",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
||||
return prisma.purchaseOrder.create({
|
||||
data: {
|
||||
vendorId: overrides.vendorId as string,
|
||||
orderDate: new Date("2026-08-01"),
|
||||
expectedDate: new Date("2026-08-10"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
|
||||
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
|
||||
// AssetCategory — tests that need a valid modelId create the full chain
|
||||
// first and use the model's id. Mirrors tests/asset-models.test.ts's
|
||||
// createTestAssetCategory() helper. Category is always high_value here —
|
||||
// AssetModel only ever references high_value categories per
|
||||
// src/lib/asset-models.ts's business rule.
|
||||
function createTestAssetCategory(overrides: { name?: string } = {}) {
|
||||
return prisma.assetCategory.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Laptop",
|
||||
valueGroup: "high_value",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
|
||||
return prisma.assetModel.create({
|
||||
data: {
|
||||
categoryId: overrides.categoryId as string,
|
||||
manufacturer: "Dell",
|
||||
name: "Latitude 5440",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createTestModelId(): Promise<string> {
|
||||
const category = await createTestAssetCategory();
|
||||
const model = await createTestAssetModel({ categoryId: category.id });
|
||||
return model.id;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from an empty table so listing/order assertions are
|
||||
// deterministic.
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. asset_items must be cleared before purchase_orders/
|
||||
// vendors/asset_models/asset_categories since asset_items has FKs to all
|
||||
// of them — deleteMany order avoids relying on cascade behavior in tests.
|
||||
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
|
||||
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
|
||||
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
|
||||
// tests/asset-models.test.ts's cleanup order).
|
||||
await prisma.assetItem.deleteMany();
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.vendor.deleteMany();
|
||||
await prisma.assetModel.deleteMany();
|
||||
await prisma.assetCategory.deleteMany();
|
||||
// Default to a logged-in session — the tests below exercise business
|
||||
// logic, not auth gating (that's covered by the "auth gating" describe
|
||||
// block further down).
|
||||
@@ -36,8 +104,9 @@ beforeEach(async () => {
|
||||
|
||||
describe("POST /api/assets", () => {
|
||||
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
|
||||
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
@@ -45,7 +114,7 @@ describe("POST /api/assets", () => {
|
||||
|
||||
expect(body).toMatchObject({
|
||||
serial_number: "SN-001",
|
||||
model: "Dell Latitude 5440",
|
||||
model_id: modelId,
|
||||
status: "in_stock",
|
||||
warranty_end_date: null,
|
||||
});
|
||||
@@ -56,10 +125,11 @@ describe("POST /api/assets", () => {
|
||||
});
|
||||
|
||||
it("creates an asset item with a warrantyEndDate", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-002",
|
||||
model: "Dell Latitude 5440",
|
||||
modelId,
|
||||
purchaseDate: "2026-01-15",
|
||||
warrantyEndDate: "2029-01-15",
|
||||
})
|
||||
@@ -71,24 +141,26 @@ describe("POST /api/assets", () => {
|
||||
});
|
||||
|
||||
it("rejects an empty serialNumber", async () => {
|
||||
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" }));
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/serialNumber/i);
|
||||
});
|
||||
|
||||
it("rejects an empty model", async () => {
|
||||
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" }));
|
||||
it("rejects a missing modelId", async () => {
|
||||
const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/model/i);
|
||||
expect(body.error).toMatch(/modelId/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid purchaseDate", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" })
|
||||
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
@@ -97,10 +169,11 @@ describe("POST /api/assets", () => {
|
||||
});
|
||||
|
||||
it("rejects an invalid warrantyEndDate", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-005",
|
||||
model: "Dell",
|
||||
modelId,
|
||||
purchaseDate: "2026-01-15",
|
||||
warrantyEndDate: "not-a-date",
|
||||
})
|
||||
@@ -111,9 +184,77 @@ describe("POST /api/assets", () => {
|
||||
expect(body.error).toMatch(/warrantyEndDate/i);
|
||||
});
|
||||
|
||||
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-007",
|
||||
modelId,
|
||||
purchaseDate: "2026-01-15",
|
||||
purchaseOrderId: purchaseOrder.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-009",
|
||||
modelId,
|
||||
purchaseDate: "2026-01-15",
|
||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.assetItem.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-012",
|
||||
modelId,
|
||||
purchaseDate: "2026-01-15",
|
||||
purchaseOrderId: "not-a-uuid",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
});
|
||||
|
||||
it("rejects a duplicate serialNumber", async () => {
|
||||
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
|
||||
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
|
||||
const modelId = await createTestModelId();
|
||||
await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
|
||||
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -123,14 +264,48 @@ describe("POST /api/assets", () => {
|
||||
const remaining = await prisma.assetItem.findMany();
|
||||
expect(remaining).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a modelId that is not a valid UUID", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/modelId/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.assetItem.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
serialNumber: "SN-014",
|
||||
modelId: "00000000-0000-0000-0000-000000000000",
|
||||
purchaseDate: "2026-01-15",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/modelId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing asset model/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.assetItem.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/assets", () => {
|
||||
it("lists created asset items, newest first", async () => {
|
||||
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" }));
|
||||
const modelId = await createTestModelId();
|
||||
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" }));
|
||||
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
@@ -140,8 +315,8 @@ describe("GET /api/assets", () => {
|
||||
expect(body).toHaveLength(2);
|
||||
|
||||
// newest first
|
||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" });
|
||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" });
|
||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no asset items", async () => {
|
||||
@@ -157,10 +332,11 @@ describe("auth gating on /api/assets", () => {
|
||||
// session presence, per the flat permission model (no role/permission
|
||||
// logic).
|
||||
it("POST /api/assets returns 401 without a session", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(
|
||||
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" })
|
||||
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
+161
-2
@@ -49,11 +49,29 @@ function createTestVendor(overrides: { name?: string } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// License.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
|
||||
// src/lib/licenses.ts) — tests that need a valid purchaseOrderId create a
|
||||
// real PurchaseOrder row first and use its id. Mirrors
|
||||
// tests/assets.test.ts's createTestPurchaseOrder() helper.
|
||||
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
||||
return prisma.purchaseOrder.create({
|
||||
data: {
|
||||
vendorId: overrides.vendorId as string,
|
||||
orderDate: new Date("2026-08-01"),
|
||||
expectedDate: new Date("2026-08-10"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. licenses must be cleared before vendors since
|
||||
// licenses.vendor_id has an FK (ON DELETE RESTRICT) to vendors.
|
||||
// deterministic. licenses must be cleared before purchase_orders/vendors
|
||||
// since licenses has FKs (ON DELETE RESTRICT to vendors, ON DELETE SET
|
||||
// NULL to purchase_orders) to both. purchase_orders must be cleared
|
||||
// before vendors (ON DELETE RESTRICT to vendors, mirrors
|
||||
// tests/assets.test.ts).
|
||||
await prisma.license.deleteMany();
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.vendor.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
|
||||
@@ -222,6 +240,21 @@ describe("validateCreateLicenseInput", () => {
|
||||
expect(result.error).toMatch(/licenseType/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is not a valid UUID", () => {
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "not-a-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/purchaseOrderId/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLicense", () => {
|
||||
@@ -298,6 +331,64 @@ describe("createLicense", () => {
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: purchaseOrder.id,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a license with no purchaseOrderId (stays null)", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.value.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a clean error when purchaseOrderId doesn't reference an existing purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreateLicenseInput({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createLicense(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/purchaseOrderId/i);
|
||||
expect(created.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listLicenses", () => {
|
||||
@@ -471,6 +562,74 @@ describe("POST /api/licenses", () => {
|
||||
expect(body.error).toMatch(/licenseType/i);
|
||||
});
|
||||
|
||||
it("creates a license with a purchaseOrderId that references a real purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: purchaseOrder.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a license with no purchaseOrderId (stays null)", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.license.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
name: "Adobe Creative Cloud",
|
||||
vendorId: vendor.id,
|
||||
seatsTotal: 10,
|
||||
licenseType: "subscription",
|
||||
purchaseOrderId: "not-a-uuid",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchaseOrderId/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/licenses", {
|
||||
method: "POST",
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Mock } from "vitest";
|
||||
import type { Session } from "next-auth";
|
||||
import {
|
||||
createPurchaseOrder,
|
||||
listPurchaseOrders,
|
||||
validateCreatePurchaseOrderInput,
|
||||
} from "@/lib/purchase-orders";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Note: this file has two layers of coverage, mirroring tests/licenses.test.ts:
|
||||
// - lib-level tests below exercise src/lib/purchase-orders.ts directly
|
||||
// (validation, co-nullability refine, FK error handling, list ordering).
|
||||
// - route-level tests (further down) exercise
|
||||
// src/app/api/purchase-orders/route.ts over HTTP, including the auth() gate.
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
import { GET, POST } from "@/app/api/purchase-orders/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/purchase-orders", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// PurchaseOrder.vendorId/receivedById are real FKs to Vendor/Employee (see
|
||||
// schema.prisma / src/lib/purchase-orders.ts) — tests that need valid ids
|
||||
// create real rows first and use their ids. Mirrors
|
||||
// tests/licenses.test.ts's createTestVendor() and tests/requests.test.ts's
|
||||
// createTestEmployee() helpers.
|
||||
function createTestVendor(overrides: { name?: string } = {}) {
|
||||
return prisma.vendor.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Vendor",
|
||||
contactEmail: "[email protected]",
|
||||
contactPhone: "0281234567",
|
||||
address: "123 Test Street",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTestEmployee(overrides: { name?: string; department?: string } = {}) {
|
||||
return prisma.employee.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Employee",
|
||||
department: overrides.department ?? "IT",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. purchase_orders must be cleared before vendors/employees
|
||||
// since purchase_orders has FKs (ON DELETE RESTRICT to vendors, ON DELETE
|
||||
// SET NULL to employees) to both.
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.vendor.deleteMany();
|
||||
await prisma.employee.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("validateCreatePurchaseOrderInput", () => {
|
||||
it("accepts valid input with no receivedAt/receivedById (not yet received)", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.vendorId).toBe("11111111-1111-1111-1111-111111111111");
|
||||
expect(result.value.orderDate).toBeInstanceOf(Date);
|
||||
expect(result.value.expectedDate).toBeInstanceOf(Date);
|
||||
expect(result.value.receivedAt).toBeUndefined();
|
||||
expect(result.value.receivedById).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts valid input with both receivedAt and receivedById explicitly null (not yet received)", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: null,
|
||||
receivedById: null,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid input with both receivedAt and receivedById set (received)", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.receivedAt).toBeInstanceOf(Date);
|
||||
expect(result.value.receivedById).toBe("22222222-2222-2222-2222-222222222222");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects receivedAt set without receivedById", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/receivedAt/i);
|
||||
expect(result.error).toMatch(/receivedById/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects receivedById set without receivedAt", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedById: "22222222-2222-2222-2222-222222222222",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/receivedAt/i);
|
||||
expect(result.error).toMatch(/receivedById/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing vendorId", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/vendorId/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a vendorId that is not a valid UUID", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "not-a-uuid",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/vendorId/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a missing orderDate", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/orderDate/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an invalid expectedDate", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "not-a-date",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/expectedDate/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a receivedById that is not a valid UUID", () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: "not-a-uuid",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toMatch(/receivedById/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPurchaseOrder", () => {
|
||||
it("creates a purchase order that is not yet received", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createPurchaseOrder(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const record = created.value;
|
||||
|
||||
expect(record.vendor_id).toBe(vendor.id);
|
||||
expect(record.received_at).toBeNull();
|
||||
expect(record.received_by).toBeNull();
|
||||
expect(typeof record.id).toBe("string");
|
||||
expect(record.id.length).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(Date.parse(record.order_date))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(record.expected_date))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a purchase order that has already been received", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const employee = await createTestEmployee();
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: employee.id,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createPurchaseOrder(result.value);
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
const record = created.value;
|
||||
|
||||
expect(record.received_by).toBe(employee.id);
|
||||
expect(Number.isNaN(Date.parse(record.received_at as string))).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createPurchaseOrder(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/vendorId/i);
|
||||
expect(created.error).toMatch(/does not reference an existing vendor/i);
|
||||
|
||||
const remaining = await prisma.purchaseOrder.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns a clean error when receivedById doesn't reference an existing employee", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const result = validateCreatePurchaseOrderInput({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
|
||||
const created = await createPurchaseOrder(result.value);
|
||||
expect(created.ok).toBe(false);
|
||||
if (created.ok) return;
|
||||
expect(created.error).toMatch(/receivedById/i);
|
||||
expect(created.error).toMatch(/does not reference an existing employee/i);
|
||||
|
||||
const remaining = await prisma.purchaseOrder.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPurchaseOrders", () => {
|
||||
it("lists created purchase orders, newest first", async () => {
|
||||
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||
|
||||
const first = validateCreatePurchaseOrderInput({
|
||||
vendorId: vendor1.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
});
|
||||
expect(first.ok).toBe(true);
|
||||
if (first.ok) await createPurchaseOrder(first.value);
|
||||
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
const second = validateCreatePurchaseOrderInput({
|
||||
vendorId: vendor2.id,
|
||||
orderDate: "2026-08-02",
|
||||
expectedDate: "2026-08-11",
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
if (second.ok) await createPurchaseOrder(second.value);
|
||||
|
||||
const records = await listPurchaseOrders();
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
// newest first
|
||||
expect(records[0]).toMatchObject({ vendor_id: vendor2.id });
|
||||
expect(records[1]).toMatchObject({ vendor_id: vendor1.id });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no purchase orders", async () => {
|
||||
const records = await listPurchaseOrders();
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/purchase-orders", () => {
|
||||
it("creates a purchase order that is not yet received on valid input", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({ vendor_id: vendor.id, received_at: null, received_by: null });
|
||||
expect(typeof body.id).toBe("string");
|
||||
expect(body.id.length).toBeGreaterThan(0);
|
||||
expect(Number.isNaN(Date.parse(body.order_date))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(body.expected_date))).toBe(false);
|
||||
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a purchase order that has already been received", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: employee.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.received_by).toBe(employee.id);
|
||||
expect(Number.isNaN(Date.parse(body.received_at))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing vendorId", async () => {
|
||||
const res = await POST(
|
||||
postRequest({ orderDate: "2026-08-01", expectedDate: "2026-08-10" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/vendorId/i);
|
||||
});
|
||||
|
||||
it("rejects a vendorId that is a well-formed UUID but doesn't reference an existing vendor", async () => {
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/vendorId/i);
|
||||
expect(body.error).toMatch(/does not reference an existing vendor/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.purchaseOrder.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a receivedById that is a well-formed UUID but doesn't reference an existing employee", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
receivedById: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/receivedById/i);
|
||||
expect(body.error).toMatch(/does not reference an existing employee/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.purchaseOrder.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects receivedAt set without receivedById", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedAt: "2026-08-12",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/receivedAt/i);
|
||||
expect(body.error).toMatch(/receivedById/i);
|
||||
});
|
||||
|
||||
it("rejects receivedById set without receivedAt", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "2026-08-10",
|
||||
receivedById: employee.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/receivedAt/i);
|
||||
expect(body.error).toMatch(/receivedById/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid expectedDate", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
vendorId: vendor.id,
|
||||
orderDate: "2026-08-01",
|
||||
expectedDate: "not-a-date",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/expectedDate/i);
|
||||
});
|
||||
|
||||
it("rejects an invalid JSON body", async () => {
|
||||
const req = new Request("http://localhost/api/purchase-orders", {
|
||||
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/licenses) — 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/purchase-orders", () => {
|
||||
it("lists created purchase orders, newest first", async () => {
|
||||
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||
|
||||
await POST(
|
||||
postRequest({ vendorId: vendor1.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
|
||||
);
|
||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(
|
||||
postRequest({ vendorId: vendor2.id, orderDate: "2026-08-02", expectedDate: "2026-08-11" })
|
||||
);
|
||||
|
||||
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({ vendor_id: vendor2.id });
|
||||
expect(body[1]).toMatchObject({ vendor_id: vendor1.id });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no purchase orders", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth gating on /api/purchase-orders", () => {
|
||||
// Mirrors tests/licenses.test.ts's "auth gating" block — only checks
|
||||
// session presence, per the flat permission model (no role/permission
|
||||
// logic).
|
||||
it("POST /api/purchase-orders returns 401 without a session", async () => {
|
||||
const vendor = await createTestVendor();
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await POST(
|
||||
postRequest({ vendorId: vendor.id, orderDate: "2026-08-01", expectedDate: "2026-08-10" })
|
||||
);
|
||||
|
||||
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.purchaseOrder.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("GET /api/purchase-orders 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" });
|
||||
});
|
||||
});
|
||||
+100
-2
@@ -50,12 +50,44 @@ function createTestEmployee(overrides: { name?: string; department?: string } =
|
||||
});
|
||||
}
|
||||
|
||||
// Request.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
|
||||
// src/lib/requests.ts) — tests that need a valid purchase_order_id create a
|
||||
// real PurchaseOrder row (and the Vendor it requires) first and use its id.
|
||||
// Mirrors tests/licenses.test.ts's createTestVendor()/createTestPurchaseOrder()
|
||||
// helpers.
|
||||
function createTestVendor(overrides: { name?: string } = {}) {
|
||||
return prisma.vendor.create({
|
||||
data: {
|
||||
name: overrides.name ?? "Test Vendor",
|
||||
contactEmail: "[email protected]",
|
||||
contactPhone: "0281234567",
|
||||
address: "123 Test Street",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
||||
return prisma.purchaseOrder.create({
|
||||
data: {
|
||||
vendorId: overrides.vendorId as string,
|
||||
orderDate: new Date("2026-08-01"),
|
||||
expectedDate: new Date("2026-08-10"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test starts from empty tables so listing/order assertions are
|
||||
// deterministic. requests must be cleared before employees since
|
||||
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees.
|
||||
// deterministic. requests must be cleared before employees/purchase_orders
|
||||
// since requests.employee_id has an FK (ON DELETE RESTRICT) to employees
|
||||
// and requests.purchase_order_id has an FK (ON DELETE SET NULL) to
|
||||
// purchase_orders. purchase_orders must be cleared before employees/vendors
|
||||
// (ON DELETE RESTRICT to vendors, mirrors tests/licenses.test.ts) — order:
|
||||
// requests -> purchase_orders -> employees -> vendors.
|
||||
await prisma.request.deleteMany();
|
||||
await prisma.purchaseOrder.deleteMany();
|
||||
await prisma.employee.deleteMany();
|
||||
await prisma.vendor.deleteMany();
|
||||
// Default to a logged-in session — the tests below exercise request
|
||||
// business logic, not auth gating (that's covered by the "auth gating"
|
||||
// describe block further down).
|
||||
@@ -141,6 +173,72 @@ describe("POST /api/requests", () => {
|
||||
const remaining = await prisma.request.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("creates a request with a purchase_order_id that references a real purchase order", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const vendor = await createTestVendor();
|
||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: purchaseOrder.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
||||
});
|
||||
|
||||
it("creates a request with no purchase_order_id (stays null)", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.purchase_order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a purchase_order_id that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: "00000000-0000-0000-0000-000000000000",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchase_order_id/i);
|
||||
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
||||
|
||||
// And it must not have created anything.
|
||||
const remaining = await prisma.request.findMany();
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a purchase_order_id that is not a valid UUID", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const res = await POST(
|
||||
postRequest({
|
||||
employee_id: employee.id,
|
||||
type: "asset",
|
||||
reason: "New laptop for onboarding",
|
||||
purchase_order_id: "not-a-uuid",
|
||||
})
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/purchase_order_id/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/requests", () => {
|
||||
|
||||
Reference in New Issue
Block a user