The only way to move an Assignment to a new employee was two separate calls (PATCH return + POST create), which is not atomic and left a race window where a third party could grab the now-in_stock AssetItem in between, plus no rollback if the second call failed. Adds transferAssignment() which closes the old Assignment and creates the new one inside a single prisma.$transaction(), per docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" spec. Does not touch AssetItem.status or License.seatsUsed — the resource never becomes free mid-transfer. Wired into PATCH /api/assignments/[id]: an employeeId key in the body dispatches to transfer, a returnedAt key to return, both together is rejected as mutually exclusive.
957 lines
37 KiB
TypeScript
957 lines
37 KiB
TypeScript
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<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/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<string> {
|
|
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: "[email protected]",
|
|
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<string> {
|
|
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" });
|
|
});
|
|
});
|