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:
@@ -32,7 +32,11 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const created = await createRequest(validation.value);
|
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> {
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
|||||||
+36
-10
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
export type RequestType = "asset" | "license";
|
export type RequestType = "asset" | "license";
|
||||||
@@ -40,7 +41,14 @@ function nonEmptyString(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createRequestSchema = z.object({
|
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"),
|
reason: nonEmptyString("reason is required and must be a non-empty string"),
|
||||||
type: z.enum(["asset", "license"], {
|
type: z.enum(["asset", "license"], {
|
||||||
errorMap: () => ({
|
errorMap: () => ({
|
||||||
@@ -85,15 +93,33 @@ function toRecord(row: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRequest(input: CreateRequestInput): Promise<RequestRecord> {
|
export type CreateRequestResult =
|
||||||
const created = await prisma.request.create({
|
| { ok: true; value: RequestRecord }
|
||||||
data: {
|
| { ok: false; error: string };
|
||||||
employeeId: input.employee_id,
|
|
||||||
type: input.type,
|
/**
|
||||||
reason: input.reason,
|
* 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
|
||||||
return toRecord(created);
|
* 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[]> {
|
export async function listRequests(): Promise<RequestRecord[]> {
|
||||||
|
|||||||
+77
-11
@@ -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 () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from an empty table so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic.
|
// 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.request.deleteMany();
|
||||||
|
await prisma.employee.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise request
|
// Default to a logged-in session — the tests below exercise request
|
||||||
// business logic, not auth gating (that's covered by the "auth gating"
|
// business logic, not auth gating (that's covered by the "auth gating"
|
||||||
// describe block further down).
|
// describe block further down).
|
||||||
@@ -50,15 +64,16 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
describe("POST /api/requests", () => {
|
describe("POST /api/requests", () => {
|
||||||
it("creates a request in Draft status on valid input", async () => {
|
it("creates a request in Draft status on valid input", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(201);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
employee_id: "emp-1",
|
employee_id: employee.id,
|
||||||
type: "asset",
|
type: "asset",
|
||||||
reason: "New laptop for onboarding",
|
reason: "New laptop for onboarding",
|
||||||
status: "Draft",
|
status: "Draft",
|
||||||
@@ -79,7 +94,8 @@ describe("POST /api/requests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects requests missing the reason field entirely", async () => {
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -87,22 +103,59 @@ describe("POST /api/requests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid type value", async () => {
|
it("rejects an invalid type value", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/type/i);
|
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", () => {
|
describe("GET /api/requests", () => {
|
||||||
it("lists created requests, newest first", async () => {
|
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.
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
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();
|
const res = await GET();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -112,8 +165,16 @@ describe("GET /api/requests", () => {
|
|||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ employee_id: "emp-2", type: "license", status: "Draft" });
|
expect(body[0]).toMatchObject({
|
||||||
expect(body[1]).toMatchObject({ employee_id: "emp-1", type: "asset", status: "Draft" });
|
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 () => {
|
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
|
// No role/permission logic here, per the flat permission model (any
|
||||||
// logged-in user can do anything) — this only checks session presence.
|
// logged-in user can do anything) — this only checks session presence.
|
||||||
it("POST /api/requests returns 401 without a session", async () => {
|
it("POST /api/requests returns 401 without a session", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await POST(
|
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);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
Reference in New Issue
Block a user