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).
209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
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<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/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);
|
|
});
|
|
|
|
it("rejects an invalid JSON body", async () => {
|
|
const req = new Request("http://localhost/api/employees", {
|
|
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/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" });
|
|
});
|
|
});
|