diff --git a/tests/assignments.test.ts b/tests/assignments.test.ts new file mode 100644 index 0000000..290ce12 --- /dev/null +++ b/tests/assignments.test.ts @@ -0,0 +1,764 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Mock } from "vitest"; +import type { Session } from "next-auth"; +import { + createAssignment, + listAssignments, + returnAssignment, + validateCreateAssignmentInput, + validateReturnAssignmentInput, +} 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("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" }); + }); +}); + +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" }); + }); +});