From 2b3c39a29189970d523eb05b97a043d73801d6c0 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:00:48 -0400 Subject: [PATCH] test: add PurchaseOrder API tests --- tests/purchase-orders.test.ts | 577 ++++++++++++++++++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 tests/purchase-orders.test.ts diff --git a/tests/purchase-orders.test.ts b/tests/purchase-orders.test.ts new file mode 100644 index 0000000..3eccab4 --- /dev/null +++ b/tests/purchase-orders.test.ts @@ -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>; + +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: "sales@testvendor.example", + 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" }); + }); +});