mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 03:02:03 +00:00
test: add Vitest setup and Playwright login e2e spec
This commit is contained in:
parent
668dd69df2
commit
7af4a56d04
14 changed files with 947 additions and 0 deletions
98
e2e/auth/login.spec.ts
Normal file
98
e2e/auth/login.spec.ts
Normal 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
26
playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
50
src/test/api/handle-api-response.test.ts
Normal file
50
src/test/api/handle-api-response.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
143
src/test/app/(auth)/_components/login-form.test.tsx
Normal file
143
src/test/app/(auth)/_components/login-form.test.tsx
Normal 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
151
src/test/app/(auth)/login.integration.test.tsx
Normal file
151
src/test/app/(auth)/login.integration.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/test/components/auth/auth-routes.test.tsx
Normal file
103
src/test/components/auth/auth-routes.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
33
src/test/domain/auth/schemas/login-schema.test.ts
Normal file
33
src/test/domain/auth/schemas/login-schema.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
59
src/test/domain/uplifts/mappers/uplift-mapper.test.ts
Normal file
59
src/test/domain/uplifts/mappers/uplift-mapper.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
22
src/test/domain/work-orders/utils/is-dispatch-locked.test.ts
Normal file
22
src/test/domain/work-orders/utils/is-dispatch-locked.test.ts
Normal 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
29
src/test/setup.ts
Normal 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
62
src/test/test-utils.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue