From 10a8b0411d805dca1f67865d4fb5c1de88af4b88 Mon Sep 17 00:00:00 2001 From: Toan Hoang Minh Date: Fri, 31 Jul 2026 14:57:37 +0700 Subject: [PATCH] test: add Employee API tests Mirrors tests/requests.test.ts's structure and @/auth mocking pattern. Covers: successful create (with and without managerId), empty name/department validation failures, malformed managerId UUID, nonexistent managerId (FK violation -> friendly 400, not a raw 500), a self-referential manager sanity check via direct Prisma update, list ordering, empty list, and 401-when-unauthenticated for both routes. --- tests/employees.test.ts | 184 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/employees.test.ts diff --git a/tests/employees.test.ts b/tests/employees.test.ts new file mode 100644 index 0000000..60c2786 --- /dev/null +++ b/tests/employees.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; + +// Mirrors tests/requests.test.ts's @/auth mocking pattern — see that file's +// comment for why auth() (which reads cookies via next/headers) can't be +// exercised directly when tests import and call GET()/POST() outside of +// Next's own request-handling machinery. +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +import { GET, POST } from "@/app/api/employees/route"; +import { prisma } from "@/lib/prisma"; +import { auth } from "@/auth"; + +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/employees", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + // Each test starts from an empty table so listing/order assertions are + // deterministic. + await prisma.employee.deleteMany(); + // Default to a logged-in session — the tests below exercise business + // logic, not auth gating (that's covered by the "auth gating" describe + // block further down). + mockedAuth.mockResolvedValue(testSession); +}); + +describe("POST /api/employees", () => { + it("creates an employee with no manager on valid input", async () => { + const res = await POST(postRequest({ name: "Alice Nguyen", department: "IT" })); + + expect(res.status).toBe(201); + const body = await res.json(); + + expect(body).toMatchObject({ + name: "Alice Nguyen", + department: "IT", + manager_id: null, + }); + 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("creates an employee with a valid managerId reference", async () => { + const managerRes = await POST(postRequest({ name: "Bob Tran", department: "IT" })); + const manager = await managerRes.json(); + + const res = await POST( + postRequest({ name: "Alice Nguyen", department: "IT", managerId: manager.id }) + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.manager_id).toBe(manager.id); + }); + + it("rejects an empty name", async () => { + const res = await POST(postRequest({ name: "", department: "IT" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + }); + + it("rejects an empty department", async () => { + const res = await POST(postRequest({ name: "Alice Nguyen", department: "" })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + }); + + it("rejects a managerId that is not a valid UUID", async () => { + const res = await POST( + postRequest({ name: "Alice Nguyen", department: "IT", managerId: "not-a-uuid" }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/managerId/i); + }); + + it("rejects a managerId that is a well-formed UUID but doesn't reference an existing employee", async () => { + const res = await POST( + postRequest({ + name: "Alice Nguyen", + department: "IT", + managerId: "00000000-0000-0000-0000-000000000000", + }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/managerId/i); + expect(body.error).toMatch(/does not reference an existing employee/i); + }); + + it("allows an employee to reference itself as manager (not disallowed by the data model)", async () => { + // Two-step: create the row first (no self-FK possible on insert since + // the id doesn't exist yet), then verify the FK accepts an update to + // point at itself, directly via Prisma (no PATCH endpoint exists yet — + // this is a data-model sanity check, not an API contract test). + const created = await prisma.employee.create({ + data: { name: "Self Manager", department: "IT" }, + }); + const updated = await prisma.employee.update({ + where: { id: created.id }, + data: { managerId: created.id }, + }); + expect(updated.managerId).toBe(created.id); + }); +}); + +describe("GET /api/employees", () => { + it("lists created employees, newest first", async () => { + await POST(postRequest({ name: "Alice Nguyen", department: "IT" })); + // Ensure a distinct created_at ordering even on fast filesystems/clocks. + await new Promise((resolve) => setTimeout(resolve, 5)); + await POST(postRequest({ name: "Bob Tran", department: "Finance" })); + + 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({ name: "Bob Tran", department: "Finance" }); + expect(body[1]).toMatchObject({ name: "Alice Nguyen", department: "IT" }); + }); + + it("returns an empty list when there are no employees", async () => { + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +describe("auth gating on /api/employees", () => { + // Mirrors tests/requests.test.ts's "auth gating" block — only checks + // session presence, per the flat permission model (no role/permission + // logic). + it("POST /api/employees returns 401 without a session", async () => { + mockedAuth.mockResolvedValue(null); + + const res = await POST(postRequest({ name: "Alice Nguyen", department: "IT" })); + + 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.employee.findMany(); + expect(remaining).toHaveLength(0); + }); + + it("GET /api/employees 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" }); + }); +});