Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
065c8cb59e | ||
|
|
aaab8fd9b7 | ||
|
|
63eb3df33d | ||
|
|
20ec7a11df | ||
|
|
69fa00012b | ||
|
|
b8b1709a31 | ||
|
|
65232fc62c | ||
|
|
3dd1b22828 | ||
|
|
abec58d239 | ||
|
|
fb52f473a5 | ||
|
|
a6d7c7b302 | ||
|
|
4d12885500 | ||
|
|
2b3c39a291 | ||
|
|
ad4b5d7c2f | ||
|
|
cc98ca4d51 | ||
|
|
58235112f0 | ||
|
|
7b5ab70596 | ||
|
|
9d99ebb425 | ||
|
|
9a4a3fede3 | ||
|
|
f613763ba4 | ||
|
|
283d94f07a | ||
|
|
19b652142a | ||
|
|
07ef1582d3 | ||
|
|
f325abf22c | ||
|
|
e3a3780253 | ||
|
|
513e70ff52 |
@@ -117,7 +117,7 @@ Với mỗi mục tiêu nghiệp vụ stakeholder đưa ra (vd "thêm tracking s
|
|||||||
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng.
|
- **Lane A (Vendor → License.vendorId → PurchaseOrder → purchaseOrderId x3)**: (1-4) `Vendor` entity chuẩn 4 commit (model+migration/lib/API/test), không phụ thuộc gì. (5) Retrofit `License.vendor` (string) → `License.vendorId` (FK) — **1 commit gộp** (schema+migration+lib+test), theo đúng tiền lệ retrofit `Request.employeeId` (commit `49d96b7`, không tách lớp như entity mới vì tách ra sẽ để lại state nửa vời không chạy được) — tiện thể fix luôn finding cũ của `code-reviewer`: `createLicense()` chưa theo pattern `{ok,value}|{ok,error}`, giờ **bắt buộc** phải thêm vì `vendorId` là FK mới (cần bắt `P2003`). Phụ thuộc (1). (6-9) `PurchaseOrder` entity chuẩn 4 commit (`vendorId` FK, `receivedBy` FK→`Employee`, Zod `.refine()` cho ràng buộc `receivedAt`/`receivedBy` cùng null hoặc cùng có giá trị, bắt `P2003` cho cả 2 FK, theo đúng pattern `{ok,error}` ngay từ đầu) — phụ thuộc (1) `Vendor` + `Employee` (đã có). (10) `AssetItem.purchaseOrderId` — 1 commit gộp (nullable FK). (11) `License.purchaseOrderId` — 1 commit gộp **riêng** với task (5) dù cùng file, vì trigger phụ thuộc khác nhau (`Vendor` vs `PurchaseOrder`) — gộp chung sẽ vi phạm nguyên tắc 1-commit-1-thay-đổi-nghiệp-vụ. (12) `Request.purchaseOrderId` — 1 commit gộp. (10)(11)(12) đều chỉ phụ thuộc (6), độc lập lẫn nhau, thứ tự giữa 3 task này không quan trọng.
|
||||||
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)).
|
- **Lane B (AssetCategory → AssetCategoryAttributeDef + AssetModel → wire dynamic validation → AssetItem.modelId)**: **gap phát hiện khi sequencing** — `AssetCategory`/`AssetModel`/`ConsumableItem` đã có spec từ round BA trước (2026-08-02, xem "Phân loại tài sản" trong doc) nhưng **CHƯA build** (không có trong `prisma/schema.prisma` hiện tại, `AssetItem.model` vẫn là string tự do) — phải làm trước thì `AssetCategoryAttributeDef` mới có ý nghĩa. (13-16) `AssetCategory` entity chuẩn 4 commit (`name`, `valueGroup` enum `high_value`/`low_value`), không phụ thuộc gì. (17-20) `AssetCategoryAttributeDef` entity chuẩn 4 commit (`categoryId` FK, `key`, `label`, `dataType` enum, `required`, `sortOrder`, unique `(categoryId,key)` → bắt `P2002`) — phụ thuộc (13), độc lập với `AssetModel`. (21-24) `AssetModel` entity chuẩn 4 commit (`categoryId` FK + business rule "category phải `valueGroup=high_value`" — cần lookup DB, không phải Zod thuần; `manufacturer`, `name`, `specs` JSONB — ở task này `specs` chỉ validate lỏng là object hợp lệ, CHƯA khớp động theo `AssetCategoryAttributeDef`) — phụ thuộc (13), độc lập với (17-20). (25) Wire dynamic specs validation vào `AssetModel` create/update — **1 commit riêng**, không gộp vào (22), vì đây là logic mới/rủi ro (build Zod schema runtime từ `AssetCategoryAttributeDef`, map `dataType`→`z.string()/number()/boolean()/coerce.date()`, từ chối key lạ, bắt buộc key `required`) — phụ thuộc (18) và (22). (26) Retrofit `AssetItem.model` (string) → `AssetItem.modelId` (FK) — 1 commit gộp, phụ thuộc (21) (không cần đợi (25)).
|
||||||
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate.
|
- **Lane C (`Assignment`, độc lập hoàn toàn với Lane A/B — chỉ cần `Employee`+`AssetItem`+`License` đã có sẵn)**: (27) model+migration (`employeeId` FK, `assetItemId`/`licenseId` nullable FK, `assignedAt`, `returnedAt` nullable, `signedAt` nullable). (28) lib validation+CRUD — rủi ro nhất trong cả 4 entity: Zod `.refine()` XOR `assetItemId`/`licenseId`, chặn >1 `Assignment` active/`AssetItem`, transaction tăng/giảm `License.seatsUsed` kèm chặn overbook (`seatsUsed >= seatsTotal`), đồng bộ `AssetItem.status` (assigned/in_stock), helper transfer (đóng cũ+mở mới cùng 1 transaction). (29) API routes — **cần quyết định shape mới**: đây là entity đầu tiên cần thao tác "update" (return/transfer) trong khi codebase hiện tại **mọi entity đều chỉ có POST(create)+GET(list), chưa có route update nào** — đề xuất mặc định `POST /api/assignments` (assign/transfer) + `PATCH /api/assignments/[id]` (return, nhận `{returnedAt}}`), cần xác nhận lại khi giao task cho `pm`/`feature-implementer` vì không có tiền lệ. (30) tests. **(31) Bắt buộc code-reviewer pass riêng cho Assignment (diff 27-30) trước khi coi là xong** — đúng rule ở "Multi-agent dev workflow" (state-mutation-với-invariant, seat-count) — không phải 1 commit, là 1 gate.
|
||||||
- **2 câu hỏi mở cần stakeholder trả lời trước khi code Lane C (không tự đoán)**: (a) `createAssignment()` có cần chặn nếu `AssetItem.status` hiện tại **không phải** `in_stock` không (vd không cho assign 1 item đang `repair`/`retired`)? Spec hiện chỉ mô tả status **tự đổi theo sau** khi assign/return, không nói rõ có precondition chặn assign hay không — nếu không chặn, 1 asset "retired" hoặc "repair" (miễn chưa có `Assignment` active nào) vẫn assign được, có vẻ sai nghiệp vụ nhưng chưa được xác nhận rõ. (b) REST shape cho "update" (PO nhận hàng, Assignment return/transfer) — đề xuất ở trên (`PATCH .../[id]`) là suy đoán hợp lý theo REST convention chuẩn, chưa có tiền lệ nào trong codebase để đối chiếu, nên xác nhận với `pm` trước khi `feature-implementer` tự bịa shape khác nhau giữa `PurchaseOrder` và `Assignment`.
|
- **2 câu hỏi mở đã được stakeholder trả lời (2026-08-02, xem "Assignment" trong `docs/asset-license-management.md`) — Lane C hết bị chặn**: (a) `createAssignment()` **chặn** nếu `AssetItem.status` khác `in_stock` (400). (b) Route update dùng `PATCH /api/<entity>/[id]` (áp dụng cho cả `Assignment` và `PurchaseOrder`), không dùng action-based sub-route.
|
||||||
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này.
|
- `ConsumableItem` (phần catalog, KHÔNG phải luồng "xin+nhận" — luồng đó vẫn BỊ CHẶN chờ 7 câu hỏi mở, xem doc): độc lập với toàn bộ 3 lane trên (chỉ cần `AssetCategory`), sẵn sàng cho `pm` chia task riêng nếu stakeholder muốn gộp vào cùng đợt — không đưa vào sequencing này vì nằm ngoài phạm vi 4 việc được giao lần này.
|
||||||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
|
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled
|
||||||
- UI (hiện chỉ có placeholder home page)
|
- UI (hiện chỉ có placeholder home page)
|
||||||
|
|||||||
@@ -107,11 +107,13 @@ Entity mới, độc lập với `AssetModel`.
|
|||||||
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
|
- Assign 1 `License` phải **tăng** `License.seatsUsed`, và phải **bị từ chối** nếu `seatsUsed >= seatsTotal` (không overbook). Trả lại (set `returnedAt`) **giảm** `seatsUsed`. Toàn bộ phải chạy trong 1 transaction — đây chính là rủi ro seat-count race condition mà `code-reviewer` đã note trước đó cho `License` (xem "Trạng thái hiện tại" ở `../CLAUDE.md`, mục `License`) — `Assignment` là chỗ thực sự implement/exercise seat counting lần đầu tiên.
|
||||||
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
|
- Chuyển tài sản giữa nhân viên (case đặc biệt đã note ở trên: "Tài sản luân chuyển giữa phòng ban / chi nhánh") = đóng `Assignment` cũ (`returnedAt = now`) + tạo `Assignment` mới, cả hai trong 1 transaction. Không cần entity "Transfer" riêng.
|
||||||
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
|
- `AssetItem.status` phải đồng bộ theo trạng thái `Assignment`: tạo `Assignment` → status `assigned`; trả lại → status `in_stock`, trừ nhánh RMA/sửa chữa đã note ở "Case đặc biệt cần xử lý" (đó là case riêng, không đi qua path trả-lại-thông-thường này).
|
||||||
|
- **Chốt 2026-08-02 (trả lời 2 câu hỏi mở của `pm`):** (a) `createAssignment()` phải **chặn** nếu `AssetItem.status` hiện tại khác `in_stock` (vd không cho assign 1 item đang `repair`/`retired`) — trả lỗi 400, không chỉ dựa vào điều kiện "chưa có Assignment active nào". (b) Route "update" (trả lại/chuyển giao `Assignment`, nhận hàng `PurchaseOrder`) dùng `PATCH /api/<entity>/[id]` — tiền lệ REST-shape đầu tiên cho update trong codebase, áp dụng thống nhất cho cả `Assignment` và `PurchaseOrder`, không dùng action-based sub-route.
|
||||||
|
|
||||||
**Ràng buộc dữ liệu:**
|
**Ràng buộc dữ liệu:**
|
||||||
- `Assignment.employeeId` bắt buộc, trỏ tới 1 `Employee` có thật.
|
- `Assignment.employeeId` bắt buộc, trỏ tới 1 `Employee` có thật.
|
||||||
- Đúng 1 trong `assetItemId`/`licenseId` được set (XOR) — validate Zod `.refine()`.
|
- Đúng 1 trong `assetItemId`/`licenseId` được set (XOR) — validate Zod `.refine()`.
|
||||||
- Không được có > 1 `Assignment` active cho cùng 1 `AssetItem` tại 1 thời điểm (enforce ở tầng application, không phải DB constraint).
|
- Không được có > 1 `Assignment` active cho cùng 1 `AssetItem` tại 1 thời điểm (enforce ở tầng application, không phải DB constraint).
|
||||||
|
- Tạo `Assignment` bị chặn nếu `AssetItem.status` khác `in_stock` (400).
|
||||||
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
|
- `License.seatsUsed` tăng/giảm đồng bộ trong 1 transaction khi assign/return; assign bị chặn nếu `seatsUsed >= seatsTotal`.
|
||||||
|
|
||||||
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
|
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "vendors" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"contact_email" TEXT NOT NULL,
|
||||||
|
"contact_phone" TEXT NOT NULL,
|
||||||
|
"address" TEXT NOT NULL,
|
||||||
|
"tax_code" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "vendors_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `vendor` on the `licenses` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `vendor_id` to the `licenses` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "licenses" DROP COLUMN "vendor",
|
||||||
|
ADD COLUMN "vendor_id" UUID NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
+136
-13
@@ -40,6 +40,15 @@ model Request {
|
|||||||
// plain string (not an enum) until more states exist — see
|
// plain string (not an enum) until more states exist — see
|
||||||
// src/lib/requests.ts's RequestStatus type.
|
// src/lib/requests.ts's RequestStatus type.
|
||||||
status String @default("Draft")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("requests")
|
@@map("requests")
|
||||||
@@ -58,6 +67,9 @@ model Employee {
|
|||||||
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
|
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
|
||||||
reports Employee[] @relation("EmployeeManager")
|
reports Employee[] @relation("EmployeeManager")
|
||||||
requests Request[]
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("employees")
|
@@map("employees")
|
||||||
@@ -75,11 +87,16 @@ enum AssetItemStatus {
|
|||||||
|
|
||||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
// 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).
|
// (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
|
// No Assignment relation yet — that entity doesn't exist yet (see "Việc còn
|
||||||
// (see "Việc còn dở" in CLAUDE.md), so this is a standalone entity for now,
|
// dở" in CLAUDE.md). warrantyEndDate is nullable: not every asset has known
|
||||||
// same starting shape as Employee before Request grew an FK to it.
|
// warranty terms at intake time (smallest sensible assumption — doesn't
|
||||||
// warrantyEndDate is nullable: not every asset has known warranty terms at
|
// block creation). purchaseOrderId is a nullable FK to PurchaseOrder — see
|
||||||
// intake time (smallest sensible assumption — doesn't block creation).
|
// 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.
|
||||||
model AssetItem {
|
model AssetItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
serialNumber String @unique @map("serial_number")
|
serialNumber String @unique @map("serial_number")
|
||||||
@@ -87,6 +104,8 @@ model AssetItem {
|
|||||||
purchaseDate DateTime @map("purchase_date") @db.Date
|
purchaseDate DateTime @map("purchase_date") @db.Date
|
||||||
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||||
status AssetItemStatus @default(in_stock)
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("asset_items")
|
@@map("asset_items")
|
||||||
@@ -102,26 +121,130 @@ enum LicenseType {
|
|||||||
|
|
||||||
// Software license inventory — see docs/asset-license-management.md's "Data
|
// Software license inventory — see docs/asset-license-management.md's "Data
|
||||||
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
|
// model (sơ bộ)" table (tên, vendor, tổng seat, seat đã dùng, ngày hết hạn,
|
||||||
// loại). `vendor` is deliberately a plain String for now, not a separate
|
// loại). `vendorId` was a free-text `vendor` String until the "## Vendor
|
||||||
// Vendor entity — no Vendor entity exists yet, same "plain string for now"
|
// (chốt 2026-08-02)" retrofit — same precedent as Request.employeeId
|
||||||
// precedent as Employee.department. `seatsUsed` is a stored counter
|
// (string -> FK once Employee existed). No production `License` data
|
||||||
// (defaults to 0 at creation), not a computed value — it will only ever be
|
// existed at the time of this change, so this was a plain column-type
|
||||||
// set to 0 here since Assignment (the thing that would consume seats)
|
// migration, not a backfill. `seatsUsed` is a stored counter (defaults to 0
|
||||||
// doesn't exist yet. `expiryDate` is nullable: perpetual licenses never
|
// at creation), not a computed value — it will only ever be set to 0 here
|
||||||
// expire, only subscription licenses usually have one.
|
// 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. `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 {
|
model License {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
vendor String
|
vendorId String @map("vendor_id") @db.Uuid
|
||||||
|
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||||
seatsTotal Int @map("seats_total")
|
seatsTotal Int @map("seats_total")
|
||||||
seatsUsed Int @default(0) @map("seats_used")
|
seatsUsed Int @default(0) @map("seats_used")
|
||||||
expiryDate DateTime? @map("expiry_date") @db.Date
|
expiryDate DateTime? @map("expiry_date") @db.Date
|
||||||
licenseType LicenseType @map("license_type")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("licenses")
|
@@map("licenses")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purchasing counterparty — see docs/asset-license-management.md's "## Vendor
|
||||||
|
// (chốt 2026-08-02)". Deliberately independent of AssetModel.manufacturer
|
||||||
|
// (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). `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
|
||||||
|
contactEmail String @map("contact_email")
|
||||||
|
contactPhone String @map("contact_phone")
|
||||||
|
address String
|
||||||
|
taxCode String? @map("tax_code")
|
||||||
|
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)
|
||||||
|
|
||||||
|
@@map("asset_categories")
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||||
|
|||||||
@@ -0,0 +1,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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
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, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||||
|
body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetItemInput({
|
const validation = validateCreateAssetItemInput({
|
||||||
serialNumber,
|
serialNumber,
|
||||||
model,
|
model,
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
|
purchaseOrderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
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 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, vendor, seatsTotal, expiryDate, licenseType } = body as Record<string, unknown>;
|
const { name, vendorId, seatsTotal, expiryDate, licenseType, purchaseOrderId } =
|
||||||
|
body as Record<string, unknown>;
|
||||||
const validation = validateCreateLicenseInput({
|
const validation = validateCreateLicenseInput({
|
||||||
name,
|
name,
|
||||||
vendor,
|
vendorId,
|
||||||
seatsTotal,
|
seatsTotal,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
licenseType,
|
licenseType,
|
||||||
|
purchaseOrderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
@@ -36,7 +38,10 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await createLicense(validation.value);
|
const created = await createLicense(validation.value);
|
||||||
return NextResponse.json(created, { status: 201 });
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(): Promise<NextResponse> {
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
|||||||
@@ -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 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { employee_id, type, reason } = body as Record<string, unknown>;
|
const { employee_id, type, reason, purchase_order_id } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateRequestInput({ employee_id, type, reason });
|
const validation = validateCreateRequestInput({ employee_id, type, reason, purchase_order_id });
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
|||||||
Vendored
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { createVendor, listVendors, validateCreateVendorInput } from "@/lib/vendors";
|
||||||
|
|
||||||
|
// 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, contactEmail, contactPhone, address, taxCode, notes } =
|
||||||
|
body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateVendorInput({
|
||||||
|
name,
|
||||||
|
contactEmail,
|
||||||
|
contactPhone,
|
||||||
|
address,
|
||||||
|
taxCode,
|
||||||
|
notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createVendor(validation.value);
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendors = await listVendors();
|
||||||
|
return NextResponse.json(vendors, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export type AssetCategoryValueGroup = "high_value" | "low_value";
|
||||||
|
|
||||||
|
export interface AssetCategoryRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value_group: AssetCategoryValueGroup;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssetCategoryInput {
|
||||||
|
name: string;
|
||||||
|
valueGroup: AssetCategoryValueGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateAssetCategoryInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// Mirrors src/lib/vendors.ts's nonEmptyString helper.
|
||||||
|
function nonEmptyString(message: string) {
|
||||||
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||||
|
(value) => value.trim() !== "",
|
||||||
|
{ message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAssetCategorySchema = z.object({
|
||||||
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
|
// Fixed 2-value set (see docs/asset-license-management.md's "## Phân
|
||||||
|
// loại tài sản" section) — mirrors src/lib/licenses.ts's licenseType
|
||||||
|
// z.enum() pattern.
|
||||||
|
valueGroup: z.enum(["high_value", "low_value"], {
|
||||||
|
errorMap: () => ({
|
||||||
|
message: "valueGroup is required and must be either 'high_value' or 'low_value'",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) asset-category-creation input. name must be a
|
||||||
|
* non-empty string, valueGroup must be one of the two fixed enum values
|
||||||
|
* (per the "## Phân loại tài sản" spec in docs/asset-license-management.md).
|
||||||
|
*/
|
||||||
|
export function validateCreateAssetCategoryInput(input: {
|
||||||
|
name?: unknown;
|
||||||
|
valueGroup?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createAssetCategorySchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
valueGroup: AssetCategoryValueGroup;
|
||||||
|
createdAt: Date;
|
||||||
|
}): AssetCategoryRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
value_group: row.valueGroup,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAssetCategoryResult =
|
||||||
|
| { ok: true; value: AssetCategoryRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AssetCategory. No unique constraint on any field, no FKs
|
||||||
|
* either (see docs/asset-license-management.md's "## Phân loại tài sản"
|
||||||
|
* spec — standalone entity, same starting shape as Vendor before it grew
|
||||||
|
* relations) — so there's no known Prisma error code to catch/translate
|
||||||
|
* here today. Still returns the `{ok,value}|{ok,error}` shape (rather than
|
||||||
|
* a bare Promise<AssetCategoryRecord> like createVendor()) to match the
|
||||||
|
* convention used across the newer entities (AssetItem/License) — see the
|
||||||
|
* code-reviewer finding on createLicense() in CLAUDE.md's "Trạng thái hiện
|
||||||
|
* tại": adding this shape up front avoids a silent 500 if a unique
|
||||||
|
* constraint is added later (e.g. on `name`) without remembering to also
|
||||||
|
* add P2002 handling here.
|
||||||
|
*/
|
||||||
|
export async function createAssetCategory(
|
||||||
|
input: CreateAssetCategoryInput
|
||||||
|
): Promise<CreateAssetCategoryResult> {
|
||||||
|
const created = await prisma.assetCategory.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
valueGroup: input.valueGroup,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssetCategories(): Promise<AssetCategoryRecord[]> {
|
||||||
|
const rows = await prisma.assetCategory.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
+25
-1
@@ -11,6 +11,7 @@ export interface AssetItemRecord {
|
|||||||
purchase_date: string;
|
purchase_date: string;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
purchase_order_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export interface CreateAssetItemInput {
|
|||||||
model: string;
|
model: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate?: Date;
|
warrantyEndDate?: Date;
|
||||||
|
purchaseOrderId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -48,12 +50,24 @@ const createAssetItemSchema = z.object({
|
|||||||
model: nonEmptyString("model is required and must be a non-empty string"),
|
model: nonEmptyString("model is required and must be a non-empty string"),
|
||||||
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
||||||
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
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
|
* Validates raw (untyped) asset-item-creation input. serialNumber/model
|
||||||
* must be non-empty strings, purchaseDate must be a valid date string,
|
* must be non-empty strings, purchaseDate must be a valid date string,
|
||||||
* warrantyEndDate is optional but must be a valid date string if present.
|
* 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
|
* status is not accepted here — new assets always start "in_stock" (same
|
||||||
* pattern as Request always starting "Draft"), later states are set via
|
* pattern as Request always starting "Draft"), later states are set via
|
||||||
* other operations, not at creation.
|
* other operations, not at creation.
|
||||||
@@ -63,6 +77,7 @@ export function validateCreateAssetItemInput(input: {
|
|||||||
model?: unknown;
|
model?: unknown;
|
||||||
purchaseDate?: unknown;
|
purchaseDate?: unknown;
|
||||||
warrantyEndDate?: unknown;
|
warrantyEndDate?: unknown;
|
||||||
|
purchaseOrderId?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetItemSchema.safeParse(input);
|
const result = createAssetItemSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -79,6 +94,7 @@ function toRecord(row: {
|
|||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate: Date | null;
|
warrantyEndDate: Date | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
purchaseOrderId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): AssetItemRecord {
|
}): AssetItemRecord {
|
||||||
return {
|
return {
|
||||||
@@ -88,6 +104,7 @@ function toRecord(row: {
|
|||||||
purchase_date: row.purchaseDate.toISOString(),
|
purchase_date: row.purchaseDate.toISOString(),
|
||||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
purchase_order_id: row.purchaseOrderId,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -102,7 +119,10 @@ export type CreateAssetItemResult =
|
|||||||
* caught here and translated into the same `{ok:false,error}` shape as
|
* caught here and translated into the same `{ok:false,error}` shape as
|
||||||
* input validation failures, rather than letting a raw Prisma error leak
|
* input validation failures, rather than letting a raw Prisma error leak
|
||||||
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
||||||
* handling for FK violations.
|
* handling for FK violations. If purchaseOrderId doesn't reference an
|
||||||
|
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) —
|
||||||
|
* caught the same way, mirrors src/lib/purchase-orders.ts's
|
||||||
|
* createPurchaseOrder() P2003 handling.
|
||||||
*/
|
*/
|
||||||
export async function createAssetItem(
|
export async function createAssetItem(
|
||||||
input: CreateAssetItemInput
|
input: CreateAssetItemInput
|
||||||
@@ -114,6 +134,7 @@ export async function createAssetItem(
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
purchaseDate: input.purchaseDate,
|
purchaseDate: input.purchaseDate,
|
||||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||||
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return { ok: true, value: toRecord(created) };
|
||||||
@@ -121,6 +142,9 @@ export async function createAssetItem(
|
|||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
return { ok: false, error: "serialNumber already exists" };
|
return { ok: false, error: "serialNumber already exists" };
|
||||||
}
|
}
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
|
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-19
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export type LicenseType = "perpetual" | "subscription";
|
export type LicenseType = "perpetual" | "subscription";
|
||||||
@@ -6,20 +7,22 @@ export type LicenseType = "perpetual" | "subscription";
|
|||||||
export interface LicenseRecord {
|
export interface LicenseRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendor_id: string;
|
||||||
seats_total: number;
|
seats_total: number;
|
||||||
seats_used: number;
|
seats_used: number;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
license_type: LicenseType;
|
license_type: LicenseType;
|
||||||
|
purchase_order_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLicenseInput {
|
export interface CreateLicenseInput {
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendorId: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
expiryDate?: Date | null;
|
expiryDate?: Date | null;
|
||||||
licenseType: LicenseType;
|
licenseType: LicenseType;
|
||||||
|
purchaseOrderId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -46,7 +49,14 @@ function dateString(message: string) {
|
|||||||
|
|
||||||
const createLicenseSchema = z.object({
|
const createLicenseSchema = z.object({
|
||||||
name: nonEmptyString("name is required and must be a non-empty string"),
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
vendor: nonEmptyString("vendor is required and must be a non-empty string"),
|
// Now a real FK to Vendor (see schema.prisma) — only a UUID *format*
|
||||||
|
// check here — whether it references a real vendor is left to Prisma's
|
||||||
|
// own FK constraint at insert time (see createLicense()'s P2003 handling
|
||||||
|
// below), rather than an extra pre-check query. Mirrors the same pattern
|
||||||
|
// used for Request.employeeId in src/lib/requests.ts.
|
||||||
|
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"),
|
||||||
seatsTotal: z
|
seatsTotal: z
|
||||||
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
|
.number({ required_error: "seatsTotal is required and must be a positive integer", invalid_type_error: "seatsTotal is required and must be a positive integer" })
|
||||||
.int({ message: "seatsTotal is required and must be a positive integer" })
|
.int({ message: "seatsTotal is required and must be a positive integer" })
|
||||||
@@ -57,23 +67,36 @@ const createLicenseSchema = z.object({
|
|||||||
message: "licenseType is required and must be either 'perpetual' or 'subscription'",
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates raw (untyped) license-creation input. name/vendor must be
|
* Validates raw (untyped) license-creation input. name must be a non-empty
|
||||||
* non-empty strings, seatsTotal must be a positive integer, expiryDate is
|
* string, vendorId must be a valid UUID string, seatsTotal must be a
|
||||||
* optional/nullable but must be a valid date string if present, licenseType
|
* positive integer, expiryDate is optional/nullable but must be a valid
|
||||||
* must be one of the two enum values. seatsUsed is not accepted here — new
|
* date string if present, licenseType must be one of the two enum values.
|
||||||
* licenses always start at 0 (same pattern as AssetItem always starting
|
* seatsUsed is not accepted here — new licenses always start at 0 (same
|
||||||
* "in_stock"), seats are only ever consumed via other operations (once
|
* pattern as AssetItem always starting "in_stock"), seats are only ever
|
||||||
* Assignment exists), not at creation.
|
* 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: {
|
export function validateCreateLicenseInput(input: {
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
vendor?: unknown;
|
vendorId?: unknown;
|
||||||
seatsTotal?: unknown;
|
seatsTotal?: unknown;
|
||||||
expiryDate?: unknown;
|
expiryDate?: unknown;
|
||||||
licenseType?: unknown;
|
licenseType?: unknown;
|
||||||
|
purchaseOrderId?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createLicenseSchema.safeParse(input);
|
const result = createLicenseSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -86,41 +109,63 @@ export function validateCreateLicenseInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
vendor: string;
|
vendorId: string;
|
||||||
seatsTotal: number;
|
seatsTotal: number;
|
||||||
seatsUsed: number;
|
seatsUsed: number;
|
||||||
expiryDate: Date | null;
|
expiryDate: Date | null;
|
||||||
licenseType: LicenseType;
|
licenseType: LicenseType;
|
||||||
|
purchaseOrderId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): LicenseRecord {
|
}): LicenseRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
vendor: row.vendor,
|
vendor_id: row.vendorId,
|
||||||
seats_total: row.seatsTotal,
|
seats_total: row.seatsTotal,
|
||||||
seats_used: row.seatsUsed,
|
seats_used: row.seatsUsed,
|
||||||
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
expiry_date: row.expiryDate ? row.expiryDate.toISOString() : null,
|
||||||
license_type: row.licenseType,
|
license_type: row.licenseType,
|
||||||
|
purchase_order_id: row.purchaseOrderId,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CreateLicenseResult =
|
||||||
|
| { ok: true; value: LicenseRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a License. No unique constraint exists on `name` (unlike
|
* Creates a License. Two separate FKs can fail here (vendorId -> Vendor,
|
||||||
* AssetItem.serialNumber), so unlike createAssetItem() there's no known
|
* purchaseOrderId -> PurchaseOrder) — Prisma raises the same error code
|
||||||
* Prisma error code to catch/translate here.
|
* (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<LicenseRecord> {
|
export async function createLicense(input: CreateLicenseInput): Promise<CreateLicenseResult> {
|
||||||
|
try {
|
||||||
const created = await prisma.license.create({
|
const created = await prisma.license.create({
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
vendor: input.vendor,
|
vendorId: input.vendorId,
|
||||||
seatsTotal: input.seatsTotal,
|
seatsTotal: input.seatsTotal,
|
||||||
expiryDate: input.expiryDate ?? null,
|
expiryDate: input.expiryDate ?? null,
|
||||||
licenseType: input.licenseType,
|
licenseType: input.licenseType,
|
||||||
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return toRecord(created);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listLicenses(): Promise<LicenseRecord[]> {
|
export async function listLicenses(): Promise<LicenseRecord[]> {
|
||||||
|
|||||||
@@ -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;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: RequestStatus;
|
status: RequestStatus;
|
||||||
|
purchase_order_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export interface CreateRequestInput {
|
|||||||
employee_id: string;
|
employee_id: string;
|
||||||
type: RequestType;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
purchase_order_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -55,6 +57,18 @@ const createRequestSchema = z.object({
|
|||||||
message: "type is required and must be exactly 'asset' or 'license'",
|
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;
|
employee_id?: unknown;
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
reason?: unknown;
|
reason?: unknown;
|
||||||
|
purchase_order_id?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createRequestSchema.safeParse(input);
|
const result = createRequestSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -81,6 +96,7 @@ function toRecord(row: {
|
|||||||
type: RequestType;
|
type: RequestType;
|
||||||
reason: string;
|
reason: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
purchaseOrderId: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): RequestRecord {
|
}): RequestRecord {
|
||||||
return {
|
return {
|
||||||
@@ -89,6 +105,7 @@ function toRecord(row: {
|
|||||||
type: row.type,
|
type: row.type,
|
||||||
reason: row.reason,
|
reason: row.reason,
|
||||||
status: row.status as RequestStatus,
|
status: row.status as RequestStatus,
|
||||||
|
purchase_order_id: row.purchaseOrderId,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -98,11 +115,13 @@ export type CreateRequestResult =
|
|||||||
| { ok: false; error: string };
|
| { ok: false; error: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a Request. If employee_id doesn't reference an existing Employee,
|
* Creates a Request. Two separate FKs can fail here (employee_id ->
|
||||||
* Prisma raises a known error (code P2003, FK violation) — caught here and
|
* Employee, purchase_order_id -> PurchaseOrder) — Prisma raises the same
|
||||||
* translated into the same `{ok:false,error}` shape as input validation
|
* error code (P2003) for either, so the FK constraint name in
|
||||||
* failures, rather than letting a raw Prisma error leak to the API layer.
|
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||||
* Mirrors src/lib/employees.ts's createEmployee() pattern for managerId.
|
* 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> {
|
export async function createRequest(input: CreateRequestInput): Promise<CreateRequestResult> {
|
||||||
try {
|
try {
|
||||||
@@ -111,11 +130,16 @@ export async function createRequest(input: CreateRequestInput): Promise<CreateRe
|
|||||||
employeeId: input.employee_id,
|
employeeId: input.employee_id,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
|
purchaseOrderId: input.purchase_order_id ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return { ok: true, value: toRecord(created) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
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" };
|
return { ok: false, error: "employee_id does not reference an existing employee" };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export interface VendorRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contact_email: string;
|
||||||
|
contact_phone: string;
|
||||||
|
address: string;
|
||||||
|
tax_code: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVendorInput {
|
||||||
|
name: string;
|
||||||
|
contactEmail: string;
|
||||||
|
contactPhone: string;
|
||||||
|
address: string;
|
||||||
|
taxCode?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateVendorInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// Mirrors src/lib/licenses.ts's nonEmptyString helper.
|
||||||
|
function nonEmptyString(message: string) {
|
||||||
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||||
|
(value) => value.trim() !== "",
|
||||||
|
{ message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVendorSchema = z.object({
|
||||||
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
|
contactEmail: z
|
||||||
|
.string({
|
||||||
|
required_error: "contactEmail is required and must be a valid email",
|
||||||
|
invalid_type_error: "contactEmail is required and must be a valid email",
|
||||||
|
})
|
||||||
|
.email("contactEmail is required and must be a valid email"),
|
||||||
|
contactPhone: nonEmptyString("contactPhone is required and must be a non-empty string"),
|
||||||
|
address: nonEmptyString("address is required and must be a non-empty string"),
|
||||||
|
// Nullable, per docs/asset-license-management.md's "## Vendor" spec — not
|
||||||
|
// every vendor record has a known VN tax code at creation time.
|
||||||
|
taxCode: nonEmptyString("taxCode must be a non-empty string").optional().nullable(),
|
||||||
|
notes: nonEmptyString("notes must be a non-empty string").optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) vendor-creation input. name/contactPhone/address
|
||||||
|
* must be non-empty strings, contactEmail must be a valid email string,
|
||||||
|
* taxCode/notes are optional/nullable (per the "## Vendor" spec in
|
||||||
|
* docs/asset-license-management.md) but must be non-empty strings if
|
||||||
|
* present.
|
||||||
|
*/
|
||||||
|
export function validateCreateVendorInput(input: {
|
||||||
|
name?: unknown;
|
||||||
|
contactEmail?: unknown;
|
||||||
|
contactPhone?: unknown;
|
||||||
|
address?: unknown;
|
||||||
|
taxCode?: unknown;
|
||||||
|
notes?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createVendorSchema.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;
|
||||||
|
contactEmail: string;
|
||||||
|
contactPhone: string;
|
||||||
|
address: string;
|
||||||
|
taxCode: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): VendorRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
contact_email: row.contactEmail,
|
||||||
|
contact_phone: row.contactPhone,
|
||||||
|
address: row.address,
|
||||||
|
tax_code: row.taxCode,
|
||||||
|
notes: row.notes,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Vendor. No unique constraint exists on any field, no FKs
|
||||||
|
* either (see docs/asset-license-management.md's "## Vendor" spec — this
|
||||||
|
* is a standalone entity, same starting shape as Employee/License before
|
||||||
|
* they grew relations) — so unlike createAssetItem()/createEmployee()
|
||||||
|
* there's no known Prisma error code to catch/translate here.
|
||||||
|
*/
|
||||||
|
export async function createVendor(input: CreateVendorInput): Promise<VendorRecord> {
|
||||||
|
const created = await prisma.vendor.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
contactEmail: input.contactEmail,
|
||||||
|
contactPhone: input.contactPhone,
|
||||||
|
address: input.address,
|
||||||
|
taxCode: input.taxCode ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return toRecord(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVendors(): Promise<VendorRecord[]> {
|
||||||
|
const rows = await prisma.vendor.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createAssetCategory,
|
||||||
|
listAssetCategories,
|
||||||
|
validateCreateAssetCategoryInput,
|
||||||
|
} from "@/lib/asset-categories";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Note: this file has two layers of coverage, mirroring tests/vendors.test.ts:
|
||||||
|
// - lib-level tests below exercise src/lib/asset-categories.ts directly
|
||||||
|
// (validation, valueGroup enum, list ordering).
|
||||||
|
// - route-level tests (further down) exercise
|
||||||
|
// src/app/api/asset-categories/route.ts over HTTP, including the auth()
|
||||||
|
// gate — mirrors tests/vendors.test.ts's @/auth mocking pattern.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/asset-categories/route";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
|
||||||
|
|
||||||
|
const testSession: Session = {
|
||||||
|
user: { id: "test-user-id" },
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function postRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/asset-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts from an empty table so listing/order assertions are
|
||||||
|
// deterministic.
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
|
// Default to a logged-in session — the lib-level tests below don't go
|
||||||
|
// through the route at all (so this is a no-op for them), and the
|
||||||
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
|
// covered by the "auth gating" describe block further down).
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCreateAssetCategoryInput", () => {
|
||||||
|
it("accepts a valid high_value category", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Laptop",
|
||||||
|
valueGroup: "high_value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({ name: "Laptop", valueGroup: "high_value" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid low_value category", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({ name: "Cáp HDMI", valueGroup: "low_value" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "",
|
||||||
|
valueGroup: "high_value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing name", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
valueGroup: "high_value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid valueGroup", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Laptop",
|
||||||
|
valueGroup: "medium_value",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/valueGroup/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing valueGroup", () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Laptop",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/valueGroup/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAssetCategory", () => {
|
||||||
|
it("creates a high_value category", async () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Laptop",
|
||||||
|
valueGroup: "high_value",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetCategory(result.value);
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value).toMatchObject({ name: "Laptop", value_group: "high_value" });
|
||||||
|
expect(typeof created.value.id).toBe("string");
|
||||||
|
expect(created.value.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a low_value category", async () => {
|
||||||
|
const result = validateCreateAssetCategoryInput({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetCategory(result.value);
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAssetCategories", () => {
|
||||||
|
it("lists created categories, newest first", async () => {
|
||||||
|
const first = validateCreateAssetCategoryInput({ name: "Cáp HDMI", valueGroup: "low_value" });
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (first.ok) await createAssetCategory(first.value);
|
||||||
|
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const second = validateCreateAssetCategoryInput({ name: "Laptop", valueGroup: "high_value" });
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (second.ok) await createAssetCategory(second.value);
|
||||||
|
|
||||||
|
const records = await listAssetCategories();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
// newest first
|
||||||
|
expect(records[0]).toMatchObject({ name: "Laptop" });
|
||||||
|
expect(records[1]).toMatchObject({ name: "Cáp HDMI" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no categories", async () => {
|
||||||
|
const records = await listAssetCategories();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/asset-categories", () => {
|
||||||
|
it("creates a high_value category on valid input", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({ name: "Laptop", value_group: "high_value" });
|
||||||
|
expect(typeof body.id).toBe("string");
|
||||||
|
expect(body.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a low_value category on valid input", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({ name: "Cáp HDMI", value_group: "low_value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "", valueGroup: "high_value" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/name/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid valueGroup", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Laptop", valueGroup: "medium_value" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/valueGroup/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing valueGroup", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Laptop" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/valueGroup/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/asset-categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(req);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object JSON body", async () => {
|
||||||
|
// Note: arrays are `typeof "object"` in JS, so they pass the route's
|
||||||
|
// object check (same behavior as /api/vendors) — use a JSON string
|
||||||
|
// primitive here to actually exercise the "not an object" branch.
|
||||||
|
const res = await POST(postRequest("not an object"));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON object/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/asset-categories", () => {
|
||||||
|
it("lists created categories, newest first", async () => {
|
||||||
|
await POST(postRequest({ name: "Cáp HDMI", valueGroup: "low_value" }));
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
|
// newest first
|
||||||
|
expect(body[0]).toMatchObject({ name: "Laptop" });
|
||||||
|
expect(body[1]).toMatchObject({ name: "Cáp HDMI" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no categories", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/asset-categories", () => {
|
||||||
|
// Mirrors tests/vendors.test.ts's "auth gating" block — only checks
|
||||||
|
// session presence, per the flat permission model (no role/permission
|
||||||
|
// logic).
|
||||||
|
it("POST /api/asset-categories returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ name: "Laptop", valueGroup: "high_value" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetCategory.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/asset-categories returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+97
-2
@@ -24,10 +24,42 @@ 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"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from an empty table so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic.
|
// deterministic. asset_items must be cleared before purchase_orders/
|
||||||
|
// vendors since asset_items has FKs (ON DELETE SET NULL to
|
||||||
|
// purchase_orders) — deleteMany order avoids relying on that cascade
|
||||||
|
// behavior in tests. purchase_orders must be cleared before vendors
|
||||||
|
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts).
|
||||||
await prisma.assetItem.deleteMany();
|
await prisma.assetItem.deleteMany();
|
||||||
|
await prisma.purchaseOrder.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise business
|
// Default to a logged-in session — the tests below exercise business
|
||||||
// logic, not auth gating (that's covered by the "auth gating" describe
|
// logic, not auth gating (that's covered by the "auth gating" describe
|
||||||
// block further down).
|
// block further down).
|
||||||
@@ -111,6 +143,69 @@ describe("POST /api/assets", () => {
|
|||||||
expect(body.error).toMatch(/warrantyEndDate/i);
|
expect(body.error).toMatch(/warrantyEndDate/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates an asset item 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({
|
||||||
|
serialNumber: "SN-007",
|
||||||
|
model: "Dell Latitude 5440",
|
||||||
|
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 res = await POST(
|
||||||
|
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", 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 res = await POST(
|
||||||
|
postRequest({
|
||||||
|
serialNumber: "SN-009",
|
||||||
|
model: "Dell Latitude 5440",
|
||||||
|
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 res = await POST(
|
||||||
|
postRequest({
|
||||||
|
serialNumber: "SN-012",
|
||||||
|
model: "Dell Latitude 5440",
|
||||||
|
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 () => {
|
it("rejects a duplicate serialNumber", async () => {
|
||||||
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
|
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 res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
|
||||||
|
|||||||
+294
-39
@@ -34,10 +34,45 @@ function postRequest(body: unknown): Request {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// License.vendorId is a real FK to Vendor (see schema.prisma /
|
||||||
|
// src/lib/licenses.ts) — tests that need a valid vendorId create a real
|
||||||
|
// Vendor row first and use its id, rather than an arbitrary string. Mirrors
|
||||||
|
// tests/requests.test.ts's createTestEmployee() 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from an empty table so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic.
|
// 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.license.deleteMany();
|
||||||
|
await prisma.purchaseOrder.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
// Default to a logged-in session — the lib-level tests below don't go
|
// 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
|
// 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
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
@@ -49,7 +84,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("accepts valid input with an expiryDate", () => {
|
it("accepts valid input with an expiryDate", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -59,7 +94,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.value).toMatchObject({
|
expect(result.value).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -70,7 +105,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
it("accepts valid input with no expiryDate (perpetual license)", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -84,7 +119,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an empty name", () => {
|
it("rejects an empty name", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "",
|
name: "",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -95,24 +130,37 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty vendor", () => {
|
it("rejects a vendorId that is not a valid UUID", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "",
|
vendorId: "not-a-uuid",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
expect(result.error).toMatch(/vendor/i);
|
expect(result.error).toMatch(/vendorId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing vendorId", () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/vendorId/i);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a zero seatsTotal", () => {
|
it("rejects a zero seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 0,
|
seatsTotal: 0,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -126,7 +174,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a negative seatsTotal", () => {
|
it("rejects a negative seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: -5,
|
seatsTotal: -5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -140,7 +188,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a non-integer seatsTotal", () => {
|
it("rejects a non-integer seatsTotal", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 3.5,
|
seatsTotal: 3.5,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -154,7 +202,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an invalid expiryDate", () => {
|
it("rejects an invalid expiryDate", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -169,7 +217,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects an invalid licenseType", () => {
|
it("rejects an invalid licenseType", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "trial",
|
licenseType: "trial",
|
||||||
});
|
});
|
||||||
@@ -183,7 +231,7 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
it("rejects a missing licenseType", () => {
|
it("rejects a missing licenseType", () => {
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: "11111111-1111-1111-1111-111111111111",
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,13 +240,29 @@ describe("validateCreateLicenseInput", () => {
|
|||||||
expect(result.error).toMatch(/licenseType/i);
|
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", () => {
|
describe("createLicense", () => {
|
||||||
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
it("creates a license with seatsUsed always 0, regardless of client input", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
// @ts-expect-error -- seatsUsed is intentionally not part of
|
// @ts-expect-error -- seatsUsed is intentionally not part of
|
||||||
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
// CreateLicenseInput; this simulates a client trying to sneak it in.
|
||||||
@@ -209,12 +273,15 @@ describe("createLicense", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
|
|
||||||
const record = await createLicense(result.value);
|
const created = await createLicense(result.value);
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
const record = created.value;
|
||||||
|
|
||||||
expect(record.seats_used).toBe(0);
|
expect(record.seats_used).toBe(0);
|
||||||
expect(record).toMatchObject({
|
expect(record).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendor_id: vendor.id,
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
});
|
});
|
||||||
@@ -225,28 +292,113 @@ describe("createLicense", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a perpetual license with no expiryDate", async () => {
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const result = validateCreateLicenseInput({
|
const result = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
|
|
||||||
const record = await createLicense(result.value);
|
const created = await createLicense(result.value);
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
const record = created.value;
|
||||||
|
|
||||||
expect(record.expiry_date).toBeNull();
|
expect(record.expiry_date).toBeNull();
|
||||||
expect(record.seats_used).toBe(0);
|
expect(record.seats_used).toBe(0);
|
||||||
expect(record.license_type).toBe("perpetual");
|
expect(record.license_type).toBe("perpetual");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when vendorId doesn't reference an existing vendor", async () => {
|
||||||
|
const result = validateCreateLicenseInput({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
});
|
||||||
|
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(/vendorId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing vendor/i);
|
||||||
|
|
||||||
|
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", () => {
|
describe("listLicenses", () => {
|
||||||
it("lists created licenses, newest first", async () => {
|
it("lists created licenses, newest first", async () => {
|
||||||
|
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||||
|
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||||
|
|
||||||
const first = validateCreateLicenseInput({
|
const first = validateCreateLicenseInput({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor1.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
});
|
});
|
||||||
@@ -258,7 +410,7 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
const second = validateCreateLicenseInput({
|
const second = validateCreateLicenseInput({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor2.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
});
|
});
|
||||||
@@ -269,8 +421,8 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
expect(records).toHaveLength(2);
|
expect(records).toHaveLength(2);
|
||||||
// newest first
|
// newest first
|
||||||
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
expect(records[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||||
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
expect(records[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
@@ -281,10 +433,11 @@ describe("listLicenses", () => {
|
|||||||
|
|
||||||
describe("POST /api/licenses", () => {
|
describe("POST /api/licenses", () => {
|
||||||
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
it("creates a license with seatsUsed always 0 on valid input", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "2027-01-15",
|
expiryDate: "2027-01-15",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -296,7 +449,7 @@ describe("POST /api/licenses", () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendor_id: vendor.id,
|
||||||
seats_total: 10,
|
seats_total: 10,
|
||||||
seats_used: 0,
|
seats_used: 0,
|
||||||
license_type: "subscription",
|
license_type: "subscription",
|
||||||
@@ -308,10 +461,11 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates a perpetual license with no expiryDate", async () => {
|
it("creates a perpetual license with no expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Windows Server 2022",
|
name: "Windows Server 2022",
|
||||||
vendor: "Microsoft",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 5,
|
seatsTotal: 5,
|
||||||
licenseType: "perpetual",
|
licenseType: "perpetual",
|
||||||
})
|
})
|
||||||
@@ -324,8 +478,9 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty name", async () => {
|
it("rejects an empty name", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ name: "", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -333,19 +488,45 @@ describe("POST /api/licenses", () => {
|
|||||||
expect(body.error).toMatch(/name/i);
|
expect(body.error).toMatch(/name/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty vendor", async () => {
|
it("rejects a vendorId that is not a valid UUID", async () => {
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ name: "Adobe Creative Cloud", vendor: "", seatsTotal: 10, licenseType: "subscription" })
|
postRequest({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendorId: "not-a-uuid",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/vendor/i);
|
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({
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendorId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
seatsTotal: 10,
|
||||||
|
licenseType: "subscription",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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.license.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a zero seatsTotal", async () => {
|
it("rejects a zero seatsTotal", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 0, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 0, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -354,10 +535,11 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid expiryDate", async () => {
|
it("rejects an invalid expiryDate", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
name: "Adobe Creative Cloud",
|
name: "Adobe Creative Cloud",
|
||||||
vendor: "Adobe",
|
vendorId: vendor.id,
|
||||||
seatsTotal: 10,
|
seatsTotal: 10,
|
||||||
expiryDate: "not-a-date",
|
expiryDate: "not-a-date",
|
||||||
licenseType: "subscription",
|
licenseType: "subscription",
|
||||||
@@ -370,8 +552,9 @@ describe("POST /api/licenses", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid licenseType", async () => {
|
it("rejects an invalid licenseType", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "trial" })
|
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "trial" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -379,6 +562,74 @@ describe("POST /api/licenses", () => {
|
|||||||
expect(body.error).toMatch(/licenseType/i);
|
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 () => {
|
it("rejects an invalid JSON body", async () => {
|
||||||
const req = new Request("http://localhost/api/licenses", {
|
const req = new Request("http://localhost/api/licenses", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -406,13 +657,16 @@ describe("POST /api/licenses", () => {
|
|||||||
|
|
||||||
describe("GET /api/licenses", () => {
|
describe("GET /api/licenses", () => {
|
||||||
it("lists created licenses, newest first", async () => {
|
it("lists created licenses, newest first", async () => {
|
||||||
|
const vendor1 = await createTestVendor({ name: "Microsoft VN" });
|
||||||
|
const vendor2 = await createTestVendor({ name: "Adobe VN" });
|
||||||
|
|
||||||
await POST(
|
await POST(
|
||||||
postRequest({ name: "Windows Server 2022", vendor: "Microsoft", seatsTotal: 5, licenseType: "perpetual" })
|
postRequest({ name: "Windows Server 2022", vendorId: vendor1.id, seatsTotal: 5, licenseType: "perpetual" })
|
||||||
);
|
);
|
||||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(
|
await POST(
|
||||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor2.id, seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET();
|
||||||
@@ -423,8 +677,8 @@ describe("GET /api/licenses", () => {
|
|||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor: "Adobe" });
|
expect(body[0]).toMatchObject({ name: "Adobe Creative Cloud", vendor_id: vendor2.id });
|
||||||
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor: "Microsoft" });
|
expect(body[1]).toMatchObject({ name: "Windows Server 2022", vendor_id: vendor1.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no licenses", async () => {
|
it("returns an empty list when there are no licenses", async () => {
|
||||||
@@ -440,10 +694,11 @@ describe("auth gating on /api/licenses", () => {
|
|||||||
// session presence, per the flat permission model (no role/permission
|
// session presence, per the flat permission model (no role/permission
|
||||||
// logic).
|
// logic).
|
||||||
it("POST /api/licenses returns 401 without a session", async () => {
|
it("POST /api/licenses returns 401 without a session", async () => {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ name: "Adobe Creative Cloud", vendor: "Adobe", seatsTotal: 10, licenseType: "subscription" })
|
postRequest({ name: "Adobe Creative Cloud", vendorId: vendor.id, seatsTotal: 10, licenseType: "subscription" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -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 () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from empty tables so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic. requests must be cleared before employees since
|
// deterministic. requests must be cleared before employees/purchase_orders
|
||||||
// requests.employee_id has an FK (ON DELETE RESTRICT) to employees.
|
// 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.request.deleteMany();
|
||||||
|
await prisma.purchaseOrder.deleteMany();
|
||||||
await prisma.employee.deleteMany();
|
await prisma.employee.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise request
|
// Default to a logged-in session — the tests below exercise request
|
||||||
// business logic, not auth gating (that's covered by the "auth gating"
|
// business logic, not auth gating (that's covered by the "auth gating"
|
||||||
// describe block further down).
|
// describe block further down).
|
||||||
@@ -141,6 +173,72 @@ describe("POST /api/requests", () => {
|
|||||||
const remaining = await prisma.request.findMany();
|
const remaining = await prisma.request.findMany();
|
||||||
expect(remaining).toHaveLength(0);
|
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", () => {
|
describe("GET /api/requests", () => {
|
||||||
|
|||||||
@@ -0,0 +1,476 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createVendor,
|
||||||
|
listVendors,
|
||||||
|
validateCreateVendorInput,
|
||||||
|
} from "@/lib/vendors";
|
||||||
|
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/vendors.ts directly (validation,
|
||||||
|
// nullable taxCode/notes, list ordering).
|
||||||
|
// - route-level tests (further down) exercise src/app/api/vendors/route.ts
|
||||||
|
// over HTTP, including the auth() gate — mirrors tests/licenses.test.ts's
|
||||||
|
// @/auth mocking pattern.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/vendors/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/vendors", {
|
||||||
|
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.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
|
||||||
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
|
// covered by the "auth gating" describe block further down).
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCreateVendorInput", () => {
|
||||||
|
it("accepts valid input with taxCode and notes", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid input with no taxCode/notes", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "Synnex FPT",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0287654321",
|
||||||
|
address: "456 Le Van Viet, Q9, HCMC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.taxCode).toBeUndefined();
|
||||||
|
expect(result.value.notes).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid contactEmail", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "not-an-email",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/contactEmail/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing contactEmail", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/contactEmail/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty contactPhone", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/contactPhone/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty address", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/address/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty taxCode when provided", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/taxCode/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a null taxCode/notes explicitly", () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.taxCode).toBeNull();
|
||||||
|
expect(result.value.notes).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createVendor", () => {
|
||||||
|
it("creates a vendor with taxCode and notes", async () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const record = await createVendor(result.value);
|
||||||
|
|
||||||
|
expect(record).toMatchObject({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contact_email: "[email protected]",
|
||||||
|
contact_phone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
tax_code: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
});
|
||||||
|
expect(typeof record.id).toBe("string");
|
||||||
|
expect(record.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(record.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a vendor with no taxCode/notes (both null)", async () => {
|
||||||
|
const result = validateCreateVendorInput({
|
||||||
|
name: "Synnex FPT",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0287654321",
|
||||||
|
address: "456 Le Van Viet, Q9, HCMC",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const record = await createVendor(result.value);
|
||||||
|
|
||||||
|
expect(record.tax_code).toBeNull();
|
||||||
|
expect(record.notes).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listVendors", () => {
|
||||||
|
it("lists created vendors, newest first", async () => {
|
||||||
|
const first = validateCreateVendorInput({
|
||||||
|
name: "Synnex FPT",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0287654321",
|
||||||
|
address: "456 Le Van Viet, Q9, HCMC",
|
||||||
|
});
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (first.ok) await createVendor(first.value);
|
||||||
|
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const second = validateCreateVendorInput({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
});
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (second.ok) await createVendor(second.value);
|
||||||
|
|
||||||
|
const records = await listVendors();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
// newest first
|
||||||
|
expect(records[0]).toMatchObject({ name: "FPT Trading" });
|
||||||
|
expect(records[1]).toMatchObject({ name: "Synnex FPT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no vendors", async () => {
|
||||||
|
const records = await listVendors();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/vendors", () => {
|
||||||
|
it("creates a vendor on valid input", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
taxCode: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contact_email: "[email protected]",
|
||||||
|
contact_phone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
tax_code: "0301234567",
|
||||||
|
notes: "Preferred reseller for Dell laptops",
|
||||||
|
});
|
||||||
|
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 vendor with no taxCode/notes", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "Synnex FPT",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0287654321",
|
||||||
|
address: "456 Le Van Viet, Q9, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.tax_code).toBeNull();
|
||||||
|
expect(body.notes).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/name/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid contactEmail", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "not-an-email",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/contactEmail/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty contactPhone", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/contactPhone/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty address", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/address/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/vendors", {
|
||||||
|
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/vendors", () => {
|
||||||
|
it("lists created vendors, newest first", async () => {
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "Synnex FPT",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0287654321",
|
||||||
|
address: "456 Le Van Viet, Q9, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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: "FPT Trading" });
|
||||||
|
expect(body[1]).toMatchObject({ name: "Synnex FPT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no vendors", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/vendors", () => {
|
||||||
|
// 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/vendors returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
name: "FPT Trading",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Nguyen Van Linh, Q7, HCMC",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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.vendor.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/vendors returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user