From 7e69efa4a8a8dd8a9a4ede9edbc47e99c0d3706a Mon Sep 17 00:00:00 2001 From: Toan Hoang Minh Date: Fri, 31 Jul 2026 15:14:55 +0700 Subject: [PATCH] fix: update Request validation/tests for Employee FK relation - createRequestSchema now requires employee_id to be a UUID-format string (was a free-text non-empty check), matching the new FK to Employee in schema.prisma. - createRequest() now returns a CreateRequestResult discriminated union and catches Prisma P2003 (FK violation), returning a friendly ok:false error instead of a raw 500 when employee_id does not reference an existing Employee. POST /api/requests branches on created.ok and returns 400 in that case. - tests/requests.test.ts: every test that used to POST a fake string employee_id (emp-1, emp-2) now creates a real Employee first via createTestEmployee() and uses its id, since employee_id is now a real FK. Added coverage for a syntactically invalid UUID and for a well-formed but non-existent UUID (expects the friendly 400 from the P2003 catch, not a raw 500) - the exact case this retrofit was meant to protect against. --- src/app/api/requests/route.ts | 6 ++- src/lib/requests.ts | 46 ++++++++++++++---- tests/requests.test.ts | 88 ++++++++++++++++++++++++++++++----- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 930b40a..2d5ddaf 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -32,7 +32,11 @@ export async function POST(request: Request): Promise { } const created = await createRequest(validation.value); - return NextResponse.json(created, { status: 201 }); + if (!created.ok) { + return NextResponse.json({ error: created.error }, { status: 400 }); + } + + return NextResponse.json(created.value, { status: 201 }); } export async function GET(): Promise { diff --git a/src/lib/requests.ts b/src/lib/requests.ts index 67324b0..eff712e 100644 --- a/src/lib/requests.ts +++ b/src/lib/requests.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { Prisma } from "@prisma/client"; import { prisma } from "./prisma"; export type RequestType = "asset" | "license"; @@ -40,7 +41,14 @@ function nonEmptyString(message: string) { } const createRequestSchema = z.object({ - employee_id: nonEmptyString("employee_id is required and must be a non-empty string"), + // Now a real FK to Employee (see schema.prisma) — only a UUID *format* + // check here — whether it references a real employee is left to Prisma's + // own FK constraint at insert time (see createRequest()'s P2003 handling + // below), rather than an extra pre-check query. Mirrors the same pattern + // used for Employee.managerId in src/lib/employees.ts. + employee_id: z + .string({ required_error: "employee_id is required and must be a valid UUID string" }) + .uuid("employee_id is required and must be a valid UUID string"), reason: nonEmptyString("reason is required and must be a non-empty string"), type: z.enum(["asset", "license"], { errorMap: () => ({ @@ -85,15 +93,33 @@ function toRecord(row: { }; } -export async function createRequest(input: CreateRequestInput): Promise { - const created = await prisma.request.create({ - data: { - employeeId: input.employee_id, - type: input.type, - reason: input.reason, - }, - }); - return toRecord(created); +export type CreateRequestResult = + | { ok: true; value: RequestRecord } + | { ok: false; error: string }; + +/** + * Creates a Request. If employee_id doesn't reference an existing Employee, + * Prisma raises a known error (code P2003, FK violation) — caught here and + * translated into the same `{ok:false,error}` shape as input validation + * failures, rather than letting a raw Prisma error leak to the API layer. + * Mirrors src/lib/employees.ts's createEmployee() pattern for managerId. + */ +export async function createRequest(input: CreateRequestInput): Promise { + try { + const created = await prisma.request.create({ + data: { + employeeId: input.employee_id, + type: input.type, + reason: input.reason, + }, + }); + return { ok: true, value: toRecord(created) }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") { + return { ok: false, error: "employee_id does not reference an existing employee" }; + } + throw error; + } } export async function listRequests(): Promise { diff --git a/tests/requests.test.ts b/tests/requests.test.ts index 24bbc63..d41796b 100644 --- a/tests/requests.test.ts +++ b/tests/requests.test.ts @@ -38,10 +38,24 @@ function postRequest(body: unknown): Request { }); } +// Request.employeeId is a real FK to Employee (see schema.prisma / +// src/lib/requests.ts) — tests that need a valid employee_id create a real +// Employee row first and use its id, rather than an arbitrary string. +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 an empty table so listing/order assertions are - // deterministic. + // Each test starts from empty tables so listing/order assertions are + // deterministic. requests must be cleared before employees since + // requests.employee_id has an FK (ON DELETE RESTRICT) to employees. await prisma.request.deleteMany(); + await prisma.employee.deleteMany(); // Default to a logged-in session — the tests below exercise request // business logic, not auth gating (that's covered by the "auth gating" // describe block further down). @@ -50,15 +64,16 @@ beforeEach(async () => { describe("POST /api/requests", () => { it("creates a request in Draft status on valid input", async () => { + const employee = await createTestEmployee(); const res = await POST( - postRequest({ employee_id: "emp-1", type: "asset", reason: "New laptop for onboarding" }) + postRequest({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding" }) ); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ - employee_id: "emp-1", + employee_id: employee.id, type: "asset", reason: "New laptop for onboarding", status: "Draft", @@ -79,7 +94,8 @@ describe("POST /api/requests", () => { }); it("rejects requests missing the reason field entirely", async () => { - const res = await POST(postRequest({ employee_id: "emp-1", type: "asset" })); + const employee = await createTestEmployee(); + const res = await POST(postRequest({ employee_id: employee.id, type: "asset" })); expect(res.status).toBe(400); const body = await res.json(); @@ -87,22 +103,59 @@ describe("POST /api/requests", () => { }); it("rejects an invalid type value", async () => { + const employee = await createTestEmployee(); const res = await POST( - postRequest({ employee_id: "emp-1", type: "not-a-real-type", reason: "Something" }) + postRequest({ employee_id: employee.id, type: "not-a-real-type", reason: "Something" }) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/type/i); }); + + it("rejects an employee_id that is not a valid UUID", async () => { + const res = await POST( + postRequest({ employee_id: "emp-1", type: "asset", reason: "Something" }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/employee_id/i); + }); + + it("rejects an employee_id that is a well-formed UUID but doesn't reference an existing employee", async () => { + const res = await POST( + postRequest({ + employee_id: "00000000-0000-0000-0000-000000000000", + type: "asset", + reason: "Something", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/employee_id/i); + expect(body.error).toMatch(/does not reference an existing employee/i); + + // And it must not have created anything. + const remaining = await prisma.request.findMany(); + expect(remaining).toHaveLength(0); + }); }); describe("GET /api/requests", () => { it("lists created requests, newest first", async () => { - await POST(postRequest({ employee_id: "emp-1", type: "asset", reason: "First request" })); + const employee1 = await createTestEmployee({ name: "Employee One" }); + const employee2 = await createTestEmployee({ name: "Employee Two" }); + + await POST( + postRequest({ employee_id: employee1.id, type: "asset", reason: "First request" }) + ); // Ensure a distinct created_at ordering even on fast filesystems/clocks. await new Promise((resolve) => setTimeout(resolve, 5)); - await POST(postRequest({ employee_id: "emp-2", type: "license", reason: "Second request" })); + await POST( + postRequest({ employee_id: employee2.id, type: "license", reason: "Second request" }) + ); const res = await GET(); expect(res.status).toBe(200); @@ -112,8 +165,16 @@ describe("GET /api/requests", () => { expect(body).toHaveLength(2); // newest first - expect(body[0]).toMatchObject({ employee_id: "emp-2", type: "license", status: "Draft" }); - expect(body[1]).toMatchObject({ employee_id: "emp-1", type: "asset", status: "Draft" }); + expect(body[0]).toMatchObject({ + employee_id: employee2.id, + type: "license", + status: "Draft", + }); + expect(body[1]).toMatchObject({ + employee_id: employee1.id, + type: "asset", + status: "Draft", + }); }); it("returns an empty list when there are no requests", async () => { @@ -131,10 +192,15 @@ describe("auth gating on /api/requests", () => { // No role/permission logic here, per the flat permission model (any // logged-in user can do anything) — this only checks session presence. it("POST /api/requests returns 401 without a session", async () => { + const employee = await createTestEmployee(); mockedAuth.mockResolvedValue(null); const res = await POST( - postRequest({ employee_id: "emp-1", type: "asset", reason: "New laptop for onboarding" }) + postRequest({ + employee_id: employee.id, + type: "asset", + reason: "New laptop for onboarding", + }) ); expect(res.status).toBe(401);