Closes the coverage gap noted in CLAUDE.md's "Việc còn dở" — mirrors the existing pattern in tests/licenses.test.ts and tests/assignments.test.ts for POST /api/assets, POST /api/employees, POST /api/requests (the only body- accepting methods on these three routes; none have a PATCH route yet).
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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<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/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",
|
|
},
|
|
});
|
|
}
|
|
|
|
// Request.purchaseOrderId is a real FK to PurchaseOrder (see schema.prisma /
|
|
// src/lib/requests.ts) — tests that need a valid purchase_order_id create a
|
|
// real PurchaseOrder row (and the Vendor it requires) first and use its id.
|
|
// Mirrors tests/licenses.test.ts's createTestVendor()/createTestPurchaseOrder()
|
|
// 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 createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
|
return prisma.purchaseOrder.create({
|
|
data: {
|
|
vendorId: overrides.vendorId as string,
|
|
orderDate: new Date("2026-08-01"),
|
|
expectedDate: new Date("2026-08-10"),
|
|
},
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
// Each test starts from empty tables so listing/order assertions are
|
|
// deterministic. requests must be cleared before employees/purchase_orders
|
|
// since requests.employee_id has an FK (ON DELETE RESTRICT) to employees
|
|
// and requests.purchase_order_id has an FK (ON DELETE SET NULL) to
|
|
// purchase_orders. purchase_orders must be cleared before employees/vendors
|
|
// (ON DELETE RESTRICT to vendors, mirrors tests/licenses.test.ts) — order:
|
|
// requests -> purchase_orders -> employees -> vendors.
|
|
await prisma.request.deleteMany();
|
|
await prisma.purchaseOrder.deleteMany();
|
|
await prisma.employee.deleteMany();
|
|
await prisma.vendor.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);
|
|
});
|
|
|
|
it("creates a request with a purchase_order_id that references a real purchase order", async () => {
|
|
const employee = await createTestEmployee();
|
|
const vendor = await createTestVendor();
|
|
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
|
const res = await POST(
|
|
postRequest({
|
|
employee_id: employee.id,
|
|
type: "asset",
|
|
reason: "New laptop for onboarding",
|
|
purchase_order_id: purchaseOrder.id,
|
|
})
|
|
);
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.purchase_order_id).toBe(purchaseOrder.id);
|
|
});
|
|
|
|
it("creates a request with no purchase_order_id (stays null)", 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.purchase_order_id).toBeNull();
|
|
});
|
|
|
|
it("rejects a purchase_order_id that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
|
const employee = await createTestEmployee();
|
|
const res = await POST(
|
|
postRequest({
|
|
employee_id: employee.id,
|
|
type: "asset",
|
|
reason: "New laptop for onboarding",
|
|
purchase_order_id: "00000000-0000-0000-0000-000000000000",
|
|
})
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/purchase_order_id/i);
|
|
expect(body.error).toMatch(/does not reference an existing purchase order/i);
|
|
|
|
// And it must not have created anything.
|
|
const remaining = await prisma.request.findMany();
|
|
expect(remaining).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects a purchase_order_id that is not a valid UUID", async () => {
|
|
const employee = await createTestEmployee();
|
|
const res = await POST(
|
|
postRequest({
|
|
employee_id: employee.id,
|
|
type: "asset",
|
|
reason: "New laptop for onboarding",
|
|
purchase_order_id: "not-a-uuid",
|
|
})
|
|
);
|
|
|
|
expect(res.status).toBe(400);
|
|
const body = await res.json();
|
|
expect(body.error).toMatch(/purchase_order_id/i);
|
|
});
|
|
|
|
it("rejects an invalid JSON body", async () => {
|
|
const req = new Request("http://localhost/api/requests", {
|
|
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 — use a JSON string primitive here to actually exercise
|
|
// the "not an object" branch (mirrors tests/licenses.test.ts).
|
|
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/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" });
|
|
});
|
|
});
|