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.
This commit is contained in:
2026-07-31 15:14:55 +07:00
parent 49d96b77e8
commit 7e69efa4a8
3 changed files with 118 additions and 22 deletions
+5 -1
View File
@@ -32,7 +32,11 @@ export async function POST(request: Request): Promise<NextResponse> {
}
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<NextResponse> {
+36 -10
View File
@@ -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<RequestRecord> {
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<CreateRequestResult> {
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<RequestRecord[]> {
+77 -11
View File
@@ -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);