Merge branch 'feat/vendor'
Vendor entity: Prisma model+migration, Zod+CRUD, API routes, tests. Verified green by test-runner (94/94 tests, tsc/lint/build clean) before merge. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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")
|
@@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,
|
// 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.
|
||||||
|
|||||||
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