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" }); }); });