Merge branch 'feat/purchase-order'
PurchaseOrder entity: Prisma model+migration, Zod+CRUD (P2003 on both vendorId/receivedById, receivedAt/receivedById co-nullability refine), API routes, tests. Verified green by test-runner (127/127 tests, tsc/lint/build clean) before merge. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -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;
|
||||||
+39
-4
@@ -58,6 +58,9 @@ model Employee {
|
|||||||
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
|
manager Employee? @relation("EmployeeManager", fields: [managerId], references: [id])
|
||||||
reports Employee[] @relation("EmployeeManager")
|
reports Employee[] @relation("EmployeeManager")
|
||||||
requests Request[]
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("employees")
|
@@map("employees")
|
||||||
@@ -130,10 +133,10 @@ model License {
|
|||||||
// (OEM, e.g. "Dell") — Vendor is the reseller/distributor actually bought
|
// (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
|
// from (e.g. "FPT Trading"), a different concept that isn't always the same
|
||||||
// company. `licenses` is the back-relation from the License.vendor ->
|
// company. `licenses` is the back-relation from the License.vendor ->
|
||||||
// License.vendorId retrofit (same doc section) — PurchaseOrder is a future
|
// License.vendorId retrofit (same doc section). `purchaseOrders` is the
|
||||||
// FK from this entity, not built yet. taxCode/notes are nullable — not
|
// back-relation from PurchaseOrder.vendorId (see "## PurchaseOrder (gộp
|
||||||
// every vendor record has a known VN tax code or extra notes at creation
|
// Receipt) (chốt 2026-08-02)"). taxCode/notes are nullable — not every
|
||||||
// time.
|
// vendor record has a known VN tax code or extra notes at creation time.
|
||||||
model Vendor {
|
model Vendor {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
@@ -144,10 +147,42 @@ model Vendor {
|
|||||||
notes String?
|
notes String?
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
licenses License[]
|
licenses License[]
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
|
||||||
@@map("vendors")
|
@@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,
|
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||||
|
|||||||
@@ -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<NextResponse> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseOrders = await listPurchaseOrders();
|
||||||
|
return NextResponse.json(purchaseOrders, { status: 200 });
|
||||||
|
}
|
||||||
@@ -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<CreatePurchaseOrderResult> {
|
||||||
|
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<PurchaseOrderRecord[]> {
|
||||||
|
const rows = await prisma.purchaseOrder.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
@@ -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<Session | null>>;
|
||||||
|
|
||||||
|
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: "[email protected]",
|
||||||
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user