test: add Vitest setup and Playwright login e2e spec

This commit is contained in:
Arthur Bassi 2026-06-12 11:56:51 -03:00
parent 668dd69df2
commit 7af4a56d04
14 changed files with 947 additions and 0 deletions

98
e2e/auth/login.spec.ts Normal file
View file

@ -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();
});
});

26
playwright.config.ts Normal file
View file

@ -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,
},
});

View file

@ -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<typeof items>(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);
});
});

View file

@ -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<LoginFormValues> {
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<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { username: "", password: "" },
});
return (
<LoginForm
register={register}
handleSubmit={handleSubmit}
errors={errors}
onSubmit={onSubmit}
isLoggingIn={isLoggingIn}
loginError={loginError}
/>
);
}
describe("LoginForm", () => {
it("renders email, password, and submit button", () => {
renderWithProviders(<LoginFormHarness />, { 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(
<LoginForm
register={vi.fn()}
handleSubmit={createMockHandleSubmit()}
errors={{
username: { type: "required", message: "Email is required" },
password: { type: "required", message: "Password is required" },
}}
onSubmit={vi.fn()}
isLoggingIn={false}
loginError={null}
/>,
{ withAuth: false },
);
expect(screen.getByText("Email is required")).toBeInTheDocument();
expect(screen.getByText("Password is required")).toBeInTheDocument();
});
it("shows API login error message", () => {
renderWithProviders(<LoginFormHarness loginError={new Error("Invalid credentials")} />, {
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(
<LoginForm
register={register as LoginFormProps["register"]}
handleSubmit={createMockHandleSubmit()}
errors={{}}
onSubmit={vi.fn()}
isLoggingIn
loginError={null}
/>,
{ 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(<LoginFormHarness onSubmit={onSubmit} />, { 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(),
);
});
});
});

View file

@ -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 (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>
);
}
describe("LoginPage integration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(authApi.getSession).mockResolvedValue(null);
});
it("shows validation errors on empty submit", async () => {
renderWithProviders(<LoginRoutes />, { 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(<LoginRoutes />, { 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(<LoginRoutes />, { 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(<LoginRoutes />, { 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();
});
});
});

View file

@ -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> = {}): 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(<AuthContext.Provider value={authValue}>{ui}</AuthContext.Provider>, {
withAuth: false,
route,
});
}
describe("ProtectedRoute", () => {
it("redirects unauthenticated users to login", () => {
renderWithAuthContext(
<ProtectedRoute>
<div>Protected content</div>
</ProtectedRoute>,
createAuthContextValue({ isAuthenticated: false }),
"/workorders",
);
expect(screen.queryByText("Protected content")).not.toBeInTheDocument();
});
it("renders children for authenticated users", () => {
renderWithAuthContext(
<ProtectedRoute>
<div>Protected content</div>
</ProtectedRoute>,
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(
<ProtectedRoute>
<div>Protected content</div>
</ProtectedRoute>,
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(
<GuestRoute>
<div>Login content</div>
</GuestRoute>,
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(
<GuestRoute>
<div>Login content</div>
</GuestRoute>,
createAuthContextValue({ isAuthenticated: false }),
"/login",
);
expect(screen.getByText("Login content")).toBeInTheDocument();
});
});

View file

@ -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);
}
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

29
src/test/setup.ts Normal file
View file

@ -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;

62
src/test/test-utils.tsx Normal file
View file

@ -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<RenderOptions, "wrapper"> & {
route?: string;
routerProps?: Omit<MemoryRouterProps, "children">;
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 ? <AuthProvider>{children}</AuthProvider> : children;
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={muiTheme}>
<CssBaseline />
<MemoryRouter {...routerProps} initialEntries={initialEntries}>
{content}
</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
}