From 13fb015995a014ed4101af701359527648363f55 Mon Sep 17 00:00:00 2001 From: Adrian Date: Mon, 1 Jun 2026 10:44:40 -0700 Subject: [PATCH] now we can make these cool fun changes that does the naming thing , and change the username --- package-lock.json | 6 +- packages/backend/routes/users.js | 24 +++++ packages/frontend/src/components/Profile.jsx | 25 ++++- packages/tests/backend/users.test.js | 20 ++++ packages/tests/frontend/Profile.test.jsx | 107 +++++++++++++++++++ 5 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 packages/tests/backend/users.test.js create mode 100644 packages/tests/frontend/Profile.test.jsx diff --git a/package-lock.json b/package-lock.json index 2134f81..d35ec1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4565,9 +4565,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" diff --git a/packages/backend/routes/users.js b/packages/backend/routes/users.js index 649caa8..b78e214 100644 --- a/packages/backend/routes/users.js +++ b/packages/backend/routes/users.js @@ -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; @@ -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(", ")}`, diff --git a/packages/frontend/src/components/Profile.jsx b/packages/frontend/src/components/Profile.jsx index fb0fd90..2565d84 100644 --- a/packages/frontend/src/components/Profile.jsx +++ b/packages/frontend/src/components/Profile.jsx @@ -67,6 +67,7 @@ function getInterestError(value, currentInterests) { function Profile() { const [user, setUser] = useState(null); const [form, setForm] = useState({ + name: "", age: "", interests: [], profileVisibility: "private", @@ -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", @@ -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, @@ -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", @@ -244,8 +255,18 @@ function Profile() {
- -
{user.name || "—"}
+ + + setForm((current) => ({ ...current, name: e.target.value })) + } + />
diff --git a/packages/tests/backend/users.test.js b/packages/tests/backend/users.test.js new file mode 100644 index 0000000..c8cfdda --- /dev/null +++ b/packages/tests/backend/users.test.js @@ -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", + }); + }); +}); diff --git a/packages/tests/frontend/Profile.test.jsx b/packages/tests/frontend/Profile.test.jsx new file mode 100644 index 0000000..e4c837f --- /dev/null +++ b/packages/tests/frontend/Profile.test.jsx @@ -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( + + + , + ); +} + +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); + }); +});