Compare commits
4
Commits
master
...
6b39c01f4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b39c01f4e | ||
|
|
5145c6af31 | ||
|
|
a72b8dbbbe | ||
|
|
07b01cd707 |
@@ -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;
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user