From 0a5adb26b26e949beb78a924d9342752b6778c64 Mon Sep 17 00:00:00 2001 From: Moshfegh Hamedani Date: Tue, 17 Feb 2026 10:20:48 -0800 Subject: [PATCH] Write unit tests for adding new users --- client/src/pages/CreateUserForm.test.tsx | 174 +++++++++++++++++++++++ client/src/pages/CreateUserForm.tsx | 3 + client/src/pages/UsersPage.test.tsx | 45 ++++++ 3 files changed, 222 insertions(+) create mode 100644 client/src/pages/CreateUserForm.test.tsx diff --git a/client/src/pages/CreateUserForm.test.tsx b/client/src/pages/CreateUserForm.test.tsx new file mode 100644 index 0000000..d813eb4 --- /dev/null +++ b/client/src/pages/CreateUserForm.test.tsx @@ -0,0 +1,174 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import { renderWithQuery } from "@/test/render"; +import CreateUserForm from "./CreateUserForm"; + +vi.mock("axios"); +const mockedAxios = vi.mocked(axios, { deep: true }); + +const onSuccess = vi.fn(); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function renderForm() { + const user = userEvent.setup(); + renderWithQuery(); + return { user }; +} + +async function fillForm( + user: ReturnType, + { name = "John Doe", email = "john@example.com", password = "password123" } = {} +) { + await user.type(screen.getByLabelText("Name"), name); + await user.type(screen.getByLabelText("Email"), email); + await user.type(screen.getByLabelText("Password"), password); +} + +describe("CreateUserForm", () => { + it("should render all form fields and submit button", () => { + renderForm(); + + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByLabelText("Password")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create User" })).toBeInTheDocument(); + }); + + it("should show validation error for short name", async () => { + const { user } = renderForm(); + + await fillForm(user, { name: "Ab" }); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByText("Name must be at least 3 characters")).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("should show validation error for short password", async () => { + const { user } = renderForm(); + + await fillForm(user, { password: "short" }); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByText("Password must be at least 8 characters")).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("should show validation error for missing email", async () => { + const { user } = renderForm(); + + await user.type(screen.getByLabelText("Name"), "John Doe"); + await user.type(screen.getByLabelText("Password"), "password123"); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByText("Invalid email address")).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("should set aria-invalid on fields with errors", async () => { + const { user } = renderForm(); + + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByLabelText("Name")).toHaveAttribute("aria-invalid", "true"); + expect(screen.getByLabelText("Password")).toHaveAttribute("aria-invalid", "true"); + }); + }); + + it("should call POST /api/users with form data on valid submit", async () => { + mockedAxios.post.mockResolvedValue({ data: { user: { id: "1" } } }); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith("/api/users", { + name: "John Doe", + email: "john@example.com", + password: "password123", + }); + }); + }); + + it("should call onSuccess after successful creation", async () => { + mockedAxios.post.mockResolvedValue({ data: { user: { id: "1" } } }); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + }); + }); + + it("should reset the form after successful creation", async () => { + mockedAxios.post.mockResolvedValue({ data: { user: { id: "1" } } }); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByLabelText("Name")).toHaveValue(""); + expect(screen.getByLabelText("Email")).toHaveValue(""); + expect(screen.getByLabelText("Password")).toHaveValue(""); + }); + }); + + it("should display server error message on 409 conflict", async () => { + const error = new Error("Conflict"); + Object.assign(error, { + response: { status: 409, data: { error: "Email already exists" } }, + }); + mockedAxios.post.mockRejectedValue(error); + mockedAxios.isAxiosError.mockReturnValue(true); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByText("Email already exists")).toBeInTheDocument(); + }); + }); + + it("should show generic error for non-Axios errors", async () => { + mockedAxios.post.mockRejectedValue(new Error("Network failure")); + mockedAxios.isAxiosError.mockReturnValue(false); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + expect(screen.getByText("Failed to create user")).toBeInTheDocument(); + }); + }); + + it("should show 'Creating...' and disable button while submitting", async () => { + mockedAxios.post.mockReturnValue(new Promise(() => {})); + const { user } = renderForm(); + + await fillForm(user); + await user.click(screen.getByRole("button", { name: "Create User" })); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Creating..." }); + expect(button).toBeDisabled(); + }); + }); +}); diff --git a/client/src/pages/CreateUserForm.tsx b/client/src/pages/CreateUserForm.tsx index 9f5334d..c527239 100644 --- a/client/src/pages/CreateUserForm.tsx +++ b/client/src/pages/CreateUserForm.tsx @@ -52,6 +52,7 @@ export default function CreateUserForm({ onSuccess }: CreateUserFormProps) { {form.formState.errors.name && ( @@ -67,6 +68,7 @@ export default function CreateUserForm({ onSuccess }: CreateUserFormProps) { type="email" placeholder="user@example.com" autoComplete="off" + aria-invalid={!!form.formState.errors.email} {...form.register("email")} /> {form.formState.errors.email && ( @@ -82,6 +84,7 @@ export default function CreateUserForm({ onSuccess }: CreateUserFormProps) { type="password" placeholder="Minimum 8 characters" autoComplete="new-password" + aria-invalid={!!form.formState.errors.password} {...form.register("password")} /> {form.formState.errors.password && ( diff --git a/client/src/pages/UsersPage.test.tsx b/client/src/pages/UsersPage.test.tsx index 52b78c1..bf4fe6c 100644 --- a/client/src/pages/UsersPage.test.tsx +++ b/client/src/pages/UsersPage.test.tsx @@ -1,4 +1,5 @@ import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi, beforeEach } from "vitest"; import axios from "axios"; import { renderWithQuery } from "@/test/render"; @@ -96,4 +97,48 @@ describe("UsersPage", () => { expect(mockedAxios.get).toHaveBeenCalledWith("/api/users"); }); }); + + it("should open the create user dialog when clicking New User", async () => { + mockedAxios.get.mockResolvedValue({ data: { users: [] } }); + const user = userEvent.setup(); + renderWithQuery(); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /new user/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Create User" })).toBeInTheDocument(); + }); + + it("should close the dialog when pressing Escape", async () => { + mockedAxios.get.mockResolvedValue({ data: { users: [] } }); + const user = userEvent.setup(); + renderWithQuery(); + + await user.click(screen.getByRole("button", { name: /new user/i })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); + + it("should close the dialog when clicking the overlay", async () => { + mockedAxios.get.mockResolvedValue({ data: { users: [] } }); + const user = userEvent.setup(); + renderWithQuery(); + + await user.click(screen.getByRole("button", { name: /new user/i })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + const overlay = screen.getByRole("dialog").parentElement!.querySelector("[data-slot='dialog-overlay']"); + await user.click(overlay!); + + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + }); });