Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/web/features/auth/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@
"name": {
"field_label": "Name",
"empty_error": "Please enter your name",
"required_error": "This field can't be empty."
"required_error": "This field can't be empty",
"invalid_characters_error": "Name can only contain letters, spaces, hyphens, and apostrophes",
"min_length_error": "Name must be at least 2 characters",
"max_length_error": "Name must be no more than 100 characters"
},
"email": {
"field_label": "Email",
Expand Down
207 changes: 207 additions & 0 deletions app/web/features/auth/signup/BasicForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,211 @@ describe("basic signup form", () => {
mockConsoleError();
await assertErrorAlert("Permission denied");
});

describe("name validation", () => {
it("rejects names shorter than 2 characters", async () => {
render(<BasicForm />, { wrapper });

const user = userEvent.setup();
const nameInput = await screen.findByLabelText(
t("auth:basic_form.name.field_label"),
);

await user.type(nameInput, "A");
await user.tab(); // Trigger blur to validate

await waitFor(() => {
expect(
screen.getByText(t("auth:basic_form.name.min_length_error")),
).toBeInTheDocument();
});

await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"test@example.com",
);
await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).not.toHaveBeenCalled();
});
});

it("rejects names longer than 100 characters", async () => {
render(<BasicForm />, { wrapper });

const user = userEvent.setup();
const nameInput = await screen.findByLabelText(
t("auth:basic_form.name.field_label"),
);

const longName = "A".repeat(101);
await user.type(nameInput, longName);
await user.tab(); // Trigger blur to validate

await waitFor(() => {
expect(
screen.getByText(t("auth:basic_form.name.max_length_error")),
).toBeInTheDocument();
});

await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"test@example.com",
);
await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).not.toHaveBeenCalled();
});
});

it("rejects names with invalid characters like !@#$", async () => {
render(<BasicForm />, { wrapper });

const user = userEvent.setup();
const nameInput = await screen.findByLabelText(
t("auth:basic_form.name.field_label"),
);

await user.type(nameInput, "John!@#$");
await user.tab(); // Trigger blur to validate

await waitFor(() => {
expect(
screen.getByText(t("auth:basic_form.name.invalid_characters_error")),
).toBeInTheDocument();
});

await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"test@example.com",
);
await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).not.toHaveBeenCalled();
});
});

it("accepts names with hyphens and apostrophes", async () => {
startSignupMock.mockResolvedValue(stateAfterStart);
render(<BasicForm />, { wrapper });

const user = userEvent.setup();

await user.type(
await screen.findByLabelText(t("auth:basic_form.name.field_label")),
"Anne-Marie O'Connor",
);
await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"anne@example.com",
);

await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).toHaveBeenCalledWith(
"Anne-Marie O'Connor",
"anne@example.com",
undefined,
);
});
});

it("accepts names with Unicode characters (Cyrillic)", async () => {
startSignupMock.mockResolvedValue(stateAfterStart);
render(<BasicForm />, { wrapper });

const user = userEvent.setup();

await user.type(
await screen.findByLabelText(t("auth:basic_form.name.field_label")),
"Иван Иванов",
);
await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"ivan@example.com",
);

await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).toHaveBeenCalledWith(
"Иван Иванов",
"ivan@example.com",
undefined,
);
});
});

it("accepts names at minimum length (2 characters)", async () => {
startSignupMock.mockResolvedValue(stateAfterStart);
render(<BasicForm />, { wrapper });

const user = userEvent.setup();

await user.type(
await screen.findByLabelText(t("auth:basic_form.name.field_label")),
"Li",
);
await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"li@example.com",
);

await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).toHaveBeenCalledWith(
"Li",
"li@example.com",
undefined,
);
});
});

it("rejects names with only leading/trailing spaces that result in less than 2 characters", async () => {
render(<BasicForm />, { wrapper });

const user = userEvent.setup();
const nameInput = await screen.findByLabelText(
t("auth:basic_form.name.field_label"),
);

await user.type(nameInput, " A ");
await user.tab(); // Trigger blur to validate

await waitFor(() => {
expect(
screen.getByText(t("auth:basic_form.name.min_length_error")),
).toBeInTheDocument();
});

await user.type(
await screen.findByLabelText(t("auth:basic_form.email.field_label")),
"test@example.com",
);
await user.click(
await screen.findByRole("button", { name: t("global:continue") }),
);

await waitFor(() => {
expect(startSignupMock).not.toHaveBeenCalled();
});
});
});
});
19 changes: 17 additions & 2 deletions app/web/features/auth/signup/BasicForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,26 @@ export default function BasicForm({
<StyledTextField
id="name"
{...register("name", {
required: t("auth:basic_form.name.required_error"),
minLength: {
value: 2,
message: t("auth:basic_form.name.min_length_error"),
},
maxLength: {
value: 100,
message: t("auth:basic_form.name.max_length_error"),
},
pattern: {
message: t("auth:basic_form.name.empty_error"),
message: t("auth:basic_form.name.invalid_characters_error"),
value: nameValidationPattern,
},
required: t("auth:basic_form.name.required_error"),
validate: (value) => {
const trimmed = value.trim();
if (trimmed.length < 2) {
return t("auth:basic_form.name.min_length_error");
}
return true;
},
})}
fullWidth
name="name"
Expand Down
2 changes: 1 addition & 1 deletion app/web/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// taken from backend
export const nameValidationPattern = /\S+/;
export const nameValidationPattern = /^[\p{L}\s'-]+$/u;
export const usernameValidationPattern = /^[a-z][0-9a-z_]*[a-z0-9]$/i;
export const validatePassword = (password: string) => {
return password.length >= 8 && password.length < 256;
Expand Down
Loading