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