import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import type { Session } from "next-auth"; import { createAssignment, listAssignments, returnAssignment, transferAssignment, validateCreateAssignmentInput, validateReturnAssignmentInput, validateTransferAssignmentInput, } from "@/lib/assignments"; import { prisma } from "@/lib/prisma"; // This file has two layers of coverage, mirroring tests/licenses.test.ts: // - lib-level tests exercise src/lib/assignments.ts directly (validation, // transactional business rules, race conditions). // - route-level tests exercise src/app/api/assignments/route.ts and // src/app/api/assignments/[id]/route.ts over HTTP, including the auth() // gate — mirrors tests/licenses.test.ts's @/auth mocking pattern. vi.mock("@/auth", () => ({ auth: vi.fn() })); import { GET, POST } from "@/app/api/assignments/route"; import { PATCH } from "@/app/api/assignments/[id]/route"; 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/assignments", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } function patchRequest(body: unknown): Request { return new Request("http://localhost/api/assignments/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } // Assignment.employeeId is a real FK to Employee — mirrors // tests/requests.test.ts's createTestEmployee() helper. function createTestEmployee(overrides: { name?: string } = {}) { return prisma.employee.create({ data: { name: overrides.name ?? "Test Employee", department: "IT", }, }); } // Assignment.assetItemId is a real FK to AssetItem, which itself requires a // full AssetModel -> AssetCategory chain. Mirrors tests/assets.test.ts's // createTestModelId() helper. async function createTestModelId(): Promise { const category = await prisma.assetCategory.create({ data: { name: "Laptop", valueGroup: "high_value" }, }); const model = await prisma.assetModel.create({ data: { categoryId: category.id, manufacturer: "Dell", name: "Latitude 5440" }, }); return model.id; } let serialCounter = 0; function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "assigned" | "repair" | "retired" }) { serialCounter += 1; return prisma.assetItem.create({ data: { serialNumber: `SN-ASSIGN-${serialCounter}`, modelId: overrides.modelId, purchaseDate: new Date("2026-01-15"), status: overrides.status ?? "in_stock", }, }); } // Assignment.licenseId is a real FK to License, which itself requires a // Vendor. Mirrors tests/licenses.test.ts's createTestVendor() helper. function createTestVendor(overrides: { name?: string } = {}) { return prisma.vendor.create({ data: { name: overrides.name ?? "Test Vendor", contactEmail: "sales@testvendor.example", contactPhone: "0281234567", address: "123 Test Street", }, }); } function createTestLicense(overrides: { vendorId: string; seatsTotal?: number; seatsUsed?: number; }) { return prisma.license.create({ data: { name: "Adobe Creative Cloud", vendorId: overrides.vendorId, seatsTotal: overrides.seatsTotal ?? 5, seatsUsed: overrides.seatsUsed ?? 0, licenseType: "subscription", }, }); } async function createTestLicenseId(overrides: { seatsTotal?: number; seatsUsed?: number } = {}): Promise { const vendor = await createTestVendor(); const license = await createTestLicense({ vendorId: vendor.id, ...overrides }); return license.id; } beforeEach(async () => { // Clear in FK-safe order: assignments first (references everything else), // then asset_items/licenses (reference purchase_orders/vendors/models), // then the rest. await prisma.assignment.deleteMany(); await prisma.assetItem.deleteMany(); await prisma.license.deleteMany(); await prisma.assetModel.deleteMany(); await prisma.assetCategory.deleteMany(); await prisma.purchaseOrder.deleteMany(); await prisma.vendor.deleteMany(); await prisma.employee.deleteMany(); serialCounter = 0; // Default to a logged-in session — the lib-level tests don't go through // the route at all (no-op for them), and the route-level tests exercise // business logic, not auth gating (covered by the "auth gating" block). mockedAuth.mockResolvedValue(testSession); }); describe("validateCreateAssignmentInput", () => { it("accepts input with only assetItemId set", () => { const result = validateCreateAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", assetItemId: "22222222-2222-2222-2222-222222222222", }); expect(result.ok).toBe(true); }); it("accepts input with only licenseId set", () => { const result = validateCreateAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", licenseId: "33333333-3333-3333-3333-333333333333", }); expect(result.ok).toBe(true); }); it("rejects input with neither assetItemId nor licenseId set", () => { const result = validateCreateAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/exactly one/i); } }); it("rejects input with both assetItemId and licenseId set", () => { const result = validateCreateAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", assetItemId: "22222222-2222-2222-2222-222222222222", licenseId: "33333333-3333-3333-3333-333333333333", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/exactly one/i); } }); it("rejects a missing employeeId", () => { const result = validateCreateAssignmentInput({ assetItemId: "22222222-2222-2222-2222-222222222222", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/employeeId/i); } }); it("rejects a non-UUID assetItemId", () => { const result = validateCreateAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", assetItemId: "not-a-uuid", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/assetItemId/i); } }); }); describe("validateReturnAssignmentInput", () => { it("accepts an empty body (returnedAt defaults to now later)", () => { const result = validateReturnAssignmentInput({}); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.returnedAt).toBeUndefined(); } }); it("accepts an explicit returnedAt", () => { const result = validateReturnAssignmentInput({ returnedAt: "2026-08-05" }); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.returnedAt).toBeInstanceOf(Date); } }); it("rejects an invalid returnedAt", () => { const result = validateReturnAssignmentInput({ returnedAt: "not-a-date" }); expect(result.ok).toBe(false); }); }); describe("createAssignment", () => { it("assigns an in_stock AssetItem, flips its status to assigned", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; expect(created.value.asset_item_id).toBe(assetItem.id); expect(created.value.license_id).toBeNull(); expect(created.value.returned_at).toBeNull(); const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(updatedItem.status).toBe("assigned"); }); it.each(["repair", "retired", "assigned"] as const)( "rejects assigning an AssetItem whose status is %s", async (status) => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status }); const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/not in_stock/i); const remaining = await prisma.assignment.findMany(); expect(remaining).toHaveLength(0); } ); it("rejects assigning an AssetItem that already has an active assignment (belt-and-suspenders check)", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const first = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(first.ok).toBe(true); // Manually force status back to in_stock without closing the active // Assignment row, to exercise the explicit "active assignment already // exists" invariant independently of the status check (data-drift // scenario per src/lib/assignments.ts's doc comment). await prisma.assetItem.update({ where: { id: assetItem.id }, data: { status: "in_stock" } }); const second = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(second.ok).toBe(false); if (second.ok) return; expect(second.error).toMatch(/already has an active assignment/i); }); it("rejects assigning an AssetItem for a second employee while the first assignment is still active", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }); expect(first.ok).toBe(true); const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }); expect(second.ok).toBe(false); if (second.ok) return; expect(second.error).toMatch(/not in_stock/i); const activeAssignments = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id, returnedAt: null } }); expect(activeAssignments).toHaveLength(1); }); it("allows re-assigning an AssetItem to a new employee after it has been returned", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }); expect(first.ok).toBe(true); if (!first.ok) return; const returned = await returnAssignment(first.value.id, {}); expect(returned.ok).toBe(true); const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }); expect(second.ok).toBe(true); if (!second.ok) return; expect(second.value.employee_id).toBe(employee2.id); const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(updatedItem.status).toBe("assigned"); }); it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => { const employee = await createTestEmployee(); const created = await createAssignment({ employeeId: employee.id, assetItemId: "00000000-0000-0000-0000-000000000000", }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/assetItemId/i); expect(created.error).toMatch(/does not reference an existing asset item/i); }); it("returns a clean error when employeeId doesn't reference an existing employee", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: "00000000-0000-0000-0000-000000000000", assetItemId: assetItem.id, }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/employeeId/i); expect(created.error).toMatch(/does not reference an existing employee/i); // And the AssetItem's status must NOT have been mutated — the // transaction must have rolled back entirely. const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("in_stock"); }); it("assigns a license seat, increments seatsUsed", async () => { const employee = await createTestEmployee(); const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 }); const created = await createAssignment({ employeeId: employee.id, licenseId }); expect(created.ok).toBe(true); if (!created.ok) return; expect(created.value.license_id).toBe(licenseId); const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); }); it("rejects assigning a license with no seats remaining (seatsUsed >= seatsTotal)", async () => { const employee = await createTestEmployee(); const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 3 }); const created = await createAssignment({ employeeId: employee.id, licenseId }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/no seats remaining/i); const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); const remaining = await prisma.assignment.findMany(); expect(remaining).toHaveLength(0); }); it("returns a clean error when licenseId doesn't reference an existing license", async () => { const employee = await createTestEmployee(); const created = await createAssignment({ employeeId: employee.id, licenseId: "00000000-0000-0000-0000-000000000000", }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/licenseId/i); expect(created.error).toMatch(/does not reference an existing license/i); }); it("closes the race: two concurrent createAssignment calls against a license with exactly 1 remaining seat — at least one must fail, seatsUsed never exceeds seatsTotal", async () => { const employee1 = await createTestEmployee({ name: "Racer 1" }); const employee2 = await createTestEmployee({ name: "Racer 2" }); // seatsTotal=3, seatsUsed=2 -> exactly 1 seat remaining. const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 2 }); const [resultA, resultB] = await Promise.all([ createAssignment({ employeeId: employee1.id, licenseId }), createAssignment({ employeeId: employee2.id, licenseId }), ]); const outcomes = [resultA, resultB]; const succeeded = outcomes.filter((r) => r.ok); const failed = outcomes.filter((r) => !r.ok); expect(succeeded).toHaveLength(1); expect(failed).toHaveLength(1); if (!failed[0].ok) { expect(failed[0].error).toMatch(/no seats remaining/i); } const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); expect(license.seatsUsed).toBeLessThanOrEqual(license.seatsTotal); const assignments = await prisma.assignment.findMany({ where: { licenseId } }); expect(assignments).toHaveLength(1); }); it("closes the race: two concurrent createAssignment calls against the same in_stock AssetItem — at least one must fail", async () => { const employee1 = await createTestEmployee({ name: "Racer 1" }); const employee2 = await createTestEmployee({ name: "Racer 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const [resultA, resultB] = await Promise.all([ createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }), createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }), ]); const outcomes = [resultA, resultB]; const succeeded = outcomes.filter((r) => r.ok); const failed = outcomes.filter((r) => !r.ok); expect(succeeded).toHaveLength(1); expect(failed).toHaveLength(1); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("assigned"); const activeAssignments = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id, returnedAt: null }, }); expect(activeAssignments).toHaveLength(1); }); }); describe("returnAssignment", () => { it("returns an AssetItem assignment: sets returnedAt, flips status back to in_stock", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const returned = await returnAssignment(created.value.id, {}); expect(returned.ok).toBe(true); if (!returned.ok) return; expect(returned.value.returned_at).not.toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("in_stock"); }); it("accepts an explicit returnedAt", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const explicitDate = new Date("2026-08-01T00:00:00.000Z"); const returned = await returnAssignment(created.value.id, { returnedAt: explicitDate }); expect(returned.ok).toBe(true); if (!returned.ok) return; expect(returned.value.returned_at).toBe(explicitDate.toISOString()); }); it("returns a license assignment: sets returnedAt, decrements seatsUsed", async () => { const employee = await createTestEmployee(); const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 }); const created = await createAssignment({ employeeId: employee.id, licenseId }); expect(created.ok).toBe(true); if (!created.ok) return; let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); const returned = await returnAssignment(created.value.id, {}); expect(returned.ok).toBe(true); license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(2); }); it("rejects returning an already-returned assignment (double return)", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const firstReturn = await returnAssignment(created.value.id, {}); expect(firstReturn.ok).toBe(true); const secondReturn = await returnAssignment(created.value.id, {}); expect(secondReturn.ok).toBe(false); if (secondReturn.ok) return; expect(secondReturn.status).toBe(400); expect(secondReturn.error).toMatch(/already been returned/i); }); it("returns 404 when returning a nonexistent assignment", async () => { const result = await returnAssignment("00000000-0000-0000-0000-000000000000", {}); expect(result.ok).toBe(false); if (result.ok) return; expect(result.status).toBe(404); expect(result.error).toMatch(/not found/i); }); it("closes the race: two concurrent returnAssignment calls against the same assignment — only one succeeds", async () => { const employee = await createTestEmployee(); const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 0 }); const created = await createAssignment({ employeeId: employee.id, licenseId }); expect(created.ok).toBe(true); if (!created.ok) return; const [resultA, resultB] = await Promise.all([ returnAssignment(created.value.id, {}), returnAssignment(created.value.id, {}), ]); const outcomes = [resultA, resultB]; const succeeded = outcomes.filter((r) => r.ok); const failed = outcomes.filter((r) => !r.ok); expect(succeeded).toHaveLength(1); expect(failed).toHaveLength(1); const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(0); }); }); describe("transferAssignment", () => { it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id }); expect(transferred.ok).toBe(true); if (!transferred.ok) return; expect(transferred.value.employee_id).toBe(employee2.id); expect(transferred.value.asset_item_id).toBe(assetItem.id); expect(transferred.value.returned_at).toBeNull(); // The AssetItem must remain "assigned" throughout the transfer — never // flipped to in_stock mid-way (that's the whole point of doing this in // one transaction rather than a separate return + create). const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("assigned"); const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } }); expect(original.returnedAt).not.toBeNull(); const rows = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id } }); expect(rows).toHaveLength(2); const newRow = rows.find((row) => row.id !== created.value.id); expect(newRow).toBeDefined(); expect(newRow?.employeeId).toBe(employee2.id); expect(newRow?.returnedAt).toBeNull(); }); it("transfers a License assignment to a new employee without changing seatsUsed", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 }); const created = await createAssignment({ employeeId: employee1.id, licenseId }); expect(created.ok).toBe(true); if (!created.ok) return; let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id }); expect(transferred.ok).toBe(true); if (!transferred.ok) return; expect(transferred.value.employee_id).toBe(employee2.id); expect(transferred.value.license_id).toBe(licenseId); license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } }); expect(license.seatsUsed).toBe(3); const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } }); expect(original.returnedAt).not.toBeNull(); }); it("rejects transferring an already-returned assignment", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const returned = await returnAssignment(created.value.id, {}); expect(returned.ok).toBe(true); const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id }); expect(transferred.ok).toBe(false); if (transferred.ok) return; expect(transferred.status).toBe(400); expect(transferred.error).toMatch(/already been returned/i); }); it("returns 404 when transferring a nonexistent assignment", async () => { const employee = await createTestEmployee(); const result = await transferAssignment("00000000-0000-0000-0000-000000000000", { employeeId: employee.id, }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.status).toBe(404); expect(result.error).toMatch(/not found/i); }); it("returns a clean 400 when transferring to a nonexistent employeeId", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }); expect(created.ok).toBe(true); if (!created.ok) return; const transferred = await transferAssignment(created.value.id, { employeeId: "00000000-0000-0000-0000-000000000000", }); expect(transferred.ok).toBe(false); if (transferred.ok) return; expect(transferred.status).toBe(400); expect(transferred.error).toMatch(/employeeId/i); expect(transferred.error).toMatch(/does not reference an existing employee/i); // Original assignment must not have been closed — the whole transfer // must have rolled back. const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } }); expect(original.returnedAt).toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("assigned"); }); }); describe("validateTransferAssignmentInput", () => { it("accepts a valid employeeId", () => { const result = validateTransferAssignmentInput({ employeeId: "11111111-1111-1111-1111-111111111111", }); expect(result.ok).toBe(true); }); it("rejects a missing employeeId", () => { const result = validateTransferAssignmentInput({}); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/employeeId/i); } }); it("rejects a non-UUID employeeId", () => { const result = validateTransferAssignmentInput({ employeeId: "not-a-uuid" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/employeeId/i); } }); }); describe("listAssignments", () => { it("lists created assignments, newest first", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const item1 = await createTestAssetItem({ modelId }); const item2 = await createTestAssetItem({ modelId }); const first = await createAssignment({ employeeId: employee.id, assetItemId: item1.id }); expect(first.ok).toBe(true); await new Promise((resolve) => setTimeout(resolve, 5)); const second = await createAssignment({ employeeId: employee.id, assetItemId: item2.id }); expect(second.ok).toBe(true); const records = await listAssignments(); expect(records).toHaveLength(2); expect(records[0].asset_item_id).toBe(item2.id); expect(records[1].asset_item_id).toBe(item1.id); }); it("returns an empty list when there are no assignments", async () => { const records = await listAssignments(); expect(records).toEqual([]); }); }); describe("POST /api/assignments", () => { it("creates an assignment on valid input", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id })); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ employee_id: employee.id, asset_item_id: assetItem.id, license_id: null }); }); it("rejects both assetItemId and licenseId set", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const licenseId = await createTestLicenseId(); const res = await POST( postRequest({ employeeId: employee.id, assetItemId: assetItem.id, licenseId }) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/exactly one/i); }); it("rejects neither assetItemId nor licenseId set", async () => { const employee = await createTestEmployee(); const res = await POST(postRequest({ employeeId: employee.id })); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/exactly one/i); }); it("rejects assigning an AssetItem that isn't in_stock", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status: "repair" }); const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id })); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/not in_stock/i); }); it("rejects an invalid JSON body", async () => { const req = new Request("http://localhost/api/assignments", { 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 () => { 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/assignments", () => { it("lists created assignments, newest first", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const item1 = await createTestAssetItem({ modelId }); const item2 = await createTestAssetItem({ modelId }); await POST(postRequest({ employeeId: employee.id, assetItemId: item1.id })); await new Promise((resolve) => setTimeout(resolve, 5)); await POST(postRequest({ employeeId: employee.id, assetItemId: item2.id })); const res = await GET(); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveLength(2); expect(body[0].asset_item_id).toBe(item2.id); expect(body[1].asset_item_id).toBe(item1.id); }); it("returns an empty list when there are no assignments", async () => { const res = await GET(); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual([]); }); }); describe("PATCH /api/assignments/[id]", () => { it("returns an assignment on valid input", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id })); const created = await createRes.json(); const res = await PATCH(patchRequest({}), { params: { id: created.id } }); expect(res.status).toBe(200); const body = await res.json(); expect(body.returned_at).not.toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("in_stock"); }); it("returns 404 for a nonexistent assignment id", async () => { const res = await PATCH(patchRequest({}), { params: { id: "00000000-0000-0000-0000-000000000000" }, }); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toMatch(/not found/i); }); it("returns 400 for a malformed id (not a UUID)", async () => { const res = await PATCH(patchRequest({}), { params: { id: "not-a-uuid" } }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/UUID/i); }); it("rejects double-return with 400", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id })); const created = await createRes.json(); await PATCH(patchRequest({}), { params: { id: created.id } }); const res = await PATCH(patchRequest({}), { params: { id: created.id } }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/already been returned/i); }); it("rejects an invalid JSON body", async () => { const req = new Request("http://localhost/api/assignments/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: "not json", }); const res = await PATCH(req, { params: { id: "00000000-0000-0000-0000-000000000000" } }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/JSON/i); }); it("rejects a non-object JSON body", async () => { const res = await PATCH(patchRequest("not an object"), { params: { id: "00000000-0000-0000-0000-000000000000" }, }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/JSON object/i); }); it("returns 401 without a session", async () => { mockedAuth.mockResolvedValue(null); const res = await PATCH(patchRequest({}), { params: { id: "00000000-0000-0000-0000-000000000000" }, }); expect(res.status).toBe(401); const body = await res.json(); expect(body).toEqual({ error: "Unauthorized" }); }); it("transfers an assignment to a new employee on valid employeeId input", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id })); const created = await createRes.json(); const res = await PATCH(patchRequest({ employeeId: employee2.id }), { params: { id: created.id } }); expect(res.status).toBe(200); const body = await res.json(); expect(body.employee_id).toBe(employee2.id); expect(body.asset_item_id).toBe(assetItem.id); expect(body.returned_at).toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("assigned"); }); it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => { const employee1 = await createTestEmployee({ name: "Employee 1" }); const employee2 = await createTestEmployee({ name: "Employee 2" }); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id })); const created = await createRes.json(); const res = await PATCH( patchRequest({ employeeId: employee2.id, returnedAt: "2026-08-01" }), { params: { id: created.id } } ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/either employeeId.*or returnedAt/i); }); it("returns 401 for a transfer request without a session", async () => { const employee = await createTestEmployee(); mockedAuth.mockResolvedValue(null); const res = await PATCH(patchRequest({ employeeId: employee.id }), { params: { id: "00000000-0000-0000-0000-000000000000" }, }); expect(res.status).toBe(401); const body = await res.json(); expect(body).toEqual({ error: "Unauthorized" }); }); }); describe("auth gating on /api/assignments", () => { it("POST /api/assignments returns 401 without a session", async () => { const employee = await createTestEmployee(); const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); mockedAuth.mockResolvedValue(null); const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id })); expect(res.status).toBe(401); const body = await res.json(); expect(body).toEqual({ error: "Unauthorized" }); const remaining = await prisma.assignment.findMany(); expect(remaining).toHaveLength(0); }); it("GET /api/assignments 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" }); }); });