Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d12885500 | ||
|
|
2b3c39a291 | ||
|
|
ad4b5d7c2f | ||
|
|
cc98ca4d51 | ||
|
|
58235112f0 |
@@ -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;
|
||||
+56
-21
@@ -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.
|
||||
|
||||
@@ -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