fix: add atomic Assignment transfer (close old + create new in one transaction)
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.
This commit is contained in:
@@ -5,8 +5,10 @@ import {
|
||||
createAssignment,
|
||||
listAssignments,
|
||||
returnAssignment,
|
||||
transferAssignment,
|
||||
validateCreateAssignmentInput,
|
||||
validateReturnAssignmentInput,
|
||||
validateTransferAssignmentInput,
|
||||
} from "@/lib/assignments";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -542,6 +544,149 @@ describe("returnAssignment", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -736,6 +881,53 @@ describe("PATCH /api/assignments/[id]", () => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user