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>; 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "contact@synnexfpt.com", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "contact@synnexfpt.com", 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: "contact@synnexfpt.com", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "contact@synnexfpt.com", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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: "contact@synnexfpt.com", 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: "sales@fpt.com.vn", 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: "sales@fpt.com.vn", 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" }); }); });