From f613763ba44340c7b34841ffe2cabd091f38cbe4 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 19:29:48 -0400 Subject: [PATCH] test: add Vendor API tests --- tests/vendors.test.ts | 476 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 tests/vendors.test.ts diff --git a/tests/vendors.test.ts b/tests/vendors.test.ts new file mode 100644 index 0000000..98b315b --- /dev/null +++ b/tests/vendors.test.ts @@ -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>; + +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" }); + }); +});