import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import type { Session } from "next-auth"; import { closeMaintenanceRecord, createMaintenanceRecord, listMaintenanceRecords, validateCloseMaintenanceRecordInput, validateCreateMaintenanceRecordInput, } from "@/lib/maintenance-records"; import { prisma } from "@/lib/prisma"; // This file has two layers of coverage, mirroring tests/assignments.test.ts: // - lib-level tests exercise src/lib/maintenance-records.ts directly // (validation, transactional business rules). // - route-level tests exercise src/app/api/maintenance-records/route.ts and // src/app/api/maintenance-records/[id]/route.ts over HTTP, including the // auth() gate — mirrors tests/assignments.test.ts's @/auth mocking pattern. vi.mock("@/auth", () => ({ auth: vi.fn() })); import { GET, POST } from "@/app/api/maintenance-records/route"; import { PATCH } from "@/app/api/maintenance-records/[id]/route"; import { auth } from "@/auth"; const mockedAuth = auth as unknown as Mock<() => Promise>; 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/maintenance-records", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } function patchRequest(body: unknown): Request { return new Request("http://localhost/api/maintenance-records/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); } // MaintenanceRecord.assetItemId is a real FK to AssetItem, which itself // requires a full AssetModel -> AssetCategory chain. Mirrors // tests/assignments.test.ts's createTestModelId() helper. async function createTestModelId(): Promise { 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" | "in_use" | "repair" | "retired"; }) { serialCounter += 1; return prisma.assetItem.create({ data: { serialNumber: `SN-MAINT-${serialCounter}`, modelId: overrides.modelId, purchaseDate: new Date("2026-01-15"), status: overrides.status ?? "in_stock", }, }); } // MaintenanceRecord.employeeId isn't a field on the model, but Assignment is // used to exercise the "active assignment blocks maintenance" rule, which // itself needs an Employee. Mirrors tests/assignments.test.ts's // createTestEmployee() helper. function createTestEmployee(overrides: { name?: string } = {}) { return prisma.employee.create({ data: { name: overrides.name ?? "Test Employee", department: "IT", }, }); } // MaintenanceRecord.vendorId is a real (nullable) FK to Vendor. Mirrors // tests/assignments.test.ts's createTestVendor() helper. function createTestVendor(overrides: { name?: string } = {}) { return prisma.vendor.create({ data: { name: overrides.name ?? "Test Vendor", contactEmail: "sales@testvendor.example", contactPhone: "0281234567", address: "123 Test Street", }, }); } beforeEach(async () => { // Clear in FK-safe order: maintenance_records + assignments first // (reference asset_items/licenses/vendors/employees), then asset_items/ // licenses (reference purchase_orders/vendors/models), then the rest. await prisma.maintenanceRecord.deleteMany(); 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("validateCreateMaintenanceRecordInput", () => { it("accepts valid input without vendorId", () => { const result = validateCreateMaintenanceRecordInput({ assetItemId: "11111111-1111-1111-1111-111111111111", type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }); expect(result.ok).toBe(true); }); it("accepts valid input with vendorId set", () => { const result = validateCreateMaintenanceRecordInput({ assetItemId: "11111111-1111-1111-1111-111111111111", type: "warranty", vendorId: "22222222-2222-2222-2222-222222222222", issueDescription: "Battery swollen", startDate: "2026-08-01", }); expect(result.ok).toBe(true); }); it("rejects a missing assetItemId", () => { const result = validateCreateMaintenanceRecordInput({ type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/assetItemId/i); } }); it("rejects an invalid type", () => { const result = validateCreateMaintenanceRecordInput({ assetItemId: "11111111-1111-1111-1111-111111111111", type: "not-a-type", issueDescription: "Screen flickers", startDate: "2026-08-01", }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/type/i); } }); }); describe("createMaintenanceRecord", () => { it("opens a maintenance record, flips AssetItem.status to repair", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(true); if (!created.ok) return; expect(created.value.asset_item_id).toBe(assetItem.id); expect(created.value.vendor_id).toBeNull(); expect(created.value.end_date).toBeNull(); const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(updatedItem.status).toBe("repair"); }); it("creates successfully without a vendorId", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "preventive", issueDescription: "Routine cleaning", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(true); if (!created.ok) return; expect(created.value.vendor_id).toBeNull(); }); it("creates successfully with a vendorId", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const vendor = await createTestVendor(); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "warranty", vendorId: vendor.id, issueDescription: "Battery swollen", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(true); if (!created.ok) return; expect(created.value.vendor_id).toBe(vendor.id); }); it("rejects opening a record for an AssetItem that has an active assignment", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status: "in_use" }); const employee = await createTestEmployee(); await prisma.assignment.create({ data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() }, }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/active assignment/i); const remaining = await prisma.maintenanceRecord.findMany(); expect(remaining).toHaveLength(0); }); it("rejects opening a record for an AssetItem that is already retired", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status: "retired" }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(false); if (created.ok) return; expect(created.error).toMatch(/retired/i); const remaining = await prisma.maintenanceRecord.findMany(); expect(remaining).toHaveLength(0); }); it("rejects opening a second record while one is already open for the same AssetItem", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const first = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(first.ok).toBe(true); const second = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "preventive", issueDescription: "Another issue", startDate: new Date("2026-08-02"), }); expect(second.ok).toBe(false); if (second.ok) return; expect(second.error).toMatch(/already has an open maintenance record/i); const records = await prisma.maintenanceRecord.findMany({ where: { assetItemId: assetItem.id } }); expect(records).toHaveLength(1); }); }); describe("closeMaintenanceRecord", () => { it.each(["in_stock", "retired"] as const)( "closes a record and applies targetStatus %s to the AssetItem", async (targetStatus) => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(true); if (!created.ok) return; const closed = await closeMaintenanceRecord(created.value.id, { endDate: new Date("2026-08-05"), targetStatus, }); expect(closed.ok).toBe(true); if (!closed.ok) return; expect(closed.value.end_date).not.toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe(targetStatus); } ); it("rejects double-closing a record", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-01"), }); expect(created.ok).toBe(true); if (!created.ok) return; const firstClose = await closeMaintenanceRecord(created.value.id, { endDate: new Date("2026-08-05"), targetStatus: "in_stock", }); expect(firstClose.ok).toBe(true); const secondClose = await closeMaintenanceRecord(created.value.id, { endDate: new Date("2026-08-06"), targetStatus: "in_stock", }); expect(secondClose.ok).toBe(false); if (secondClose.ok) return; expect(secondClose.status).toBe(400); expect(secondClose.error).toMatch(/already been closed/i); }); it("rejects an endDate before the record's startDate", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const created = await createMaintenanceRecord({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: new Date("2026-08-05"), }); expect(created.ok).toBe(true); if (!created.ok) return; const closed = await closeMaintenanceRecord(created.value.id, { endDate: new Date("2026-08-01"), targetStatus: "in_stock", }); expect(closed.ok).toBe(false); if (closed.ok) return; expect(closed.status).toBe(400); expect(closed.error).toMatch(/endDate must be on or after startDate/i); }); it("returns 404 for a nonexistent record id", async () => { const result = await closeMaintenanceRecord("00000000-0000-0000-0000-000000000000", { endDate: new Date("2026-08-05"), targetStatus: "in_stock", }); expect(result.ok).toBe(false); if (result.ok) return; expect(result.status).toBe(404); expect(result.error).toMatch(/not found/i); }); }); describe("validateCloseMaintenanceRecordInput", () => { it("accepts valid input", () => { const result = validateCloseMaintenanceRecordInput({ endDate: "2026-08-05", targetStatus: "in_stock", }); expect(result.ok).toBe(true); }); it("rejects a missing targetStatus", () => { const result = validateCloseMaintenanceRecordInput({ endDate: "2026-08-05" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/targetStatus/i); } }); it("rejects a missing endDate", () => { const result = validateCloseMaintenanceRecordInput({ targetStatus: "in_stock" }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/endDate/i); } }); }); describe("listMaintenanceRecords", () => { it("lists created records, newest first", async () => { const modelId = await createTestModelId(); const item1 = await createTestAssetItem({ modelId }); const item2 = await createTestAssetItem({ modelId }); const first = await createMaintenanceRecord({ assetItemId: item1.id, type: "repair", issueDescription: "Issue 1", startDate: new Date("2026-08-01"), }); expect(first.ok).toBe(true); await new Promise((resolve) => setTimeout(resolve, 5)); const second = await createMaintenanceRecord({ assetItemId: item2.id, type: "repair", issueDescription: "Issue 2", startDate: new Date("2026-08-01"), }); expect(second.ok).toBe(true); const records = await listMaintenanceRecords(); 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 records", async () => { const records = await listMaintenanceRecords(); expect(records).toEqual([]); }); }); describe("POST /api/maintenance-records", () => { it("creates a record on valid input", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ asset_item_id: assetItem.id, type: "repair", vendor_id: null, end_date: null, }); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe("repair"); }); it("returns the full expected snake_case shape", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const vendor = await createTestVendor(); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "warranty", vendorId: vendor.id, issueDescription: "Battery swollen", startDate: "2026-08-01", }) ); expect(res.status).toBe(201); const body = await res.json(); expect(body).toMatchObject({ id: expect.any(String), asset_item_id: assetItem.id, type: "warranty", vendor_id: vendor.id, issue_description: "Battery swollen", resolution_notes: null, start_date: expect.any(String), end_date: null, created_at: expect.any(String), }); }); it("rejects creating a record for an AssetItem with an active assignment", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status: "in_use" }); const employee = await createTestEmployee(); await prisma.assignment.create({ data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() }, }); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/active assignment/i); }); it("rejects creating a record for a retired AssetItem", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId, status: "retired" }); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/retired/i); }); it("rejects creating a second open record for the same AssetItem", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "preventive", issueDescription: "Another issue", startDate: "2026-08-02", }) ); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/already has an open maintenance record/i); }); it("rejects an invalid JSON body", async () => { const req = new Request("http://localhost/api/maintenance-records", { 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 (string)", 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); }); it("rejects a non-object JSON body (number)", async () => { const res = await POST(postRequest(123)); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/JSON object/i); }); }); describe("GET /api/maintenance-records", () => { it("lists created records, newest first", async () => { const modelId = await createTestModelId(); const item1 = await createTestAssetItem({ modelId }); const item2 = await createTestAssetItem({ modelId }); await POST( postRequest({ assetItemId: item1.id, type: "repair", issueDescription: "Issue 1", startDate: "2026-08-01", }) ); await new Promise((resolve) => setTimeout(resolve, 5)); await POST( postRequest({ assetItemId: item2.id, type: "repair", issueDescription: "Issue 2", startDate: "2026-08-01", }) ); 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 records", async () => { const res = await GET(); expect(res.status).toBe(200); const body = await res.json(); expect(body).toEqual([]); }); it("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" }); }); }); describe("PATCH /api/maintenance-records/[id]", () => { it.each(["in_stock", "retired"] as const)( "closes a record with targetStatus %s", async (targetStatus) => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); const created = await createRes.json(); const res = await PATCH( patchRequest({ endDate: "2026-08-05", targetStatus }), { params: { id: created.id } } ); expect(res.status).toBe(200); const body = await res.json(); expect(body.end_date).not.toBeNull(); const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } }); expect(item.status).toBe(targetStatus); } ); it("returns 400 when targetStatus is missing", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); const created = await createRes.json(); const res = await PATCH(patchRequest({ endDate: "2026-08-05" }), { params: { id: created.id }, }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/targetStatus/i); }); it("rejects double-closing a record with 400", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); const created = await createRes.json(); await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), { params: { id: created.id }, }); const res = await PATCH(patchRequest({ endDate: "2026-08-06", targetStatus: "in_stock" }), { params: { id: created.id }, }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/already been closed/i); }); it("rejects an endDate before startDate with 400", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); const createRes = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-05", }) ); const created = await createRes.json(); const res = await PATCH(patchRequest({ endDate: "2026-08-01", targetStatus: "in_stock" }), { params: { id: created.id }, }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/endDate must be on or after startDate/i); }); it("returns 404 for a nonexistent record id", async () => { const res = await PATCH(patchRequest({ endDate: "2026-08-05", targetStatus: "in_stock" }), { 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({ endDate: "2026-08-05", targetStatus: "in_stock" }), { params: { id: "not-a-uuid" }, }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/UUID/i); }); it("rejects an invalid JSON body", async () => { const req = new Request("http://localhost/api/maintenance-records/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 (string)", 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("rejects a non-object JSON body (number)", async () => { const res = await PATCH(patchRequest(123), { 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({ endDate: "2026-08-05", targetStatus: "in_stock" }), { 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/maintenance-records", () => { it("POST /api/maintenance-records returns 401 without a session", async () => { const modelId = await createTestModelId(); const assetItem = await createTestAssetItem({ modelId }); mockedAuth.mockResolvedValue(null); const res = await POST( postRequest({ assetItemId: assetItem.id, type: "repair", issueDescription: "Screen flickers", startDate: "2026-08-01", }) ); expect(res.status).toBe(401); const body = await res.json(); expect(body).toEqual({ error: "Unauthorized" }); const remaining = await prisma.maintenanceRecord.findMany(); expect(remaining).toHaveLength(0); }); });