'assigned' implied a specific person always holds the item, but
createAssignment() only requires status=in_stock to hand something out --
the status value itself never guaranteed a matching Assignment row exists.
~158 of the 613 real-imported assets carry this status with no Assignment
at all (shared/unowned equipment like meeting-room gear), so "assigned"
was actively misleading for them. 'in_use' doesn't carry that implication.
DB: `ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use'`
(hand-written migration -- runs cleanly, no data migration needed, every
existing row's status just reads under the new name). Renamed across:
schema.prisma enum, src/lib/assets.ts (type union + IMPORT_STATUS_MAP,
which keeps an `assigned` key mapped to `in_use` for backward-compat with
pre-2026-08-25 exports/CSVs), src/lib/assignments.ts (the literal status
value createAssignment() sets), src/app/assets/page.tsx + api/assets/
route.ts (filter dropdown + validStatuses), src/app/maintenance-records/
page.tsx (its own copy of the status list for the "close" targetStatus
picker). maintenance-records.ts itself needed no logic change -- its
closeableTargetStatusValues was already just ["in_stock", "retired"],
"assigned" was never a valid close target, only mentioned in comments
(updated for accuracy).
Also fixes a real regression found while verifying this: tests/
assets.test.ts (a pre-existing, properly-reviewed test file, not written
this session) broke 2 commits ago when GET /api/assets's signature and
response shape changed for pagination -- it still called GET() with no
Request and expected a bare array. tsc didn't catch this until now
because this container has no dev bind mount (Dockerfile is COPY-based,
`docker exec` only reflects the last build) -- see the
ubi-dan-itam-docker-no-bind-mount memory note. Added a getRequest()
helper and updated assertions to the {items, total} shape.
Verified by actually rebuilding and running the full suite fresh (not
just tsc/lint smoke checks): 369/369 tests pass, including
assignments.test.ts and maintenance-records.test.ts, which this rename
touches directly. tsc/lint clean against the rebuilt image. Migration
applied to the real DB, data verified intact (542 in_use + 71 in_stock =
613, matches pre-rename counts exactly).
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||||
|
ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use';
|
||||||
@@ -90,10 +90,15 @@ model Employee {
|
|||||||
// 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the
|
// 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the
|
||||||
// 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the
|
// 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the
|
||||||
// values are read directly by business logic, e.g. createAssignment()'s
|
// values are read directly by business logic, e.g. createAssignment()'s
|
||||||
// `status !== "in_stock"` gate, not just display labels).
|
// `status !== "in_stock"` gate, not just display labels). `assigned`
|
||||||
|
// renamed to `in_use` same day (ALTER TYPE ... RENAME VALUE) -- "assigned"
|
||||||
|
// implied a specific person always holds it, but createAssignment() only
|
||||||
|
// requires in_stock -> the enum value itself, and ~158 real-imported items
|
||||||
|
// have this status with no Assignment row at all (shared/unowned
|
||||||
|
// equipment) -- "in_use" doesn't carry that false implication.
|
||||||
enum AssetItemStatus {
|
enum AssetItemStatus {
|
||||||
in_stock
|
in_stock
|
||||||
assigned
|
in_use
|
||||||
repair
|
repair
|
||||||
retired
|
retired
|
||||||
lost
|
lost
|
||||||
@@ -157,7 +162,7 @@ model AssetItem {
|
|||||||
// Nullable: real-world coverage is thin (the ITAM export's own location
|
// Nullable: real-world coverage is thin (the ITAM export's own location
|
||||||
// data was nearly useless -- "Physical location" was the literal string
|
// data was nearly useless -- "Physical location" was the literal string
|
||||||
// "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried
|
// "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried
|
||||||
// any signal, and only for the ~71 in_stock items -- an assigned item is
|
// any signal, and only for the ~71 in_stock items -- an in_use item is
|
||||||
// with a person, not sitting in a stockroom bin, so it has no area here
|
// with a person, not sitting in a stockroom bin, so it has no area here
|
||||||
// by design, not by data gap).
|
// by design, not by data gap).
|
||||||
areaId String? @map("area_id") @db.Uuid
|
areaId String? @map("area_id") @db.Uuid
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export async function GET(request: Request): Promise<NextResponse> {
|
|||||||
const areaId = params.get("areaId") ?? undefined;
|
const areaId = params.get("areaId") ?? undefined;
|
||||||
const search = params.get("search") ?? undefined;
|
const search = params.get("search") ?? undefined;
|
||||||
|
|
||||||
const validStatuses = ["in_stock", "assigned", "repair", "retired", "lost"];
|
const validStatuses = ["in_stock", "in_use", "repair", "retired", "lost"];
|
||||||
const result = await listAssetItems({
|
const result = await listAssetItems({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ interface AssetItemRecord {
|
|||||||
model_id: string;
|
model_id: string;
|
||||||
purchase_date: string | null;
|
purchase_date: string | null;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
status: "in_stock" | "in_use" | "repair" | "retired" | "lost";
|
||||||
cost: number | null;
|
cost: number | null;
|
||||||
cost_currency: string | null;
|
cost_currency: string | null;
|
||||||
area_id: string | null;
|
area_id: string | null;
|
||||||
@@ -467,7 +467,7 @@ export default function AssetsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All</SelectItem>
|
<SelectItem value="all">All</SelectItem>
|
||||||
<SelectItem value="in_stock">in_stock</SelectItem>
|
<SelectItem value="in_stock">in_stock</SelectItem>
|
||||||
<SelectItem value="assigned">assigned</SelectItem>
|
<SelectItem value="in_use">in_use</SelectItem>
|
||||||
<SelectItem value="repair">repair</SelectItem>
|
<SelectItem value="repair">repair</SelectItem>
|
||||||
<SelectItem value="retired">retired</SelectItem>
|
<SelectItem value="retired">retired</SelectItem>
|
||||||
<SelectItem value="lost">lost</SelectItem>
|
<SelectItem value="lost">lost</SelectItem>
|
||||||
@@ -623,7 +623,7 @@ export default function AssetsPage() {
|
|||||||
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
||||||
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
||||||
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
||||||
<code>In use</code> → assigned, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
<code>In use</code> → in_use, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
||||||
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
||||||
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
||||||
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
|
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ interface VendorRecord {
|
|||||||
// assetItemStatusValues constant (not re-derived from an API call, this is
|
// assetItemStatusValues constant (not re-derived from an API call, this is
|
||||||
// a closed set already known client-side, same precedent as licenses
|
// a closed set already known client-side, same precedent as licenses
|
||||||
// page's licenseType/perpetual|subscription <Select>).
|
// page's licenseType/perpetual|subscription <Select>).
|
||||||
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "assigned", "repair", "retired"] as const;
|
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "in_use", "repair", "retired"] as const;
|
||||||
|
|
||||||
// Radix <Select.Item> can't take an empty-string value, so "no vendor" is
|
// Radix <Select.Item> can't take an empty-string value, so "no vendor" is
|
||||||
// represented by this sentinel in the form state and translated to "omit
|
// represented by this sentinel in the form state and translated to "omit
|
||||||
|
|||||||
+6
-5
@@ -4,7 +4,7 @@ import { prisma } from "./prisma";
|
|||||||
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
|
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
|
||||||
import { createOrGetArea } from "./areas";
|
import { createOrGetArea } from "./areas";
|
||||||
|
|
||||||
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired" | "lost";
|
export type AssetItemStatus = "in_stock" | "in_use" | "repair" | "retired" | "lost";
|
||||||
|
|
||||||
export interface AssetItemRecord {
|
export interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -526,14 +526,15 @@ export interface ImportAssetItemsResult {
|
|||||||
// Maps free-text status values from a real export (e.g. ServiceNow's
|
// Maps free-text status values from a real export (e.g. ServiceNow's
|
||||||
// "State" column: "In use"/"In stock") onto this app's AssetItemStatus
|
// "State" column: "In use"/"In stock") onto this app's AssetItemStatus
|
||||||
// enum -- plus this app's own enum values, in case a re-exported CSV
|
// enum -- plus this app's own enum values, in case a re-exported CSV
|
||||||
// round-trips one back in. "In use" -> "assigned" is a simplification: it
|
// round-trips one back in. "In use" -> "in_use" is a simplification: it
|
||||||
// doesn't require (or create) a matching Assignment row, just reflects
|
// doesn't require (or create) a matching Assignment row, just reflects
|
||||||
// that the item is deployed rather than sitting in stock -- see
|
// that the item is deployed rather than sitting in stock -- see
|
||||||
// docs/asset-license-management.md's 2026-08-24 section for why linking
|
// docs/asset-license-management.md's 2026-08-24 section for why linking
|
||||||
// actual Employee assignments is explicitly out of scope for this import.
|
// actual Employee assignments is explicitly out of scope for this import.
|
||||||
const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
||||||
"in use": "assigned",
|
"in use": "in_use",
|
||||||
assigned: "assigned",
|
in_use: "in_use",
|
||||||
|
assigned: "in_use", // backward-compat: pre-2026-08-25 exports/CSVs said "assigned"
|
||||||
"in stock": "in_stock",
|
"in stock": "in_stock",
|
||||||
in_stock: "in_stock",
|
in_stock: "in_stock",
|
||||||
repair: "repair",
|
repair: "repair",
|
||||||
@@ -742,7 +743,7 @@ export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<Impo
|
|||||||
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
|
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
|
||||||
* real round trip, not just a one-way dump. `status` round-trips too as of
|
* real round trip, not just a one-way dump. `status` round-trips too as of
|
||||||
* 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
|
* 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
|
||||||
* enum text ("in_stock"/"assigned"/...), which IMPORT_STATUS_MAP also
|
* enum text ("in_stock"/"in_use"/...), which IMPORT_STATUS_MAP also
|
||||||
* recognizes on the way back in.
|
* recognizes on the way back in.
|
||||||
*/
|
*/
|
||||||
export async function exportAssetItemsCsv(): Promise<string> {
|
export async function exportAssetItemsCsv(): Promise<string> {
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export type CreateAssignmentResult =
|
|||||||
* see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an
|
* 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
|
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
|
||||||
* belt-and-suspenders invariant per the doc, on top of the status check),
|
* belt-and-suspenders invariant per the doc, on top of the status check),
|
||||||
* then atomically flips AssetItem.status to "assigned".
|
* then atomically flips AssetItem.status to "in_use".
|
||||||
* - License branch: rejects if the License doesn't exist, rejects if
|
* - License branch: rejects if the License doesn't exist, rejects if
|
||||||
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
||||||
* seatsUsed by 1.
|
* seatsUsed by 1.
|
||||||
@@ -238,7 +238,7 @@ export type CreateAssignmentResult =
|
|||||||
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
|
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
|
||||||
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
|
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
|
||||||
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
|
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
|
||||||
* "assigned" } })` — is the actual race-closing mechanism: Postgres
|
* "in_use" } })` — is the actual race-closing mechanism: Postgres
|
||||||
* evaluates and applies that as a single atomic UPDATE statement, taking a
|
* evaluates and applies that as a single atomic UPDATE statement, taking a
|
||||||
* row lock for its duration. Two concurrent transactions racing on the same
|
* row lock for its duration. Two concurrent transactions racing on the same
|
||||||
* AssetItem/License row serialize on that lock — the first to commit wins,
|
* AssetItem/License row serialize on that lock — the first to commit wins,
|
||||||
@@ -262,7 +262,7 @@ export type CreateAssignmentResult =
|
|||||||
* is intentionally layered BEFORE the atomic status update, as defense in
|
* is intentionally layered BEFORE the atomic status update, as defense in
|
||||||
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
|
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
|
||||||
* intervention) — under normal operation this codebase always keeps
|
* intervention) — under normal operation this codebase always keeps
|
||||||
* `status === "assigned"` in exact sync with "has an active Assignment", so
|
* `status === "in_use"` in exact sync with "has an active Assignment", so
|
||||||
* the atomic status-guarded `updateMany` above is what actually closes the
|
* the atomic status-guarded `updateMany` above is what actually closes the
|
||||||
* concurrency race for the AssetItem branch; this check does not need its
|
* 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
|
* own atomic guard on top because the transaction's row lock (from the
|
||||||
@@ -298,7 +298,7 @@ export async function createAssignment(
|
|||||||
// Atomic race-closing gate — see this function's doc comment.
|
// Atomic race-closing gate — see this function's doc comment.
|
||||||
const statusUpdate = await tx.assetItem.updateMany({
|
const statusUpdate = await tx.assetItem.updateMany({
|
||||||
where: { id: input.assetItemId, status: "in_stock" },
|
where: { id: input.assetItemId, status: "in_stock" },
|
||||||
data: { status: "assigned" },
|
data: { status: "in_use" },
|
||||||
});
|
});
|
||||||
if (statusUpdate.count === 0) {
|
if (statusUpdate.count === 0) {
|
||||||
throw new AssignmentBusinessError(
|
throw new AssignmentBusinessError(
|
||||||
@@ -463,7 +463,7 @@ export type TransferAssignmentResult =
|
|||||||
* transfer (no race window for a third party to grab either one mid-transfer
|
* transfer (no race window for a third party to grab either one mid-transfer
|
||||||
* — that's the whole point of doing this in one transaction instead of a
|
* — that's the whole point of doing this in one transaction instead of a
|
||||||
* separate PATCH-return followed by a POST-create, which is exactly the gap
|
* separate PATCH-return followed by a POST-create, which is exactly the gap
|
||||||
* this function closes). AssetItem.status stays "assigned" throughout;
|
* this function closes). AssetItem.status stays "in_use" throughout;
|
||||||
* seatsUsed is net zero (same seat, new holder).
|
* seatsUsed is net zero (same seat, new holder).
|
||||||
*
|
*
|
||||||
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function validateCreateMaintenanceRecordInput(input: {
|
|||||||
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
|
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
|
||||||
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
|
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
|
||||||
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
|
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
|
||||||
// why "assigned"/"repair" are excluded).
|
// why "in_use"/"repair" are excluded).
|
||||||
const closeableTargetStatusValues = ["in_stock", "retired"] as const;
|
const closeableTargetStatusValues = ["in_stock", "retired"] as const;
|
||||||
|
|
||||||
const closeMaintenanceRecordSchema = z.object({
|
const closeMaintenanceRecordSchema = z.object({
|
||||||
@@ -130,8 +130,8 @@ const closeMaintenanceRecordSchema = z.object({
|
|||||||
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
|
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
|
||||||
// AssetItemStatus enum) — those are the only two outcomes a repair can
|
// AssetItemStatus enum) — those are the only two outcomes a repair can
|
||||||
// actually resolve to (fixed → back to stock, unfixable → write off).
|
// actually resolve to (fixed → back to stock, unfixable → write off).
|
||||||
// "assigned" is deliberately rejected: setting AssetItem.status to
|
// "in_use" is deliberately rejected: setting AssetItem.status to
|
||||||
// "assigned" here would NOT create a corresponding Assignment row, so
|
// "in_use" here would NOT create a corresponding Assignment row, so
|
||||||
// Assignment (the source of truth for "who currently holds what") and
|
// Assignment (the source of truth for "who currently holds what") and
|
||||||
// AssetItem.status would go out of sync with no way back — createAssignment()
|
// AssetItem.status would go out of sync with no way back — createAssignment()
|
||||||
// only accepts a "in_stock" AssetItem, and there is no standalone
|
// only accepts a "in_stock" AssetItem, and there is no standalone
|
||||||
@@ -252,7 +252,7 @@ export type CreateMaintenanceRecordResult =
|
|||||||
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
|
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
|
||||||
* plain read-then-write. Pinning the exact required prior value (not
|
* plain read-then-write. Pinning the exact required prior value (not
|
||||||
* excluding "retired") matters: an excluding guard would still match an
|
* excluding "retired") matters: an excluding guard would still match an
|
||||||
* item another concurrent transaction just flipped to "assigned" or
|
* item another concurrent transaction just flipped to "in_use" or
|
||||||
* "repair", letting two concurrent opens (or an open racing an Assignment
|
* "repair", letting two concurrent opens (or an open racing an Assignment
|
||||||
* create) both win. That `updateMany`'s `count` is the actual
|
* create) both win. That `updateMany`'s `count` is the actual
|
||||||
* race-closing gate: two concurrent opens racing on the same AssetItem
|
* race-closing gate: two concurrent opens racing on the same AssetItem
|
||||||
@@ -304,7 +304,7 @@ export async function createMaintenanceRecord(
|
|||||||
// exact prior value ("in_stock") rather than excluding a value
|
// exact prior value ("in_stock") rather than excluding a value
|
||||||
// ("not retired"), mirroring createAssignment()'s own status guard
|
// ("not retired"), mirroring createAssignment()'s own status guard
|
||||||
// (src/lib/assignments.ts) — the excluding form would still match an
|
// (src/lib/assignments.ts) — the excluding form would still match an
|
||||||
// AssetItem another concurrent transaction just flipped to "assigned"
|
// AssetItem another concurrent transaction just flipped to "in_use"
|
||||||
// or "repair" (neither equals "retired"), letting two opens (or an
|
// or "repair" (neither equals "retired"), letting two opens (or an
|
||||||
// open racing an Assignment create) both win.
|
// open racing an Assignment create) both win.
|
||||||
const statusUpdate = await tx.assetItem.updateMany({
|
const statusUpdate = await tx.assetItem.updateMany({
|
||||||
|
|||||||
+18
-8
@@ -24,6 +24,13 @@ function postRequest(body: unknown): Request {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/assets takes a Request now (2026-08-25, see route.ts) so it can
|
||||||
|
// read pagination/filter query params -- query is an optional raw query
|
||||||
|
// string (e.g. "status=in_use&page=2"), empty by default.
|
||||||
|
function getRequest(query = ""): Request {
|
||||||
|
return new Request(`http://localhost/api/assets${query ? `?${query}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
|
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
|
||||||
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
||||||
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
|
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
|
||||||
@@ -331,23 +338,26 @@ describe("GET /api/assets", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET(getRequest());
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
// {items, total} now (2026-08-25, see listAssetItems()'s doc comment
|
||||||
|
// in src/lib/assets.ts) -- was a bare array before pagination.
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(Array.isArray(body)).toBe(true);
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
expect(body).toHaveLength(2);
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
expect(body.items[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
expect(body.items[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no asset items", async () => {
|
it("returns an empty list when there are no asset items", async () => {
|
||||||
const res = await GET();
|
const res = await GET(getRequest());
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toEqual([]);
|
expect(body).toEqual({ items: [], total: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,7 +385,7 @@ describe("auth gating on /api/assets", () => {
|
|||||||
it("GET /api/assets returns 401 without a session", async () => {
|
it("GET /api/assets returns 401 without a session", async () => {
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET(getRequest());
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|||||||
+11
-11
@@ -72,7 +72,7 @@ async function createTestModelId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serialCounter = 0;
|
let serialCounter = 0;
|
||||||
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "assigned" | "repair" | "retired" }) {
|
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "in_use" | "repair" | "retired" }) {
|
||||||
serialCounter += 1;
|
serialCounter += 1;
|
||||||
return prisma.assetItem.create({
|
return prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -223,7 +223,7 @@ describe("validateReturnAssignmentInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createAssignment", () => {
|
describe("createAssignment", () => {
|
||||||
it("assigns an in_stock AssetItem, flips its status to assigned", async () => {
|
it("assigns an in_stock AssetItem, flips its status to in_use", async () => {
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId });
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
@@ -236,10 +236,10 @@ describe("createAssignment", () => {
|
|||||||
expect(created.value.returned_at).toBeNull();
|
expect(created.value.returned_at).toBeNull();
|
||||||
|
|
||||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(updatedItem.status).toBe("assigned");
|
expect(updatedItem.status).toBe("in_use");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["repair", "retired", "assigned"] as const)(
|
it.each(["repair", "retired", "in_use"] as const)(
|
||||||
"rejects assigning an AssetItem whose status is %s",
|
"rejects assigning an AssetItem whose status is %s",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
@@ -312,7 +312,7 @@ describe("createAssignment", () => {
|
|||||||
expect(second.value.employee_id).toBe(employee2.id);
|
expect(second.value.employee_id).toBe(employee2.id);
|
||||||
|
|
||||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(updatedItem.status).toBe("assigned");
|
expect(updatedItem.status).toBe("in_use");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
|
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
|
||||||
@@ -434,7 +434,7 @@ describe("createAssignment", () => {
|
|||||||
expect(failed).toHaveLength(1);
|
expect(failed).toHaveLength(1);
|
||||||
|
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(item.status).toBe("assigned");
|
expect(item.status).toBe("in_use");
|
||||||
|
|
||||||
const activeAssignments = await prisma.assignment.findMany({
|
const activeAssignments = await prisma.assignment.findMany({
|
||||||
where: { assetItemId: assetItem.id, returnedAt: null },
|
where: { assetItemId: assetItem.id, returnedAt: null },
|
||||||
@@ -545,7 +545,7 @@ describe("returnAssignment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("transferAssignment", () => {
|
describe("transferAssignment", () => {
|
||||||
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
|
it("transfers an AssetItem assignment to a new employee, keeping status in_use throughout", async () => {
|
||||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
@@ -562,11 +562,11 @@ describe("transferAssignment", () => {
|
|||||||
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
||||||
expect(transferred.value.returned_at).toBeNull();
|
expect(transferred.value.returned_at).toBeNull();
|
||||||
|
|
||||||
// The AssetItem must remain "assigned" throughout the transfer — never
|
// The AssetItem must remain "in_use" throughout the transfer — never
|
||||||
// flipped to in_stock mid-way (that's the whole point of doing this in
|
// flipped to in_stock mid-way (that's the whole point of doing this in
|
||||||
// one transaction rather than a separate return + create).
|
// one transaction rather than a separate return + create).
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(item.status).toBe("assigned");
|
expect(item.status).toBe("in_use");
|
||||||
|
|
||||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||||
expect(original.returnedAt).not.toBeNull();
|
expect(original.returnedAt).not.toBeNull();
|
||||||
@@ -658,7 +658,7 @@ describe("transferAssignment", () => {
|
|||||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||||
expect(original.returnedAt).toBeNull();
|
expect(original.returnedAt).toBeNull();
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(item.status).toBe("assigned");
|
expect(item.status).toBe("in_use");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -898,7 +898,7 @@ describe("PATCH /api/assignments/[id]", () => {
|
|||||||
expect(body.returned_at).toBeNull();
|
expect(body.returned_at).toBeNull();
|
||||||
|
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
expect(item.status).toBe("assigned");
|
expect(item.status).toBe("in_use");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
|
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async function createTestModelId(): Promise<string> {
|
|||||||
let serialCounter = 0;
|
let serialCounter = 0;
|
||||||
function createTestAssetItem(overrides: {
|
function createTestAssetItem(overrides: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
status?: "in_stock" | "assigned" | "repair" | "retired";
|
status?: "in_stock" | "in_use" | "repair" | "retired";
|
||||||
}) {
|
}) {
|
||||||
serialCounter += 1;
|
serialCounter += 1;
|
||||||
return prisma.assetItem.create({
|
return prisma.assetItem.create({
|
||||||
@@ -223,7 +223,7 @@ describe("createMaintenanceRecord", () => {
|
|||||||
|
|
||||||
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
|
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
await prisma.assignment.create({
|
await prisma.assignment.create({
|
||||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
||||||
@@ -495,7 +495,7 @@ describe("POST /api/maintenance-records", () => {
|
|||||||
|
|
||||||
it("rejects creating a record for an AssetItem with an active assignment", async () => {
|
it("rejects creating a record for an AssetItem with an active assignment", async () => {
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
await prisma.assignment.create({
|
await prisma.assignment.create({
|
||||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
||||||
|
|||||||
Reference in New Issue
Block a user