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
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions packages/backend/routes/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ function getAllowedUpdates(body, allowedFields) {
);
}

export function normalizeUserName(name) {
if (typeof name !== "string") {
return { error: "Username is required" };
}

const value = name.trim();

if (!value) {
return { error: "Username is required" };
}

return { value };
}

//check if email exists
function isDuplicateKeyError(error) {
return error?.code === 11000;
Expand Down Expand Up @@ -246,6 +260,16 @@ router.put("/:userParam", requireAuth, async (req, res) => {

const updates = getAllowedUpdates(req.body, allowedUserUpdateFields);

if (Object.hasOwn(updates, "name")) {
const normalizedName = normalizeUserName(updates.name);

if (normalizedName.error) {
return res.status(400).json({ error: normalizedName.error });
}

updates.name = normalizedName.value;
}

if (Object.keys(updates).length === 0) {
return res.status(400).json({
error: `At least one valid field is required: ${allowedUserUpdateFields.join(", ")}`,
Expand Down
25 changes: 23 additions & 2 deletions packages/frontend/src/components/Profile.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function getInterestError(value, currentInterests) {
function Profile() {
const [user, setUser] = useState(null);
const [form, setForm] = useState({
name: "",
age: "",
interests: [],
profileVisibility: "private",
Expand All @@ -90,6 +91,7 @@ function Profile() {
.then((data) => {
setUser(data.user);
setForm({
name: data.user.name || "",
age: data.user.age || "",
interests: normalizeInterests(data.user.interests),
profileVisibility: data.user.profileVisibility || "private",
Expand Down Expand Up @@ -141,11 +143,19 @@ function Profile() {
setError("");
setMessage("");

const name = form.name.trim();

if (!name) {
setError("Username is required");
return;
}

fetch(`${AZURE_URL}/api/users/${user._id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name,
age: form.age === "" ? null : Number(form.age),
interests: form.interests,
profileVisibility: form.profileVisibility,
Expand All @@ -160,6 +170,7 @@ function Profile() {
setUser(data);
setForm((current) => ({
...current,
name: data.name || "",
age: data.age || "",
interests: normalizeInterests(data.interests),
profileVisibility: data.profileVisibility || "private",
Expand Down Expand Up @@ -244,8 +255,18 @@ function Profile() {
<div className="profileContainer">
<div className="profileCard">
<div className="profileRow">
<label className="profileLabel">Name:</label>
<div className="profileValue">{user.name || "—"}</div>
<label className="profileLabel" htmlFor="profile-name">
Username:
</label>
<input
id="profile-name"
className="profileValue profileInput"
type="text"
value={form.name}
onChange={(e) =>
setForm((current) => ({ ...current, name: e.target.value }))
}
/>
</div>

<div className="profileRow">
Expand Down
20 changes: 20 additions & 0 deletions packages/tests/backend/users.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { normalizeUserName } from "@backend/routes/users.js";

describe("user update helpers", () => {
it("trims valid usernames", () => {
expect(normalizeUserName(" New Goat ")).toEqual({ value: "New Goat" });
});

it("rejects blank usernames", () => {
expect(normalizeUserName(" ")).toEqual({
error: "Username is required",
});
});

it("rejects non-string usernames", () => {
expect(normalizeUserName(null)).toEqual({
error: "Username is required",
});
});
});
107 changes: 107 additions & 0 deletions packages/tests/frontend/Profile.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import Profile from "@frontend/components/Profile.jsx";

const AZURE_URL =
"https://goattimer-hgh5bxcub9hrdgha.centralus-01.azurewebsites.net";

const profileUser = {
_id: "user-123",
name: "maastest",
email: "maastest@example.com",
age: 19,
interests: ["math"],
profileVisibility: "public",
featureSettings: {
groupsEnabled: true,
leaderboardEnabled: true,
},
};

function mockFetchResponse(body, ok = true) {
return Promise.resolve({
ok,
json: () => Promise.resolve(body),
});
}

function renderProfile(fetchMock) {
vi.stubGlobal("fetch", fetchMock);

render(
<MemoryRouter>
<Profile />
</MemoryRouter>,
);
}

afterEach(() => {
vi.unstubAllGlobals();
});

describe("Profile", () => {
it("saves an edited username with the rest of the profile data", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(mockFetchResponse({ user: profileUser }))
.mockResolvedValueOnce(
mockFetchResponse({
...profileUser,
name: "New Goat",
}),
);

renderProfile(fetchMock);

const usernameInput = await screen.findByLabelText(/username/i);
expect(usernameInput).toHaveValue("maastest");

fireEvent.change(usernameInput, { target: { value: " New Goat " } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));

const [url, options] = fetchMock.mock.calls[1];
const body = JSON.parse(options.body);

expect(url).toBe(`${AZURE_URL}/api/users/user-123`);
expect(options).toEqual(
expect.objectContaining({
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
}),
);
expect(body).toEqual(
expect.objectContaining({
name: "New Goat",
age: 19,
interests: ["math"],
profileVisibility: "public",
featureSettings: {
groupsEnabled: true,
leaderboardEnabled: true,
},
}),
);
expect(await screen.findByText(/profile updated/i)).toBeInTheDocument();
expect(usernameInput).toHaveValue("New Goat");
});

it("rejects blank usernames before sending a profile update", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(mockFetchResponse({ user: profileUser }));

renderProfile(fetchMock);

const usernameInput = await screen.findByLabelText(/username/i);

fireEvent.change(usernameInput, { target: { value: " " } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));

expect(await screen.findByText(/username is required/i)).toBeInTheDocument();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
Loading