diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts index 6615dbb..6fa709f 100644 --- a/src/app/api/compare/route.ts +++ b/src/app/api/compare/route.ts @@ -6,15 +6,13 @@ import { resolveLocale, } from "@/features/comparison/services"; import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; +import { cleanGitHubUsername } from "@/utils/github"; export const runtime = "nodejs"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const usernames = searchParams - .getAll("username") - .map((username) => username.trim()) - .filter(Boolean); + const usernames = searchParams.getAll("username").map(cleanGitHubUsername).filter(Boolean); if (usernames.length !== 2) { return NextResponse.json( diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index 0b0fabc..5a39372 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,12 +1,13 @@ import { NextResponse } from "next/server"; import { getUserProfile } from "@/features/developer/services"; import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; +import { cleanGitHubUsername } from "@/utils/github"; export const runtime = "nodejs"; export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) { const { username } = await params; - const trimmed = username?.trim(); + const trimmed = cleanGitHubUsername(username ?? ""); if (!trimmed) { return NextResponse.json( diff --git a/src/features/comparison/components/compare-form.tsx b/src/features/comparison/components/compare-form.tsx index d980f9f..4467b98 100644 --- a/src/features/comparison/components/compare-form.tsx +++ b/src/features/comparison/components/compare-form.tsx @@ -9,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { useTranslation } from "@/components/providers/language-provider"; import { handleEscapeToClear, matchesHotkey, useHotkeys, useIsApplePlatform } from "@/hooks"; import { cn } from "@/utils/cn"; +import { cleanGitHubUsername } from "@/utils/github"; const LANGUAGE_OPTIONS = [ "TypeScript", @@ -115,7 +116,7 @@ export function CompareForm({ const submitComparison = () => { if (!canSubmit) return; - onSubmit(username1.trim(), username2.trim(), { + onSubmit(cleanGitHubUsername(username1), cleanGitHubUsername(username2), { selectedLanguages, }); }; diff --git a/src/features/comparison/tests/compare-form.test.tsx b/src/features/comparison/tests/compare-form.test.tsx index 5c44d7b..6e38297 100644 --- a/src/features/comparison/tests/compare-form.test.tsx +++ b/src/features/comparison/tests/compare-form.test.tsx @@ -10,7 +10,19 @@ vi.mock("@/components/providers/language-provider", () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -function ControlledForm({ initial1 = "", initial2 = "" }) { +function ControlledForm({ + initial1 = "", + initial2 = "", + onSubmit = () => {}, +}: { + initial1?: string; + initial2?: string; + onSubmit?: ( + username1: string, + username2: string, + options: { selectedLanguages: string[] }, + ) => void; +}) { const [username1, setUsername1] = useState(initial1); const [username2, setUsername2] = useState(initial2); const [selectedLanguages, setSelectedLanguages] = useState([]); @@ -25,7 +37,7 @@ function ControlledForm({ initial1 = "", initial2 = "" }) { setUsername1={setUsername1} setUsername2={setUsername2} setSelectedLanguages={setSelectedLanguages} - onSubmit={() => {}} + onSubmit={onSubmit} /> ); @@ -89,4 +101,20 @@ describe("CompareForm / shortcut", () => { "/", ); }); + + test("submits cleaned GitHub usernames", async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "form.compare" })); + + expect(onSubmit).toHaveBeenCalledWith("octocat", "torvalds", { selectedLanguages: [] }); + }); }); diff --git a/src/features/comparison/tests/compare.route.test.ts b/src/features/comparison/tests/compare.route.test.ts index b3aadf8..cb1bef8 100644 --- a/src/features/comparison/tests/compare.route.test.ts +++ b/src/features/comparison/tests/compare.route.test.ts @@ -216,6 +216,24 @@ describe("GET /api/compare", () => { expect(body.winner?.username).toBe("user-a"); }); + test("cleans GitHub handles and profile URLs before loading users", async () => { + mocks.getUserData.mockResolvedValue({ + data: makeUser("octocat", "The Octocat"), + metrics: { duration: 0, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValue(makeScore(10)); + + const response = await GET( + makeRequest({ + username: [" @octocat ", "https://github.com/torvalds/"], + }), + ); + + expect(response.status).toBe(200); + expect(mocks.getUserData).toHaveBeenNthCalledWith(1, "octocat", expect.any(Object)); + expect(mocks.getUserData).toHaveBeenNthCalledWith(2, "torvalds", expect.any(Object)); + }); + test("returns null percentage when the losing final score is zero", async () => { mocks.getUserData.mockResolvedValueOnce({ data: makeUser("user-a", "User A"), diff --git a/src/features/developer/tests/user.route.test.ts b/src/features/developer/tests/user.route.test.ts index b6c718d..7763ab8 100644 --- a/src/features/developer/tests/user.route.test.ts +++ b/src/features/developer/tests/user.route.test.ts @@ -150,6 +150,23 @@ describe("GET /api/user/[username]", () => { expect(body.location).toBe("Stockholm, Sweden"); }); + test("cleans a GitHub profile URL before loading the user", async () => { + const rawUser = makeUser("octocat", "The Octocat"); + mocks.getUserData.mockResolvedValueOnce({ + data: rawUser, + metrics: { duration: 100, errors: [] }, + }); + mocks.calculateUserScore.mockReturnValueOnce(makeScore(40)); + + const request = new Request("http://localhost/api/user/https%3A%2F%2Fgithub.com%2Foctocat%2F"); + const response = await GET(request, { + params: Promise.resolve({ username: "https://github.com/octocat/" }), + }); + + expect(response.status).toBe(200); + expect(mocks.getUserData).toHaveBeenCalledWith("octocat", expect.any(Object)); + }); + test("returns 404 when user is not found on GitHub", async () => { mocks.getUserData.mockRejectedValueOnce( new GitHubApiError({ diff --git a/src/utils/github.test.ts b/src/utils/github.test.ts new file mode 100644 index 0000000..36adfda --- /dev/null +++ b/src/utils/github.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest"; +import { cleanGitHubUsername } from "./github"; + +describe("cleanGitHubUsername", () => { + test.each([ + [" @octocat ", "octocat"], + ["https://github.com/octocat/", "octocat"], + ["github.com/torvalds", "torvalds"], + ["octocat/", "octocat"], + ["https://example.com/octocat", "https://example.com/octocat"], + ])("cleans %j", (input, expected) => { + expect(cleanGitHubUsername(input)).toBe(expected); + }); +}); diff --git a/src/utils/github.ts b/src/utils/github.ts new file mode 100644 index 0000000..c07ad8d --- /dev/null +++ b/src/utils/github.ts @@ -0,0 +1,20 @@ +export function cleanGitHubUsername(input: string): string { + const trimmed = input.trim().replace(/^@+/, "").replace(/\/+$/, ""); + if (!trimmed) return ""; + + const url = tryParseGitHubUrl(trimmed); + if (!url) return trimmed; + + return url.pathname.split("/").filter(Boolean)[0] ?? ""; +} + +function tryParseGitHubUrl(input: string): URL | null { + const urlInput = input.startsWith("github.com/") ? `https://${input}` : input; + + try { + const url = new URL(urlInput); + return url.hostname === "github.com" || url.hostname === "www.github.com" ? url : null; + } catch { + return null; + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 963c6b2..0245115 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from "./cn"; +export * from "./github";