Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 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.
|
||||
- **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.
|
||||
- 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)
|
||||
|
||||
@@ -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.
|
||||
- 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).
|
||||
- **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:**
|
||||
- `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()`.
|
||||
- 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`.
|
||||
|
||||
## 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")
|
||||
);
|
||||
@@ -122,6 +122,28 @@ model License {
|
||||
@@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. No FKs on this entity yet: it's the starting point for later
|
||||
// FKs from PurchaseOrder/License (License.vendor string -> License.vendorId
|
||||
// FK retrofit is noted in the doc as a separate follow-up, not done here).
|
||||
// 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)
|
||||
|
||||
@@map("vendors")
|
||||
}
|
||||
|
||||
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||
|
||||
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,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,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