4 Commits
Author SHA1 Message Date
toanhoang 6b39c01f4e test: add Assignment API tests
48 tests covering the XOR validation, the AssetItem.status/active-assignment
preconditions, seat overbooking, seat/status sync across assign+return,
FK-not-found for all three FKs, double-return, 404s, auth-gating, malformed
bodies, and two explicit concurrency tests (Promise.all against a
1-remaining-seat License and against a single in_stock AssetItem) exercising
the transaction race-closing logic in src/lib/assignments.ts.
2026-08-02 21:19:40 -04:00
toanhoang 5145c6af31 feat: add Assignment API routes
POST/GET /api/assignments mirror the existing entity route pattern
(auth-gated, thin handler delegating to src/lib/assignments.ts). PATCH
/api/assignments/[id] is the first dynamic-segment route in the codebase,
per the stakeholder-confirmed convention (docs/asset-license-management.md)
of update routes being PATCH /api/<entity>/[id] rather than an
action-based sub-route.
2026-08-02 21:19:34 -04:00
toanhoang a72b8dbbbe feat: add Assignment validation + CRUD (src/lib/assignments.ts)
createAssignment() enforces the XOR (assetItemId/licenseId) at the Zod
layer and the AssetItem.status/License.seatsUsed business rules inside a
single prisma.$transaction(), using atomic WHERE-guarded updateMany() calls
(not plain read-then-write) to close the seat-overbooking and
double-assignment race conditions flagged for License by code-reviewer.
returnAssignment() reverses those side-effects the same way and closes the
double-return race with the same pattern.
2026-08-02 21:19:28 -04:00
toanhoang 07b01cd707 feat: add Assignment Prisma model + migration
Links Employee to exactly one of AssetItem/License (XOR enforced at the
Zod layer, not built yet) via nullable FKs, plus assignedAt/returnedAt/
signedAt timestamps. See docs/asset-license-management.md's "Assignment"
section for the full spec.
2026-08-02 21:13:46 -04:00
6 changed files with 1359 additions and 3 deletions
@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "assignments" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"asset_item_id" UUID,
"license_id" UUID,
"assigned_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returned_at" TIMESTAMPTZ(6),
"signed_at" TIMESTAMPTZ(6),
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+59 -3
View File
@@ -70,6 +70,10 @@ model Employee {
// Back-relation for PurchaseOrder.receivedById — an Employee can be the // Back-relation for PurchaseOrder.receivedById — an Employee can be the
// receiver on multiple POs over time. // receiver on multiple POs over time.
purchaseOrdersReceived PurchaseOrder[] purchaseOrdersReceived PurchaseOrder[]
// Back-relation for Assignment.employeeId — an Employee can hold multiple
// Assignments (assets/licenses) over time, including several active ones
// at once (e.g. a laptop + a license seat simultaneously).
assignments Assignment[]
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("employees") @@map("employees")
@@ -117,6 +121,10 @@ model AssetItem {
purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.assetItemId — an AssetItem can have
// multiple Assignment rows over its lifetime (historical + at most one
// active, see Assignment model's comment), not built until this task.
assignments Assignment[]
@@map("asset_items") @@map("asset_items")
} }
@@ -158,6 +166,10 @@ model License {
purchaseOrderId String? @map("purchase_order_id") @db.Uuid purchaseOrderId String? @map("purchase_order_id") @db.Uuid
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id]) purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// Back-relation for Assignment.licenseId — a License can have multiple
// Assignment rows (one per seat handed out, plus historical returns), not
// built until this task.
assignments Assignment[]
@@map("licenses") @@map("licenses")
} }
@@ -247,10 +259,10 @@ enum AssetCategoryValueGroup {
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value // (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
// set. // set.
model AssetCategory { model AssetCategory {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
name String name String
valueGroup AssetCategoryValueGroup @map("value_group") valueGroup AssetCategoryValueGroup @map("value_group")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
attributeDefs AssetCategoryAttributeDef[] attributeDefs AssetCategoryAttributeDef[]
// Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)" // Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)"
// in docs/asset-license-management.md. Only categories with // in docs/asset-license-management.md. Only categories with
@@ -344,6 +356,50 @@ model AssetModel {
@@map("asset_models") @@map("asset_models")
} }
// Assigns an AssetItem (high-value hardware) or a License seat to a specific
// Employee — see docs/asset-license-management.md's "## Assignment (chốt
// 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)"
// table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced
// at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB
// constraint, per the same convention as PurchaseOrder's
// receivedAt/receivedById co-nullability check: Prisma schema can't express
// an XOR between two nullable FKs natively. Both FKs stay plain nullable
// columns here for that reason. `assignedAt` defaults to now() but is a
// real stored column (not derived from createdAt) because a future
// "transfer" operation (out of scope for this task, see doc) will create a
// new Assignment row whose assignedAt should reflect the transfer moment,
// not necessarily row-insert time. `returnedAt` null means still held by
// the employee — this is also what "active assignment" means throughout
// the codebase (see the uniqueness rule below). `signedAt` is deliberately
// just a nullable timestamp marking "handover paperwork signed" — NO file
// upload, stakeholder explicitly decided against storing a scanned file at
// this stage (see doc). No `@@unique` on assetItemId here for "only one
// active assignment at a time" — a plain unique constraint would also
// forbid a *second, already-returned* historical Assignment for the same
// AssetItem (e.g. item reassigned after being returned), which is valid and
// expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per
// AssetItem" invariant is instead enforced in application code
// (src/lib/assignments.ts's createAssignment(), inside the same transaction
// that mutates AssetItem.status/License.seatsUsed) — a partial unique index
// (`WHERE returned_at IS NULL`) would express this at the DB level too, but
// that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately
// not added here; see feature-implementer report for this trade-off.
model Assignment {
id String @id @default(uuid()) @db.Uuid
employeeId String @map("employee_id") @db.Uuid
employee Employee @relation(fields: [employeeId], references: [id])
assetItemId String? @map("asset_item_id") @db.Uuid
assetItem AssetItem? @relation(fields: [assetItemId], references: [id])
licenseId String? @map("license_id") @db.Uuid
license License? @relation(fields: [licenseId], references: [id])
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6)
returnedAt DateTime? @map("returned_at") @db.Timestamptz(6)
signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@map("assignments")
}
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out, // Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff // see CLAUDE.md / auth decision notes). A handful of trusted IT staff
// accounts, created by hand via scripts/create-user.ts, no self-registration. // accounts, created by hand via scripts/create-user.ts, no self-registration.
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { returnAssignment, validateReturnAssignmentInput } from "@/lib/assignments";
// First dynamic-segment ([id]) route in the codebase — see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
// bullet (b): update routes use PATCH /api/<entity>/[id], not an
// action-based sub-route (e.g. NOT /api/assignments/[id]/return). Next.js
// 14 App Router route handler params are a plain synchronous object (not a
// Promise) — that changed starting in Next.js 15, which this project does
// not use (see package.json / CLAUDE.md).
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate the [id] path segment's format up front — an invalid UUID
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
// column) throws a raw PrismaClientKnownRequestError that
// returnAssignment()'s try/catch doesn't specifically handle, which would
// otherwise leak as an uncaught 500 instead of a clean 400.
if (!z.string().uuid().safeParse(params.id).success) {
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { returnedAt } = body as Record<string, unknown>;
const validation = validateReturnAssignmentInput({ returnedAt });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const updated = await returnAssignment(params.id, validation.value);
if (!updated.ok) {
return NextResponse.json({ error: updated.error }, { status: updated.status });
}
return NextResponse.json(updated.value, { status: 200 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import {
createAssignment,
listAssignments,
validateCreateAssignmentInput,
} from "@/lib/assignments";
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
// /api from route protection, so this checks the session itself. Flat
// permission model (CLAUDE.md): only "logged in or not", no role check.
export async function POST(request: Request): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
}
if (typeof body !== "object" || body === null) {
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
}
const { employeeId, assetItemId, licenseId } = body as Record<string, unknown>;
const validation = validateCreateAssignmentInput({ employeeId, assetItemId, licenseId });
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const created = await createAssignment(validation.value);
if (!created.ok) {
return NextResponse.json({ error: created.error }, { status: 400 });
}
return NextResponse.json(created.value, { status: 201 });
}
export async function GET(): Promise<NextResponse> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const assignments = await listAssignments();
return NextResponse.json(assignments, { status: 200 });
}
+409
View File
@@ -0,0 +1,409 @@
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "./prisma";
export interface AssignmentRecord {
id: string;
employee_id: string;
asset_item_id: string | null;
license_id: string | null;
assigned_at: string;
returned_at: string | null;
signed_at: string | null;
created_at: string;
}
// assignedAt/returnedAt/signedAt are all deliberately NOT accepted from the
// client at creation time — same precedent as AssetItem.status always
// starting "in_stock" and License.seatsUsed always starting 0:
// assignedAt is a system-set "moment of assignment" timestamp
// (Prisma's @default(now()) on Assignment.assignedAt), returnedAt must be
// null on creation (an Assignment cannot be created already-returned — that
// would make no sense given the seat/status side-effects below), and
// signedAt (marking handover paperwork as signed) is not wired to any
// endpoint in this task at all — see feature-implementer report's "Out of
// scope" section. It stays null until a future "mark as signed" operation
// is built.
export interface CreateAssignmentInput {
employeeId: string;
assetItemId?: string | null;
licenseId?: string | null;
}
export type ValidationResult =
| { ok: true; value: CreateAssignmentInput }
| { ok: false; error: string };
// Mirrors src/lib/purchase-orders.ts's dateString helper — accepts any
// string Date.parse() can handle and transforms it into a Date for Prisma.
function dateString(message: string) {
return z
.string({ required_error: message, invalid_type_error: message })
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
.transform((value) => new Date(value));
}
const createAssignmentSchema = z
.object({
// FK to Employee — only a UUID *format* check here, whether it
// references a real employee is left to Prisma's own FK constraint at
// insert time (see createAssignment()'s P2003 handling below). Mirrors
// src/lib/requests.ts's employee_id handling.
employeeId: z
.string({ required_error: "employeeId is required and must be a valid UUID string" })
.uuid("employeeId is required and must be a valid UUID string"),
// Exactly one of assetItemId/licenseId must be set — see the .refine()
// below for the XOR check. Unlike other nullable FKs in this codebase
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row
// is NOT deferred entirely to Prisma's insert-time FK constraint —
// createAssignment() needs to read the referenced row anyway (to check
// AssetItem.status / License.seatsUsed business rules), so existence is
// checked explicitly inside the transaction instead. Only format
// validation happens here at the Zod layer.
assetItemId: z
.string({ invalid_type_error: "assetItemId must be a valid UUID string" })
.uuid("assetItemId must be a valid UUID string")
.optional()
.nullable(),
licenseId: z
.string({ invalid_type_error: "licenseId must be a valid UUID string" })
.uuid("licenseId must be a valid UUID string")
.optional()
.nullable(),
})
.refine(
(value) => {
const hasAssetItem = value.assetItemId !== undefined && value.assetItemId !== null;
const hasLicense = value.licenseId !== undefined && value.licenseId !== null;
// XOR: exactly one of the two must be set.
return hasAssetItem !== hasLicense;
},
{
message: "Exactly one of assetItemId or licenseId must be set (not both, not neither)",
// Attach the error to both fields so it surfaces regardless of which
// one the caller looks at first — mirrors
// src/lib/purchase-orders.ts's receivedAt/receivedById co-nullability
// refine, which attaches to a single path for the same reason.
path: ["assetItemId"],
}
);
/**
* Validates raw (untyped) assignment-creation input. employeeId must be a
* valid UUID string. Exactly one of assetItemId/licenseId must be a valid
* UUID string (XOR — both set or both unset is rejected). assignedAt,
* returnedAt, and signedAt are not accepted here — see CreateAssignmentInput's
* doc comment for why.
*/
export function validateCreateAssignmentInput(input: {
employeeId?: unknown;
assetItemId?: unknown;
licenseId?: unknown;
}): ValidationResult {
const result = createAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
const returnAssignmentSchema = z.object({
// Optional — defaults to "now" at the time returnAssignment() runs if not
// provided (mirrors Assignment.assignedAt's own default-now behavior at
// creation). Accepting an explicit value lets a caller backdate a return
// (e.g. recording a return that was physically handled yesterday but only
// entered into the system today) — same spirit as PurchaseOrder accepting
// an explicit receivedAt rather than always stamping "now".
returnedAt: dateString("returnedAt must be a valid date string").optional(),
});
export type ReturnValidationResult =
| { ok: true; value: { returnedAt?: Date } }
| { ok: false; error: string };
/**
* Validates raw (untyped) assignment-return input. returnedAt is optional
* but must be a valid date string if present.
*/
export function validateReturnAssignmentInput(input: {
returnedAt?: unknown;
}): ReturnValidationResult {
const result = returnAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: {
id: string;
employeeId: string;
assetItemId: string | null;
licenseId: string | null;
assignedAt: Date;
returnedAt: Date | null;
signedAt: Date | null;
createdAt: Date;
}): AssignmentRecord {
return {
id: row.id,
employee_id: row.employeeId,
asset_item_id: row.assetItemId,
license_id: row.licenseId,
assigned_at: row.assignedAt.toISOString(),
returned_at: row.returnedAt ? row.returnedAt.toISOString() : null,
signed_at: row.signedAt ? row.signedAt.toISOString() : null,
created_at: row.createdAt.toISOString(),
};
}
// Internal error type used to carry a clean, already-user-facing message out
// of the `prisma.$transaction()` callback below, distinguishing
// application-level business-rule rejections (400) from unexpected/infra
// errors (rethrown as-is). Not exported — callers only ever see the
// {ok,error} result shape.
class AssignmentBusinessError extends Error {
constructor(message: string) {
super(message);
this.name = "AssignmentBusinessError";
}
}
class AssignmentNotFoundError extends Error {
constructor() {
super("Assignment not found");
this.name = "AssignmentNotFoundError";
}
}
export type CreateAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; error: string };
/**
* Creates an Assignment (assigns an AssetItem or a License seat to an
* Employee), enforcing every business rule from docs/
* asset-license-management.md's "## Assignment (chốt 2026-08-02)" section
* inside a single `prisma.$transaction()`:
*
* - AssetItem branch: rejects if the AssetItem doesn't exist, rejects if its
* current status isn't "in_stock" (stakeholder-confirmed precondition —
* see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
* belt-and-suspenders invariant per the doc, on top of the status check),
* then atomically flips AssetItem.status to "assigned".
* - License branch: rejects if the License doesn't exist, rejects if
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
* seatsUsed by 1.
*
* Race-condition note (read this before touching the transaction body):
* both branches use a two-step "read for validation, then conditional
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
* "assigned" } })` — is the actual race-closing mechanism: Postgres
* evaluates and applies that as a single atomic UPDATE statement, taking a
* row lock for its duration. Two concurrent transactions racing on the same
* AssetItem/License row serialize on that lock — the first to commit wins,
* and the second's WHERE clause is re-evaluated against the now-committed
* row and no longer matches, so its `updateMany` returns `count: 0`, which
* this function treats as a "lost the race" rejection. This is why the
* `findUnique` calls right before each `updateMany` are NOT the
* race-closing check by themselves (there's a real TOCTOU gap between a
* `findUnique` and a later `update` in isolation) — they only exist to
* produce a specific, friendly error message (not-found vs. wrong-status vs.
* no-seats) for the *common* (non-racing) case; the `updateMany`'s `count`
* is always re-checked afterwards and is what actually gets trusted.
* seatsTotal itself is read once via that same `findUnique` and reused in
* the `updateMany`'s WHERE clause (`seatsUsed: { lt: seatsTotal }`) — this
* is safe because nothing in this codebase ever mutates seatsTotal after
* creation (no update endpoint exists for it), so it can't go stale between
* the read and the write within the same transaction.
*
* The explicit "no existing active Assignment for this AssetItem" check
* (`tx.assignment.findFirst({ where: { assetItemId, returnedAt: null } })`)
* is intentionally layered BEFORE the atomic status update, as defense in
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
* intervention) — under normal operation this codebase always keeps
* `status === "assigned"` in exact sync with "has an active Assignment", so
* the atomic status-guarded `updateMany` above is what actually closes the
* concurrency race for the AssetItem branch; this check does not need its
* own atomic guard on top because the transaction's row lock (from the
* subsequent `updateMany`) is what makes the overall operation safe, not
* this read on its own.
*/
export async function createAssignment(
input: CreateAssignmentInput
): Promise<CreateAssignmentResult> {
try {
const created = await prisma.$transaction(async (tx) => {
if (input.assetItemId) {
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
if (!assetItem) {
throw new AssignmentBusinessError(
"assetItemId does not reference an existing asset item"
);
}
if (assetItem.status !== "in_stock") {
throw new AssignmentBusinessError(
`assetItemId refers to an AssetItem that is not in_stock (current status: ${assetItem.status})`
);
}
const existingActive = await tx.assignment.findFirst({
where: { assetItemId: input.assetItemId, returnedAt: null },
});
if (existingActive) {
throw new AssignmentBusinessError(
"assetItemId already has an active assignment (returnedAt IS NULL)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const statusUpdate = await tx.assetItem.updateMany({
where: { id: input.assetItemId, status: "in_stock" },
data: { status: "assigned" },
});
if (statusUpdate.count === 0) {
throw new AssignmentBusinessError(
"assetItemId refers to an AssetItem that is not in_stock (lost a race to another concurrent assignment)"
);
}
}
if (input.licenseId) {
const license = await tx.license.findUnique({ where: { id: input.licenseId } });
if (!license) {
throw new AssignmentBusinessError("licenseId does not reference an existing license");
}
if (license.seatsUsed >= license.seatsTotal) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (seatsUsed >= seatsTotal)"
);
}
// Atomic race-closing gate — see this function's doc comment.
const seatUpdate = await tx.license.updateMany({
where: { id: input.licenseId, seatsUsed: { lt: license.seatsTotal } },
data: { seatsUsed: { increment: 1 } },
});
if (seatUpdate.count === 0) {
throw new AssignmentBusinessError(
"licenseId has no seats remaining (lost a race to another concurrent assignment)"
);
}
}
return tx.assignment.create({
data: {
employeeId: input.employeeId,
assetItemId: input.assetItemId ?? null,
licenseId: input.licenseId ?? null,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof AssignmentBusinessError) {
return { ok: false, error: error.message };
}
// Only employeeId can still fail with a raw FK violation here —
// assetItemId/licenseId existence is already fully resolved above
// (found-or-rejected before the transaction ever reaches
// tx.assignment.create()).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return { ok: false, error: "employeeId does not reference an existing employee" };
}
throw error;
}
}
export async function listAssignments(): Promise<AssignmentRecord[]> {
const rows = await prisma.assignment.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return rows.map(toRecord);
}
export type ReturnAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Marks an Assignment as returned (sets returnedAt), and reverses its
* side-effects from createAssignment() inside the same transaction:
* - If assetItemId is set: flips AssetItem.status back to "in_stock" —
* always, regardless of any RMA/repair path (there isn't one built yet,
* per the task's instructions; see feature-implementer report).
* - If licenseId is set: atomically decrements License.seatsUsed by 1.
*
* Race-condition note: double-return is closed the same way as
* createAssignment()'s races — an atomic, WHERE-guarded `updateMany`
* (`where: { id, returnedAt: null }`) is the actual gate. Two concurrent
* PATCH-return calls against the same Assignment race on that row's UPDATE
* lock; only the first to commit gets `count: 1`, the second gets `count: 0`
* and is rejected as "already returned". The initial `findUnique` (used to
* read assetItemId/licenseId for the AssetItem/License side-effects, and to
* distinguish "not found" from "already returned" in the common case) is
* NOT itself the race-closing check, same caveat as createAssignment()'s
* doc comment. Decrementing seatsUsed does not need its own conditional
* `updateMany` guard the way incrementing did on the assign side — a bare
* `{ decrement: 1 }` is a single atomic UPDATE with no upper bound to
* enforce (unlike assign's seatsUsed < seatsTotal check), so a plain
* `update()` is race-safe on its own here.
*/
export async function returnAssignment(
id: string,
input: { returnedAt?: Date }
): Promise<ReturnAssignmentResult> {
try {
const updated = await prisma.$transaction(async (tx) => {
const assignment = await tx.assignment.findUnique({ where: { id } });
if (!assignment) {
throw new AssignmentNotFoundError();
}
const returnedAt = input.returnedAt ?? new Date();
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already returned" rejection in the non-racing case,
// since `assignment.returnedAt !== null` implies this WHERE clause
// won't match either.
const claim = await tx.assignment.updateMany({
where: { id, returnedAt: null },
data: { returnedAt },
});
if (claim.count === 0) {
throw new AssignmentBusinessError("Assignment has already been returned");
}
if (assignment.assetItemId) {
await tx.assetItem.update({
where: { id: assignment.assetItemId },
data: { status: "in_stock" },
});
}
if (assignment.licenseId) {
await tx.license.update({
where: { id: assignment.licenseId },
data: { seatsUsed: { decrement: 1 } },
});
}
return tx.assignment.findUniqueOrThrow({ where: { id } });
});
return { ok: true, value: toRecord(updated) };
} catch (error) {
if (error instanceof AssignmentNotFoundError) {
return { ok: false, status: 404, error: "Assignment not found" };
}
if (error instanceof AssignmentBusinessError) {
return { ok: false, status: 400, error: error.message };
}
throw error;
}
}
+764
View File
@@ -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<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("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" });
});
});