diff --git a/e2e/auth/login.spec.ts b/e2e/auth/login.spec.ts new file mode 100644 index 00000000..acd6de68 --- /dev/null +++ b/e2e/auth/login.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Page } from "@playwright/test"; + +const TEST_USER = { + id: 1, + email: "user@seahavenind.com", + name: "Test User", +}; + +async function mockUnauthenticatedSession(page: Page) { + await page.route("**/api/Authentication/GetCurrentUser", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ message: "Unauthorized" }), + }); + }); +} + +async function mockDashboardStats(page: Page) { + await page.route("**/api/Dashboard/Stats", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + openWorkOrders: 0, + pendingApprovals: 0, + scheduledPMs: 0, + activeAssets: 0, + }, + }), + }); + }); +} + +test.describe("Login page", () => { + test.beforeEach(async ({ page }) => { + await mockUnauthenticatedSession(page); + await mockDashboardStats(page); + }); + + test("loads login page with form fields", async ({ page }) => { + await page.goto("/login"); + + await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible(); + await expect(page.locator("#login-email")).toBeVisible(); + await expect(page.locator("#login-password")).toBeVisible(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); + }); + + test("shows client-side validation errors on empty submit", async ({ page }) => { + await page.goto("/login"); + + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page.getByText("Email is required")).toBeVisible(); + await expect(page.getByText("Password is required")).toBeVisible(); + }); + + test("shows error message for invalid credentials", async ({ page }) => { + await page.route("**/api/Authentication/Login", async (route) => { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ message: "Invalid credentials" }), + }); + }); + + await page.goto("/login"); + + await page.locator("#login-email").fill("user@seahavenind.com"); + await page.locator("#login-password").fill("wrong-password"); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page.getByText("Invalid email or password. Please try again.")).toBeVisible(); + await expect(page).toHaveURL(/\/login$/); + }); + + test("logs in successfully and navigates to dashboard", async ({ page }) => { + await page.route("**/api/Authentication/Login", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: TEST_USER }), + }); + }); + + await page.goto("/login"); + + await page.locator("#login-email").fill("user@seahavenind.com"); + await page.locator("#login-password").fill("secret123"); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page).toHaveURL(/\/$/); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome back" })).not.toBeVisible(); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..be2a106f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/src/test/api/handle-api-response.test.ts b/src/test/api/handle-api-response.test.ts new file mode 100644 index 00000000..520c03f7 --- /dev/null +++ b/src/test/api/handle-api-response.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response"; + +describe("handleApiResponse", () => { + it("returns array payloads as-is", () => { + const items = [{ id: 1 }, { id: 2 }]; + expect(handleApiResponse(items)).toEqual(items); + }); + + it("unwraps data, Data, and value wrappers", () => { + expect(handleApiResponse<{ id: number }>({ data: { id: 1 } })).toEqual({ id: 1 }); + expect(handleApiResponse<{ id: number }>({ Data: { id: 2 } })).toEqual({ id: 2 }); + expect(handleApiResponse<{ id: number }>({ value: { id: 3 } })).toEqual({ id: 3 }); + }); + + it("returns object payload when no wrapper is present", () => { + const payload = { id: 99, name: "Test" }; + expect(handleApiResponse(payload)).toEqual(payload); + }); +}); + +describe("handlePaginatedResponse", () => { + it("normalizes paginated ASP.NET payload", () => { + const result = handlePaginatedResponse<{ id: number }>( + { + Data: [{ id: 1 }, { id: 2 }], + TotalCount: 50, + PageNumber: 2, + PageSize: 25, + }, + { page: 2, pageSize: 25 }, + ); + + expect(result).toEqual({ + items: [{ id: 1 }, { id: 2 }], + totalCount: 50, + page: 2, + pageSize: 25, + }); + }); + + it("handles plain arrays", () => { + const result = handlePaginatedResponse([{ id: 1 }], { page: 1, pageSize: 10 }); + + expect(result.items).toEqual([{ id: 1 }]); + expect(result.totalCount).toBe(1); + expect(result.page).toBe(1); + expect(result.pageSize).toBe(10); + }); +}); diff --git a/src/test/app/(auth)/_components/login-form.test.tsx b/src/test/app/(auth)/_components/login-form.test.tsx new file mode 100644 index 00000000..aab67e49 --- /dev/null +++ b/src/test/app/(auth)/_components/login-form.test.tsx @@ -0,0 +1,143 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { useForm, type UseFormHandleSubmit } from "react-hook-form"; +import { describe, expect, it, vi } from "vitest"; + +import { LoginForm, type LoginFormProps } from "@/app/(auth)/_components/login-form"; +import { loginSchema, type LoginFormValues } from "@/domain/auth/schemas/login-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +function createMockHandleSubmit(): UseFormHandleSubmit { + return (onValid) => async (event) => { + event?.preventDefault?.(); + await onValid({ username: "", password: "" }); + }; +} + +type LoginFormHarnessProps = { + onSubmit?: (values: LoginFormValues) => void; + isLoggingIn?: boolean; + loginError?: Error | null; +}; + +function LoginFormHarness({ + onSubmit = vi.fn(), + isLoggingIn = false, + loginError = null, +}: LoginFormHarnessProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { username: "", password: "" }, + }); + + return ( + + ); +} + +describe("LoginForm", () => { + it("renders email, password, and submit button", () => { + renderWithProviders(, { withAuth: false }); + + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText("you@seahavenind.com")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("••••••••")).toBeInTheDocument(); + }); + + it("displays field validation helper text", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("Email is required")).toBeInTheDocument(); + expect(screen.getByText("Password is required")).toBeInTheDocument(); + }); + + it("shows API login error message", () => { + renderWithProviders(, { + withAuth: false, + }); + + expect(screen.getByText("Invalid email or password. Please try again.")).toBeInTheDocument(); + }); + + it("disables fields and shows loading state while logging in", () => { + const register = (name: "username" | "password") => ({ + name, + onChange: vi.fn(), + onBlur: vi.fn(), + ref: vi.fn(), + }); + + renderWithProviders( + , + { withAuth: false }, + ); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + + expect(within(form!).getByLabelText("Email").closest(".MuiInputBase-root")).toHaveClass( + "Mui-disabled", + ); + expect(within(form!).getByLabelText("Password").closest(".MuiInputBase-root")).toHaveClass( + "Mui-disabled", + ); + expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled(); + }); + + it("submits credentials via onSubmit", async () => { + const onSubmit = vi.fn(); + + renderWithProviders(, { withAuth: false }); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + + fireEvent.change(within(form!).getByLabelText("Email"), { + target: { value: "user@seahavenind.com" }, + }); + fireEvent.change(within(form!).getByLabelText("Password"), { + target: { value: "secret123" }, + }); + fireEvent.click(within(form!).getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { username: "user@seahavenind.com", password: "secret123" }, + expect.anything(), + ); + }); + }); +}); diff --git a/src/test/app/(auth)/login.integration.test.tsx b/src/test/app/(auth)/login.integration.test.tsx new file mode 100644 index 00000000..c2384ea9 --- /dev/null +++ b/src/test/app/(auth)/login.integration.test.tsx @@ -0,0 +1,151 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import LoginPage from "@/app/(auth)/login"; +import * as authApi from "@/domain/auth/api/auth-api"; +import { renderWithProviders } from "@/test/test-utils"; + +const { toastSuccessMock } = vi.hoisted(() => ({ + toastSuccessMock: vi.fn(), +})); + +vi.mock("@/domain/auth/api/auth-api", () => ({ + getSession: vi.fn(), + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), +})); + +vi.mock("react-toastify", () => ({ + toast: { + success: toastSuccessMock, + error: vi.fn(), + info: vi.fn(), + }, +})); + +function LoginRoutes() { + return ( + + } /> + Dashboard} /> + + ); +} + +describe("LoginPage integration", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(authApi.getSession).mockResolvedValue(null); + }); + + it("shows validation errors on empty submit", async () => { + renderWithProviders(, { route: "/login" }); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeInTheDocument(); + }); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByText("Email is required")).toBeInTheDocument(); + }); + expect(screen.getByText("Password is required")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeInTheDocument(); + expect(authApi.login).not.toHaveBeenCalled(); + }); + + it("logs in successfully and navigates to dashboard", async () => { + const userPayload = { id: 1, email: "user@seahavenind.com" }; + + vi.mocked(authApi.login).mockResolvedValue(userPayload); + vi.mocked(authApi.getSession).mockResolvedValue(userPayload); + + renderWithProviders(, { route: "/login" }); + + await waitFor(() => { + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); + await userEvent.type(within(form!).getByLabelText("Password"), "secret123"); + await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(authApi.login).toHaveBeenCalledWith({ + username: "user@seahavenind.com", + password: "secret123", + }); + }); + + await waitFor(() => { + expect(screen.getByTestId("dashboard")).toBeInTheDocument(); + }); + + expect(screen.queryByRole("heading", { name: "Welcome back" })).not.toBeInTheDocument(); + expect(toastSuccessMock).toHaveBeenCalledWith("Login successful!"); + }); + + it("shows generic error message when login fails", async () => { + vi.mocked(authApi.login).mockRejectedValue(new Error("Invalid credentials")); + + renderWithProviders(, { route: "/login" }); + + await waitFor(() => { + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); + await userEvent.type(within(form!).getByLabelText("Password"), "wrong-password"); + await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(screen.getByText("Invalid email or password. Please try again.")).toBeInTheDocument(); + }); + + expect(screen.getByRole("heading", { name: "Welcome back" })).toBeInTheDocument(); + }); + + it("shows loading state while login is pending", async () => { + let resolveLogin: (value: { id: number; email: string }) => void = () => {}; + + vi.mocked(authApi.login).mockImplementation( + () => + new Promise((resolve) => { + resolveLogin = resolve; + }), + ); + vi.mocked(authApi.getSession).mockResolvedValue({ id: 1, email: "user@seahavenind.com" }); + + renderWithProviders(, { route: "/login" }); + + await waitFor(() => { + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + const form = document.querySelector("form"); + expect(form).not.toBeNull(); + await userEvent.type(within(form!).getByLabelText("Email"), "user@seahavenind.com"); + await userEvent.type(within(form!).getByLabelText("Password"), "secret123"); + await userEvent.click(within(form!).getByRole("button", { name: /sign in/i })); + + await waitFor(() => { + expect(within(form!).getByRole("button", { name: /signing in/i })).toBeDisabled(); + }); + + resolveLogin({ id: 1, email: "user@seahavenind.com" }); + + await waitFor(() => { + expect(screen.getByTestId("dashboard")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/test/components/auth/auth-routes.test.tsx b/src/test/components/auth/auth-routes.test.tsx new file mode 100644 index 00000000..d5135ca8 --- /dev/null +++ b/src/test/components/auth/auth-routes.test.tsx @@ -0,0 +1,103 @@ +import { screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { GuestRoute } from "@/components/auth/guest-route"; +import { ProtectedRoute } from "@/components/auth/protected-route"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; +import { renderWithProviders } from "@/test/test-utils"; + +function createAuthContextValue(overrides: Partial = {}): AuthContextValue { + return { + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), + isLoggingIn: false, + isLoggingOut: false, + isChangingPassword: false, + loginError: null, + ...overrides, + }; +} + +function renderWithAuthContext(ui: ReactElement, authValue: AuthContextValue, route = "/") { + return renderWithProviders({ui}, { + withAuth: false, + route, + }); +} + +describe("ProtectedRoute", () => { + it("redirects unauthenticated users to login", () => { + renderWithAuthContext( + +
Protected content
+
, + createAuthContextValue({ isAuthenticated: false }), + "/workorders", + ); + + expect(screen.queryByText("Protected content")).not.toBeInTheDocument(); + }); + + it("renders children for authenticated users", () => { + renderWithAuthContext( + +
Protected content
+
, + createAuthContextValue({ + isAuthenticated: true, + user: { id: 1, email: "user@seahavenind.com" }, + }), + "/workorders", + ); + + expect(screen.getByText("Protected content")).toBeInTheDocument(); + }); + + it("shows loading indicator while session is loading", () => { + renderWithAuthContext( + +
Protected content
+
, + createAuthContextValue({ isLoading: true }), + "/workorders", + ); + + expect(screen.getByRole("status", { name: "Loading" })).toBeInTheDocument(); + expect(screen.queryByText("Protected content")).not.toBeInTheDocument(); + }); +}); + +describe("GuestRoute", () => { + it("redirects authenticated users away from login", () => { + renderWithAuthContext( + +
Login content
+
, + createAuthContextValue({ + isAuthenticated: true, + user: { id: 1, email: "user@seahavenind.com" }, + }), + "/login", + ); + + expect(screen.queryByText("Login content")).not.toBeInTheDocument(); + }); + + it("renders children for unauthenticated users", () => { + renderWithAuthContext( + +
Login content
+
, + createAuthContextValue({ isAuthenticated: false }), + "/login", + ); + + expect(screen.getByText("Login content")).toBeInTheDocument(); + }); +}); diff --git a/src/test/domain/auth/schemas/login-schema.test.ts b/src/test/domain/auth/schemas/login-schema.test.ts new file mode 100644 index 00000000..4f66b0c6 --- /dev/null +++ b/src/test/domain/auth/schemas/login-schema.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { loginSchema } from "@/domain/auth/schemas/login-schema"; + +describe("loginSchema", () => { + it("rejects empty username", () => { + const result = loginSchema.safeParse({ username: "", password: "secret" }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.username).toEqual(["Email is required"]); + } + }); + + it("rejects empty password", () => { + const result = loginSchema.safeParse({ username: "user@seahavenind.com", password: "" }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.flatten().fieldErrors.password).toEqual(["Password is required"]); + } + }); + + it("accepts valid credentials", () => { + const credentials = { username: "user@seahavenind.com", password: "secret" }; + const result = loginSchema.safeParse(credentials); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(credentials); + } + }); +}); diff --git a/src/test/domain/uplifts/mappers/uplift-mapper.test.ts b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts new file mode 100644 index 00000000..75c5cfde --- /dev/null +++ b/src/test/domain/uplifts/mappers/uplift-mapper.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { mapUpliftQueueItem } from "@/domain/uplifts/mappers/uplift-mapper"; + +describe("mapUpliftQueueItem", () => { + it("maps ASP.NET PascalCase payload with queue fields", () => { + const result = mapUpliftQueueItem({ + Id: 42, + Status: "Pending", + CurrentNTE: 1000, + RequestedNTE: 1500, + Delta: 500, + VendorReason: "Scope increase", + RequestedAt: "2026-01-15T10:00:00Z", + RequestedByVendorName: "Acme Plumbing", + RequiredTier: 2, + CanDecide: true, + DispatchNumber: "D-1001", + PONumber: "PO-2002", + VendorCompanyName: "Acme Plumbing LLC", + WorkOrderId: 99, + DispatchId: 7, + }); + + expect(result).toMatchObject({ + id: 42, + status: "Pending", + currentNTE: 1000, + requestedNTE: 1500, + delta: 500, + vendorReason: "Scope increase", + requestedAt: "2026-01-15T10:00:00Z", + requestedByVendorName: "Acme Plumbing", + requiredTier: 2, + canDecide: true, + dispatchNumber: "D-1001", + poNumber: "PO-2002", + vendorCompanyName: "Acme Plumbing LLC", + workOrderId: 99, + dispatchId: 7, + }); + }); + + it("maps camelCase payload", () => { + const result = mapUpliftQueueItem({ + id: "u1", + status: "Approved", + currentNTE: 200, + requestedNTE: 250, + delta: 50, + dispatchNumber: "D-1", + poNumber: "PO-1", + vendorCompanyName: "Vendor Co", + }); + + expect(result.id).toBe("u1"); + expect(result.status).toBe("Approved"); + expect(result.dispatchNumber).toBe("D-1"); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts new file mode 100644 index 00000000..ed8696ef --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-form-payload.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + mapWorkOrderFormToCreatePayload, + mapWorkOrderFormToUpdatePayload, +} from "@/domain/work-orders/mappers/work-order-mapper"; +import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order"; + +describe("mapWorkOrderFormToCreatePayload", () => { + it("maps required create fields to API payload", () => { + const values = { + ...createDefaultWorkOrderFormValues(), + workOrderNumber: "CUST-001", + title: "Replace filter", + locationId: "42", + priority: "Sev 1", + status: "Open" as const, + createdDate: "2026-01-15", + assignedTo: "Jane Doe", + description: "HVAC filter replacement", + }; + + expect(mapWorkOrderFormToCreatePayload(values)).toMatchObject({ + workOrderNumber: "CUST-001", + title: "Replace filter", + locationId: 42, + priority: "Sev 1", + status: "Open", + createdDate: "2026-01-15", + assignedTo: "Jane Doe", + description: "HVAC filter replacement", + }); + }); +}); + +describe("mapWorkOrderFormToUpdatePayload", () => { + it("includes CreatedDate in update payload", () => { + const values = { + ...createDefaultWorkOrderFormValues(), + workOrderNumber: "CUST-001", + title: "Replace filter", + locationId: "42", + priority: "Sev 1", + status: "Open" as const, + createdDate: "2026-01-15", + description: "Updated description", + }; + + const payload = mapWorkOrderFormToUpdatePayload(99, values); + + expect(payload.CreatedDate).toBe("2026-01-15"); + expect(payload.Id).toBe(99); + expect(payload.WorkerOrderTitle).toBe("Replace filter"); + }); + + it("maps nullable optional fields", () => { + const values = { + ...createDefaultWorkOrderFormValues(), + createdDate: "2026-01-15", + }; + + const payload = mapWorkOrderFormToUpdatePayload(1, values); + + expect(payload.CreatedDate).toBe("2026-01-15"); + expect(payload.PO).toBeNull(); + expect(payload.LocationId).toBeNull(); + }); +}); diff --git a/src/test/domain/work-orders/mappers/work-order-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts new file mode 100644 index 00000000..a9dfc9bf --- /dev/null +++ b/src/test/domain/work-orders/mappers/work-order-mapper.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { mapUpliftRequest } from "@/domain/work-orders/mappers/work-order-mapper"; + +describe("mapUpliftRequest", () => { + it("maps decided uplift fields from PascalCase payload", () => { + const result = mapUpliftRequest({ + Id: 10, + Status: "Approved", + CurrentNTE: 800, + RequestedNTE: 1200, + Delta: 400, + VendorReason: "Additional materials", + RequestedAt: "2026-02-01T08:00:00Z", + RequestedByVendorName: "Beta HVAC", + DecidedAt: "2026-02-02T14:30:00Z", + DecidedByName: "Jane Manager", + DecisionNote: "Approved per contract", + RequiredTier: 2, + CanDecide: false, + }); + + expect(result).toEqual({ + id: 10, + status: "Approved", + currentNTE: 800, + requestedNTE: 1200, + delta: 400, + vendorReason: "Additional materials", + requestedAt: "2026-02-01T08:00:00Z", + requestedByVendorName: "Beta HVAC", + decidedAt: "2026-02-02T14:30:00Z", + decidedByName: "Jane Manager", + decisionNote: "Approved per contract", + requiredTier: 2, + canDecide: false, + }); + }); + + it("defaults missing numeric fields", () => { + const result = mapUpliftRequest({ id: 1, status: "Pending" }); + + expect(result.requestedNTE).toBe(0); + expect(result.delta).toBe(0); + expect(result.requiredTier).toBe(1); + expect(result.canDecide).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/schemas/work-order-schema.test.ts b/src/test/domain/work-orders/schemas/work-order-schema.test.ts new file mode 100644 index 00000000..09234098 --- /dev/null +++ b/src/test/domain/work-orders/schemas/work-order-schema.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { workOrderFormSchema } from "@/domain/work-orders/schemas/work-order-schema"; +import { createDefaultWorkOrderFormValues } from "@/domain/work-orders/types/work-order"; + +describe("workOrderFormSchema", () => { + it("accepts valid work order form values", () => { + const result = workOrderFormSchema.safeParse({ + ...createDefaultWorkOrderFormValues(), + title: "Replace filter", + locationId: "10", + priority: "Sev 1", + status: "Open", + createdDate: "2026-01-15", + }); + + expect(result.success).toBe(true); + }); + + it("rejects missing title", () => { + const result = workOrderFormSchema.safeParse({ + ...createDefaultWorkOrderFormValues(), + title: "", + locationId: "10", + priority: "Sev 1", + status: "Open", + createdDate: "2026-01-15", + }); + + expect(result.success).toBe(false); + }); + + it("rejects missing location", () => { + const result = workOrderFormSchema.safeParse({ + ...createDefaultWorkOrderFormValues(), + title: "Replace filter", + locationId: "", + priority: "Sev 1", + status: "Open", + createdDate: "2026-01-15", + }); + + expect(result.success).toBe(false); + }); + + it("rejects invalid priority", () => { + const result = workOrderFormSchema.safeParse({ + ...createDefaultWorkOrderFormValues(), + title: "Replace filter", + locationId: "10", + priority: "Urgent", + status: "Open", + createdDate: "2026-01-15", + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/src/test/domain/work-orders/utils/is-dispatch-locked.test.ts b/src/test/domain/work-orders/utils/is-dispatch-locked.test.ts new file mode 100644 index 00000000..f26370c9 --- /dev/null +++ b/src/test/domain/work-orders/utils/is-dispatch-locked.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked"; + +describe("isDispatchLocked", () => { + it("returns true when dispatch has verifiedAt timestamp", () => { + expect(isDispatchLocked({ verifiedAt: "2026-02-01T10:00:00Z", status: "In Progress" })).toBe( + true, + ); + }); + + it("returns true when dispatch status is Verified", () => { + expect(isDispatchLocked({ verifiedAt: null, status: "Verified" })).toBe(true); + }); + + it("returns true when dispatch status is Cancelled", () => { + expect(isDispatchLocked({ verifiedAt: null, status: "Cancelled" })).toBe(true); + }); + + it("returns false for active dispatch", () => { + expect(isDispatchLocked({ verifiedAt: null, status: "Sent" })).toBe(false); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..97877956 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,29 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +window.ResizeObserver = ResizeObserverMock; diff --git a/src/test/test-utils.tsx b/src/test/test-utils.tsx new file mode 100644 index 00000000..7137d34c --- /dev/null +++ b/src/test/test-utils.tsx @@ -0,0 +1,62 @@ +import { CssBaseline, ThemeProvider } from "@mui/material"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderOptions } from "@testing-library/react"; +import type { ReactElement, ReactNode } from "react"; +import { MemoryRouter, type MemoryRouterProps } from "react-router-dom"; + +import { muiTheme } from "@/lib/theme/mui-theme"; +import { AuthProvider } from "@/providers/auth-provider"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, + }); +} + +type RenderWithProvidersOptions = Omit & { + route?: string; + routerProps?: Omit; + withAuth?: boolean; + queryClient?: QueryClient; +}; + +export function renderWithProviders( + ui: ReactElement, + { + route = "/", + routerProps, + withAuth = true, + queryClient = createTestQueryClient(), + ...renderOptions + }: RenderWithProvidersOptions = {}, +) { + const initialEntries = routerProps?.initialEntries ?? [route]; + + function Wrapper({ children }: { children: ReactNode }) { + const content = withAuth ? {children} : children; + + return ( + + + + + {content} + + + + ); + } + + return { + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + queryClient, + }; +}