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: 2 additions & 4 deletions src/app/api/compare/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/user/[username]/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
3 changes: 2 additions & 1 deletion src/features/comparison/components/compare-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -115,7 +116,7 @@ export function CompareForm({

const submitComparison = () => {
if (!canSubmit) return;
onSubmit(username1.trim(), username2.trim(), {
onSubmit(cleanGitHubUsername(username1), cleanGitHubUsername(username2), {
selectedLanguages,
});
};
Expand Down
32 changes: 30 additions & 2 deletions src/features/comparison/tests/compare-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
Expand All @@ -25,7 +37,7 @@ function ControlledForm({ initial1 = "", initial2 = "" }) {
setUsername1={setUsername1}
setUsername2={setUsername2}
setSelectedLanguages={setSelectedLanguages}
onSubmit={() => {}}
onSubmit={onSubmit}
/>
</>
);
Expand Down Expand Up @@ -89,4 +101,20 @@ describe("CompareForm / shortcut", () => {
"/",
);
});

test("submits cleaned GitHub usernames", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<ControlledForm
initial1=" @octocat "
initial2="https://github.com/torvalds/"
onSubmit={onSubmit}
/>,
);

await user.click(screen.getByRole("button", { name: "form.compare" }));

expect(onSubmit).toHaveBeenCalledWith("octocat", "torvalds", { selectedLanguages: [] });
});
});
18 changes: 18 additions & 0 deletions src/features/comparison/tests/compare.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 17 additions & 0 deletions src/features/developer/tests/user.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 14 additions & 0 deletions src/utils/github.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions src/utils/github.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./cn";
export * from "./github";
Loading