Files
ubi-dan-itam/tests/assets.test.ts
toanhoang 03d74d69c1
CI / test (push) Failing after 9s
Rename AssetItemStatus 'assigned' to 'in_use'
'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).
2026-08-24 21:57:33 -04:00

395 lines
13 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Mock } from "vitest";
import type { Session } from "next-auth";
// Mirrors tests/employees.test.ts's @/auth mocking pattern.
vi.mock("@/auth", () => ({ auth: vi.fn() }));
import { GET, POST } from "@/app/api/assets/route";
import { prisma } from "@/lib/prisma";
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/assets", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
// 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
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
// first and use their ids. Mirrors tests/purchase-orders.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 createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
return prisma.purchaseOrder.create({
data: {
vendorId: overrides.vendorId as string,
orderDate: new Date("2026-08-01"),
expectedDate: new Date("2026-08-10"),
},
});
}
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
// AssetCategory — tests that need a valid modelId create the full chain
// first and use the model's id. Mirrors tests/asset-models.test.ts's
// createTestAssetCategory() helper. Category is always high_value here —
// AssetModel only ever references high_value categories per
// src/lib/asset-models.ts's business rule.
function createTestAssetCategory(overrides: { name?: string } = {}) {
return prisma.assetCategory.create({
data: {
name: overrides.name ?? "Laptop",
valueGroup: "high_value",
},
});
}
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
return prisma.assetModel.create({
data: {
categoryId: overrides.categoryId as string,
manufacturer: "Dell",
name: "Latitude 5440",
},
});
}
async function createTestModelId(): Promise<string> {
const category = await createTestAssetCategory();
const model = await createTestAssetModel({ categoryId: category.id });
return model.id;
}
beforeEach(async () => {
// Each test starts from empty tables so listing/order assertions are
// deterministic. asset_items must be cleared before purchase_orders/
// vendors/asset_models/asset_categories since asset_items has FKs to all
// of them — deleteMany order avoids relying on cascade behavior in tests.
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
// tests/asset-models.test.ts's cleanup order).
await prisma.assetItem.deleteMany();
await prisma.purchaseOrder.deleteMany();
await prisma.vendor.deleteMany();
await prisma.assetModel.deleteMany();
await prisma.assetCategory.deleteMany();
// Default to a logged-in session — the tests below exercise business
// logic, not auth gating (that's covered by the "auth gating" describe
// block further down).
mockedAuth.mockResolvedValue(testSession);
});
describe("POST /api/assets", () => {
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body).toMatchObject({
serial_number: "SN-001",
model_id: modelId,
status: "in_stock",
warranty_end_date: null,
});
expect(typeof body.id).toBe("string");
expect(body.id.length).toBeGreaterThan(0);
expect(Number.isNaN(Date.parse(body.purchase_date))).toBe(false);
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
});
it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-002",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15",
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(Number.isNaN(Date.parse(body.warranty_end_date))).toBe(false);
});
it("rejects an empty serialNumber", async () => {
const modelId = await createTestModelId();
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/serialNumber/i);
});
it("rejects a missing modelId", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
});
it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseDate/i);
});
it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-005",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/warrantyEndDate/i);
});
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
const modelId = await createTestModelId();
const vendor = await createTestVendor();
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
const res = await POST(
postRequest({
serialNumber: "SN-007",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: purchaseOrder.id,
})
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBe(purchaseOrder.id);
});
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
const body = await res.json();
expect(body.purchase_order_id).toBeNull();
});
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-009",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
expect(body.error).toMatch(/does not reference an existing purchase order/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-012",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "not-a-uuid",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/purchaseOrderId/i);
});
it("rejects a duplicate serialNumber", async () => {
const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/serialNumber already exists/i);
// And it must not have created a second row.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(1);
});
it("rejects a modelId that is not a valid UUID", async () => {
const res = await POST(
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
const res = await POST(
postRequest({
serialNumber: "SN-014",
modelId: "00000000-0000-0000-0000-000000000000",
purchaseDate: "2026-01-15",
})
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/modelId/i);
expect(body.error).toMatch(/does not reference an existing asset model/i);
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("rejects an invalid JSON body", async () => {
const req = new Request("http://localhost/api/assets", {
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 () => {
// Note: arrays are `typeof "object"` in JS, so they pass the route's
// object check — use a JSON string primitive here to actually exercise
// the "not an object" branch (mirrors tests/licenses.test.ts).
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/assets", () => {
it("lists created asset items, newest first", async () => {
const modelId = await createTestModelId();
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
await new Promise((resolve) => setTimeout(resolve, 5));
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
const res = await GET(getRequest());
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();
expect(Array.isArray(body.items)).toBe(true);
expect(body.items).toHaveLength(2);
expect(body.total).toBe(2);
// newest first
expect(body.items[0]).toMatchObject({ serial_number: "SN-011", 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 () => {
const res = await GET(getRequest());
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ items: [], total: 0 });
});
});
describe("auth gating on /api/assets", () => {
// Mirrors tests/employees.test.ts's "auth gating" block — only checks
// session presence, per the flat permission model (no role/permission
// logic).
it("POST /api/assets returns 401 without a session", async () => {
const modelId = await createTestModelId();
mockedAuth.mockResolvedValue(null);
const res = await POST(
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
// And it must not have created anything.
const remaining = await prisma.assetItem.findMany();
expect(remaining).toHaveLength(0);
});
it("GET /api/assets returns 401 without a session", async () => {
mockedAuth.mockResolvedValue(null);
const res = await GET(getRequest());
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
});