diff --git a/backend/package-lock.json b/backend/package-lock.json index 10ff6243..a7b8393c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -42,6 +42,7 @@ "class-validator": "^0.14.3", "date-fns": "^4.1.0", "exceljs": "^4.4.0", + "helmet": "^8.0.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", @@ -9548,6 +9549,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", diff --git a/frontend/__tests__/api-client.test.tsx b/frontend/__tests__/api-client.test.tsx index 82cd9660..bebdbfed 100644 --- a/frontend/__tests__/api-client.test.tsx +++ b/frontend/__tests__/api-client.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ import { api } from '@/lib/api'; import axios from 'axios'; diff --git a/frontend/__tests__/asset-detail-page.test.tsx b/frontend/__tests__/asset-detail-page.test.tsx new file mode 100644 index 00000000..ec6e3ef8 --- /dev/null +++ b/frontend/__tests__/asset-detail-page.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import AssetDetailPage from "@/app/(dashboard)/assets/[id]/page"; + +const push = jest.fn(); +const asset = { + id: "asset-1", assetId: "AST-001", name: "MacBook Pro", status: "ACTIVE", condition: "GOOD", + createdAt: "2026-01-01", updatedAt: "2026-01-02", imageUrls: [], tags: [], +}; +let mockAsset: typeof asset | undefined = asset; + +jest.mock("next/navigation", () => ({ + useParams: () => ({ id: "asset-1" }), + useRouter: () => ({ push }), +})); + +jest.mock("@/lib/query/hooks/useAsset", () => ({ + useAsset: () => ({ data: mockAsset, isLoading: false }), + useAssetHistory: () => ({ data: [], isLoading: false }), + useAssetDocuments: () => ({ data: [], isLoading: false }), + useMaintenanceRecords: () => ({ data: [], isLoading: false }), + useAssetNotes: () => ({ data: [], isLoading: false }), + useDeleteAsset: () => ({ mutate: jest.fn(), isPending: false }), + useUploadDocument: () => ({ mutate: jest.fn(), isPending: false }), + useDeleteDocument: () => ({ mutate: jest.fn(), isPending: false }), + useCreateMaintenanceRecord: () => ({ mutate: jest.fn(), isPending: false }), + useUpdateMaintenanceStatus: () => ({ mutate: jest.fn(), isPending: false }), + useCreateNote: () => ({ mutate: jest.fn(), isPending: false }), + useDeleteNote: () => ({ mutate: jest.fn(), isPending: false }), +})); + +jest.mock("@/components/assets/status-badge", () => ({ StatusBadge: () => Active })); +jest.mock("@/components/assets/condition-badge", () => ({ ConditionBadge: () => Good })); +jest.mock("@/components/assets/inline-edit", () => ({ InlineEdit: ({ display }: { display: React.ReactNode }) => <>{display} })); +jest.mock("@/components/assets/photo-gallery", () => ({ PhotoGallery: () =>
Photos
})); +jest.mock("@/components/assets/edit-asset-modal", () => ({ EditAssetModal: () =>
Edit Asset Form
})); + +describe("AssetDetailPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAsset = asset; + global.fetch = jest.fn().mockResolvedValue({ ok: false }); + }); + + it("loads the asset and switches across core tabs", () => { + render(); + expect(screen.getByRole("heading", { name: "MacBook Pro" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "History" })); + expect(screen.getByText("Change History")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Notes" })); + expect(screen.getByRole("heading", { name: "Add Note" })).toBeInTheDocument(); + }); + + it("exposes edit, transfer, and status-change actions", () => { + render(); + expect(screen.getByRole("button", { name: /transfer/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /update status/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Edit" })); + expect(screen.getByRole("dialog")).toHaveTextContent("Edit Asset Form"); + }); + + it("renders a not-found state for an invalid asset id", () => { + mockAsset = undefined; + render(); + expect(screen.getByText("Asset not found.")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/licenses-page.test.tsx b/frontend/__tests__/licenses-page.test.tsx new file mode 100644 index 00000000..a75e7482 --- /dev/null +++ b/frontend/__tests__/licenses-page.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import LicensesPage from "@/app/(dashboard)/licenses/page"; + +const license = { + id: "lic-1", + name: "Design Suite", + vendorId: "Acme", + type: "SUBSCRIPTION", + billingPeriod: "YEARLY", + seatsTotal: 5, + seatsUsed: 2, + cost: 1200, + currency: "USD", + expiryDate: "2026-09-01T00:00:00.000Z", + renewsSoon: true, +}; + +jest.mock("@/lib/query/hooks/useLicenses", () => ({ + useLicenses: () => ({ data: [license], isLoading: false }), + useCreateLicense: () => ({ mutateAsync: jest.fn(), isPending: false }), + useUpdateLicense: () => ({ mutateAsync: jest.fn(), isPending: false }), + useDeleteLicense: () => ({ mutateAsync: jest.fn(), isPending: false }), + useRevealLicenseKey: () => ({ mutateAsync: jest.fn(), isPending: false }), + useLicenseAssignments: () => ({ data: [], isLoading: false }), + useAssignSeat: () => ({ mutateAsync: jest.fn(), isPending: false }), + useUnassignSeat: () => ({ mutate: jest.fn(), isPending: false }), +})); + +jest.mock("@/lib/query/hooks/useAssets", () => ({ + useUsers: () => ({ data: [{ id: "user-1", name: "Jane User" }] }), +})); + +describe("LicensesPage", () => { + it("renders licenses and highlights an upcoming renewal", () => { + render(); + + expect(screen.getByText("Design Suite")).toBeInTheDocument(); + expect(screen.getAllByText(/renewal/i).length).toBeGreaterThan(0); + expect(screen.getByText(/9\/1\/2026|09\/01\/2026/)).toBeInTheDocument(); + }); + + it("opens the seat assignment UI from a license row", () => { + render(); + fireEvent.click(screen.getByText("Design Suite")); + + expect(screen.getByText("Seat Assignments")).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Jane User" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Assign" })).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/locations-page.test.tsx b/frontend/__tests__/locations-page.test.tsx new file mode 100644 index 00000000..a8618bb7 --- /dev/null +++ b/frontend/__tests__/locations-page.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import LocationsPage from "@/app/(dashboard)/locations/page"; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +const createLocation = jest.fn(); + +jest.mock("@/lib/query/hooks/useLocations", () => ({ + useLocations: () => ({ + data: [ + { id: "root", name: "HQ", code: "HQ", type: "BUILDING", totalAssetCount: 2 }, + { id: "child", name: "Floor 1", code: "HQ-1", type: "FLOOR", parentLocationId: "root", totalAssetCount: 1 }, + ], + isLoading: false, + }), + useCreateLocation: () => ({ mutateAsync: createLocation, isPending: false }), + useUpdateLocation: () => ({ mutateAsync: jest.fn(), isPending: false }), + useDeleteLocation: () => ({ mutateAsync: jest.fn(), isPending: false }), +})); + +describe("LocationsPage", () => { + it("renders the location tree and expands child locations", () => { + render(); + + expect(screen.getByRole("heading", { name: "Locations" })).toBeInTheDocument(); + expect(screen.getAllByText("HQ").length).toBeGreaterThan(0); + expect(screen.queryByText("Floor 1")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getByText("Floor 1")).toBeInTheDocument(); + }); + + it("opens create and edit entry points", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Add Root Location" })); + expect(screen.getByRole("heading", { name: "New Location" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + fireEvent.click(screen.getByTitle("Edit location")); + expect(screen.getByRole("heading", { name: "Edit Location" })).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/maintenance-page.test.tsx b/frontend/__tests__/maintenance-page.test.tsx new file mode 100644 index 00000000..149a0630 --- /dev/null +++ b/frontend/__tests__/maintenance-page.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import MaintenancePage from "@/app/(dashboard)/maintenance/page"; + +const updateStatus = jest.fn(); + +jest.mock("@dnd-kit/core", () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + useDroppable: () => ({ setNodeRef: jest.fn(), isOver: false }), + useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: jest.fn(), transform: null, isDragging: false }), + useSensor: jest.fn(), + useSensors: jest.fn(), +})); + +jest.mock("@/lib/query/hooks/useMaintenance", () => ({ + useMaintenanceRecords: () => ({ + data: [{ id: "mnt-1", assetId: "asset-1", type: "SCHEDULED", status: "SCHEDULED", title: "Quarterly service", scheduledDate: "2099-01-15", cost: 0, currency: "USD" }], + isLoading: false, + }), + useCreateMaintenanceRecord: () => ({ mutateAsync: jest.fn(), isPending: false }), + useUpdateMaintenanceStatus: () => ({ mutate: updateStatus, isPending: false }), +})); + +jest.mock("@/lib/query/hooks/useAssets", () => ({ + useAssets: () => ({ data: { data: [{ id: "asset-1", name: "Laptop" }] } }), + useDepartmentsList: () => ({ data: [] }), +})); + +describe("MaintenancePage", () => { + it("renders maintenance records and switches to the calendar", () => { + render(); + + expect(screen.getByRole("heading", { name: "Maintenance" })).toBeInTheDocument(); + expect(screen.getByText("Quarterly service")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Calendar" })); + expect(screen.getByText("Sun")).toBeInTheDocument(); + }); + + it("opens scheduling from the page and exposes completion entry point", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "New Maintenance" })); + expect(screen.getByRole("heading", { name: "New Maintenance" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Schedule" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.getByText("Quarterly service")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index d4abab8d..4179f9d5 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,2 +1,3 @@ // jest.setup.js +import "@testing-library/jest-dom"; import '@testing-library/jest-dom'; \ No newline at end of file