fix: retrofit AssetItem.model to modelId FK

AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
This commit is contained in:
toanhoang
2026-08-02 21:07:12 -04:00
parent d280279322
commit 2a5792df67
5 changed files with 169 additions and 46 deletions
+105 -24
View File
@@ -50,16 +50,52 @@ function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
});
}
// 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 since asset_items has FKs (ON DELETE SET NULL to
// purchase_orders) — deleteMany order avoids relying on that cascade
// behavior in tests. purchase_orders must be cleared before vendors
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts).
// 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).
@@ -68,8 +104,9 @@ beforeEach(async () => {
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", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
@@ -77,7 +114,7 @@ describe("POST /api/assets", () => {
expect(body).toMatchObject({
serial_number: "SN-001",
model: "Dell Latitude 5440",
model_id: modelId,
status: "in_stock",
warranty_end_date: null,
});
@@ -88,10 +125,11 @@ describe("POST /api/assets", () => {
});
it("creates an asset item with a warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-002",
model: "Dell Latitude 5440",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "2029-01-15",
})
@@ -103,24 +141,26 @@ describe("POST /api/assets", () => {
});
it("rejects an empty serialNumber", async () => {
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" }));
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 an empty model", async () => {
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" }));
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(/model/i);
expect(body.error).toMatch(/modelId/i);
});
it("rejects an invalid purchaseDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" })
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
);
expect(res.status).toBe(400);
@@ -129,10 +169,11 @@ describe("POST /api/assets", () => {
});
it("rejects an invalid warrantyEndDate", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-005",
model: "Dell",
modelId,
purchaseDate: "2026-01-15",
warrantyEndDate: "not-a-date",
})
@@ -144,13 +185,14 @@ describe("POST /api/assets", () => {
});
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",
model: "Dell Latitude 5440",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: purchaseOrder.id,
})
@@ -162,8 +204,9 @@ describe("POST /api/assets", () => {
});
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(201);
@@ -172,10 +215,11 @@ describe("POST /api/assets", () => {
});
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",
model: "Dell Latitude 5440",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
})
@@ -192,10 +236,11 @@ describe("POST /api/assets", () => {
});
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
const modelId = await createTestModelId();
const res = await POST(
postRequest({
serialNumber: "SN-012",
model: "Dell Latitude 5440",
modelId,
purchaseDate: "2026-01-15",
purchaseOrderId: "not-a-uuid",
})
@@ -207,8 +252,9 @@ describe("POST /api/assets", () => {
});
it("rejects a duplicate serialNumber", async () => {
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
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();
@@ -218,14 +264,48 @@ describe("POST /api/assets", () => {
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);
});
});
describe("GET /api/assets", () => {
it("lists created asset items, newest first", async () => {
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" }));
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", model: "HP", purchaseDate: "2026-02-01" }));
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
const res = await GET();
expect(res.status).toBe(200);
@@ -235,8 +315,8 @@ describe("GET /api/assets", () => {
expect(body).toHaveLength(2);
// newest first
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" });
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
});
it("returns an empty list when there are no asset items", async () => {
@@ -252,10 +332,11 @@ describe("auth gating on /api/assets", () => {
// 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", model: "Dell", purchaseDate: "2026-01-15" })
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
);
expect(res.status).toBe(401);