From 58235112f0cb00fbe39996d10e0909f4c2477ae1 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 19:57:58 -0400 Subject: [PATCH 1/4] feat: add PurchaseOrder Prisma model + migration --- .../migration.sql | 18 +++++ prisma/schema.prisma | 77 ++++++++++++++----- 2 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260802235729_add_purchase_order/migration.sql diff --git a/prisma/migrations/20260802235729_add_purchase_order/migration.sql b/prisma/migrations/20260802235729_add_purchase_order/migration.sql new file mode 100644 index 0000000..35aba7e --- /dev/null +++ b/prisma/migrations/20260802235729_add_purchase_order/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "purchase_orders" ( + "id" UUID NOT NULL, + "vendor_id" UUID NOT NULL, + "order_date" TIMESTAMPTZ(6) NOT NULL, + "expected_date" TIMESTAMPTZ(6) NOT NULL, + "received_at" TIMESTAMPTZ(6), + "received_by" UUID, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "purchase_orders_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_vendor_id_fkey" FOREIGN KEY ("vendor_id") REFERENCES "vendors"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "purchase_orders" ADD CONSTRAINT "purchase_orders_received_by_fkey" FOREIGN KEY ("received_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a3919c..b6850fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,14 +51,17 @@ model Request { // possible future normalization, not built yet. `managerId` is a nullable // self-relation: top-level employees (no manager) have `managerId = null`. model Employee { - id String @id @default(uuid()) @db.Uuid - name String - department String - managerId String? @map("manager_id") @db.Uuid - manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) - reports Employee[] @relation("EmployeeManager") - requests Request[] - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + id String @id @default(uuid()) @db.Uuid + name String + department String + managerId String? @map("manager_id") @db.Uuid + manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id]) + reports Employee[] @relation("EmployeeManager") + requests Request[] + // Back-relation for PurchaseOrder.receivedById — an Employee can be the + // receiver on multiple POs over time. + purchaseOrdersReceived PurchaseOrder[] + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@map("employees") } @@ -130,24 +133,56 @@ model License { // (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. `licenses` is the back-relation from the License.vendor -> -// License.vendorId retrofit (same doc section) — PurchaseOrder is a future -// FK from this entity, not built yet. taxCode/notes are nullable — not -// every vendor record has a known VN tax code or extra notes at creation -// time. +// License.vendorId retrofit (same doc section). `purchaseOrders` is the +// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp +// Receipt) (chốt 2026-08-02)"). 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) - licenses License[] + 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) + licenses License[] + purchaseOrders PurchaseOrder[] @@map("vendors") } +// Purchasing document — see docs/asset-license-management.md's "## +// PurchaseOrder (gộp Receipt) (chốt 2026-08-02)". Deliberately absorbs what +// would otherwise be a separate `Receipt` entity: receiving is just part of +// this row's own state (`receivedAt`/`receivedBy`), not a separate table to +// reconcile against. No `status` field — status is inferred from linked +// `Request`s (Ordered -> Received -> Delivered), not stored here, to avoid +// two sources of truth. No cost/price fields — stakeholder said explicitly +// PurchaseOrder doesn't need to track money, only "which asset/license +// belongs to which PO". Receiving is all-or-nothing per PO (no partial +// receipt tracking) — a deliberate simplification, see doc for the +// stakeholder-accepted tradeoff. `receivedAt`/`receivedBy` are both +// nullable and must be co-null/co-set together (both-null = not yet +// received) — enforced at the Zod layer (src/lib/purchase-orders.ts), not a +// DB constraint, same approach as other cross-field invariants in this +// codebase (e.g. Assignment's assetItemId/licenseId XOR, not built yet). +// purchaseOrderId back-references on AssetItem/License/Request (traceability, +// same doc section) are NOT part of this change — out of scope for this task. +model PurchaseOrder { + id String @id @default(uuid()) @db.Uuid + vendorId String @map("vendor_id") @db.Uuid + vendor Vendor @relation(fields: [vendorId], references: [id]) + orderDate DateTime @map("order_date") @db.Timestamptz(6) + expectedDate DateTime @map("expected_date") @db.Timestamptz(6) + receivedAt DateTime? @map("received_at") @db.Timestamptz(6) + receivedById String? @map("received_by") @db.Uuid + receivedBy Employee? @relation(fields: [receivedById], references: [id]) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + @@map("purchase_orders") +} + // Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // see CLAUDE.md / auth decision notes). A handful of trusted IT staff // accounts, created by hand via scripts/create-user.ts, no self-registration. From cc98ca4d512f8163d4084e4ef4d12e849c14f0aa Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 19:59:05 -0400 Subject: [PATCH 2/4] feat: add PurchaseOrder validation + CRUD (src/lib/purchase-orders.ts) --- src/lib/purchase-orders.ts | 161 +++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/lib/purchase-orders.ts diff --git a/src/lib/purchase-orders.ts b/src/lib/purchase-orders.ts new file mode 100644 index 0000000..45c8970 --- /dev/null +++ b/src/lib/purchase-orders.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { prisma } from "./prisma"; + +export interface PurchaseOrderRecord { + id: string; + vendor_id: string; + order_date: string; + expected_date: string; + received_at: string | null; + received_by: string | null; + created_at: string; +} + +export interface CreatePurchaseOrderInput { + vendorId: string; + orderDate: Date; + expectedDate: Date; + receivedAt?: Date | null; + receivedById?: string | null; +} + +export type ValidationResult = + | { ok: true; value: CreatePurchaseOrderInput } + | { ok: false; error: string }; + +// Mirrors src/lib/licenses.ts's dateString helper — accepts any string +// Date.parse() can handle and transforms it into a Date for Prisma. Used +// here for orderDate/expectedDate/receivedAt, which are all Timestamptz +// (not just Date) columns, so datetime strings (not just "2026-07-31") are +// also accepted. +function dateString(message: string) { + return z + .string({ required_error: message, invalid_type_error: message }) + .refine((value) => !Number.isNaN(Date.parse(value)), { message }) + .transform((value) => new Date(value)); +} + +const createPurchaseOrderSchema = z + .object({ + // FK to Vendor — only a UUID *format* check here, whether it references + // a real vendor is left to Prisma's own FK constraint at insert time + // (see createPurchaseOrder()'s P2003 handling below). Mirrors + // src/lib/licenses.ts's vendorId handling. + vendorId: z + .string({ required_error: "vendorId is required and must be a valid UUID string" }) + .uuid("vendorId is required and must be a valid UUID string"), + orderDate: dateString("orderDate is required and must be a valid date string"), + expectedDate: dateString("expectedDate is required and must be a valid date string"), + // Both nullable — null means "not yet received". Co-nullability with + // receivedById is enforced by the .refine() below, not here. + receivedAt: dateString("receivedAt must be a valid date string").optional().nullable(), + receivedById: z + .string({ invalid_type_error: "receivedById must be a valid UUID string" }) + .uuid("receivedById must be a valid UUID string") + .optional() + .nullable(), + }) + .refine( + (value) => { + const hasReceivedAt = value.receivedAt !== undefined && value.receivedAt !== null; + const hasReceivedById = value.receivedById !== undefined && value.receivedById !== null; + return hasReceivedAt === hasReceivedById; + }, + { + message: "receivedAt and receivedById must both be set or both be unset", + // Attach the error to both fields so it surfaces regardless of which + // one the caller looks at first. + path: ["receivedAt"], + } + ); + +/** + * Validates raw (untyped) purchase-order-creation input. vendorId must be a + * valid UUID string, orderDate/expectedDate must be valid date strings, + * receivedAt/receivedById are optional/nullable but must both be set or + * both be unset (co-nullability invariant — receiving a PO always records + * both "when" and "who" together). No status field is accepted — PurchaseOrder + * deliberately has no stored status (see schema.prisma comment), it's + * inferred from linked Requests. + */ +export function validateCreatePurchaseOrderInput(input: { + vendorId?: unknown; + orderDate?: unknown; + expectedDate?: unknown; + receivedAt?: unknown; + receivedById?: unknown; +}): ValidationResult { + const result = createPurchaseOrderSchema.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; + vendorId: string; + orderDate: Date; + expectedDate: Date; + receivedAt: Date | null; + receivedById: string | null; + createdAt: Date; +}): PurchaseOrderRecord { + return { + id: row.id, + vendor_id: row.vendorId, + order_date: row.orderDate.toISOString(), + expected_date: row.expectedDate.toISOString(), + received_at: row.receivedAt ? row.receivedAt.toISOString() : null, + received_by: row.receivedById, + created_at: row.createdAt.toISOString(), + }; +} + +export type CreatePurchaseOrderResult = + | { ok: true; value: PurchaseOrderRecord } + | { ok: false; error: string }; + +/** + * Creates a PurchaseOrder. Two separate FKs can fail here (vendorId -> + * Vendor, receivedById -> Employee) — Prisma raises the same error code + * (P2003) for either, so the FK constraint name in `error.meta.field_name` + * is used to tell them apart and return a targeted message, rather than a + * raw Prisma error leaking to the API layer. Mirrors + * src/lib/licenses.ts's createLicense() pattern, extended for the + * two-FK case. + */ +export async function createPurchaseOrder( + input: CreatePurchaseOrderInput +): Promise { + try { + const created = await prisma.purchaseOrder.create({ + data: { + vendorId: input.vendorId, + orderDate: input.orderDate, + expectedDate: input.expectedDate, + receivedAt: input.receivedAt ?? null, + receivedById: input.receivedById ?? null, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : ""; + if (fieldName.includes("received_by")) { + return { ok: false, error: "receivedById does not reference an existing employee" }; + } + return { ok: false, error: "vendorId does not reference an existing vendor" }; + } + throw error; + } +} + +export async function listPurchaseOrders(): Promise { + const rows = await prisma.purchaseOrder.findMany({ + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + }); + return rows.map(toRecord); +} From ad4b5d7c2fa95baf8c32e5eaf1cd7366fa74cd17 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 19:59:18 -0400 Subject: [PATCH 3/4] feat: add PurchaseOrder API routes --- src/app/api/purchase-orders/route.ts | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/app/api/purchase-orders/route.ts diff --git a/src/app/api/purchase-orders/route.ts b/src/app/api/purchase-orders/route.ts new file mode 100644 index 0000000..c4934fd --- /dev/null +++ b/src/app/api/purchase-orders/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { + createPurchaseOrder, + listPurchaseOrders, + validateCreatePurchaseOrderInput, +} from "@/lib/purchase-orders"; + +// 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 { + 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 { vendorId, orderDate, expectedDate, receivedAt, receivedById } = + body as Record; + const validation = validateCreatePurchaseOrderInput({ + vendorId, + orderDate, + expectedDate, + receivedAt, + receivedById, + }); + + if (!validation.ok) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const created = await createPurchaseOrder(validation.value); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + return NextResponse.json(created.value, { status: 201 }); +} + +export async function GET(): Promise { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const purchaseOrders = await listPurchaseOrders(); + return NextResponse.json(purchaseOrders, { status: 200 }); +} From 2b3c39a29189970d523eb05b97a043d73801d6c0 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Sun, 2 Aug 2026 20:00:48 -0400 Subject: [PATCH 4/4] 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" }); + }); +});