test: add MaintenanceRecord API tests
This commit is contained in:
@@ -0,0 +1,809 @@
|
|||||||
|
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<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/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<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-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: "[email protected]",
|
||||||
|
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: "assigned" });
|
||||||
|
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: "assigned" });
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user