import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import type { Session } from "next-auth"; // The route handlers call auth() (src/auth.ts) directly to gate access (see // src/app/api/requests/route.ts). That auth() reads cookies via // next/headers under the hood, which only works inside Next.js's own // request-handling machinery — not when these tests import and call // GET()/POST() directly, bypassing Next entirely (the same reason // middleware.ts itself isn't tested here; see CLAUDE.md's task notes). // Mocking @/auth sidesteps next/headers altogether and lets each test // control the session directly: a valid session by default (so the // existing business-logic tests below are unaffected), and no session for // the dedicated "returns 401" tests. vi.mock("@/auth", () => ({ auth: vi.fn() })); import { GET, POST } from "@/app/api/requests/route"; import { prisma } from "@/lib/prisma"; import { auth } from "@/auth"; // auth's real type is next-auth's overloaded call signature (RSC helper, // middleware wrapper, API-route form, ...) — vi.mocked() picks the wrong // overload to infer mock*Value's parameter type from, so cast to the one // shape actually used here: the no-arg session-helper call the route // handlers make. const mockedAuth = auth as unknown as Mock<() => Promise>; 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/requests", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } // 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 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). mockedAuth.mockResolvedValue(testSession); }); 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: employee.id, type: "asset", reason: "New laptop for onboarding" }) ); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ employee_id: employee.id, type: "asset", reason: "New laptop for onboarding", status: "Draft", }); expect(typeof body.id).toBe("string"); expect(body.id.length).toBeGreaterThan(0); expect(typeof body.created_at).toBe("string"); expect(Number.isNaN(Date.parse(body.created_at))).toBe(false); }); it("rejects requests with missing required fields", async () => { const res = await POST(postRequest({ employee_id: "", type: "asset", reason: "" })); expect(res.status).toBe(400); const body = await res.json(); expect(typeof body.error).toBe("string"); expect(body.error.length).toBeGreaterThan(0); }); it("rejects requests missing the reason field entirely", async () => { 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(); expect(typeof body.error).toBe("string"); }); it("rejects an invalid type value", async () => { const employee = await createTestEmployee(); const res = await POST( 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 () => { 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: employee2.id, type: "license", reason: "Second request" }) ); 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({ 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 () => { const res = await GET(); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual([]); }); }); describe("auth gating on /api/requests", () => { // Route-level coverage for the "logged in or not" gate added alongside // middleware.ts (src/middleware.ts) — see that file's comment for why // page routes are gated via middleware but API routes gate themselves. // 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: employee.id, type: "asset", reason: "New laptop for onboarding", }) ); 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.request.findMany(); expect(remaining).toHaveLength(0); }); it("GET /api/requests 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" }); }); });