From 69ac6140337b1e0e9a45da4baa4ba48667d19c18 Mon Sep 17 00:00:00 2001 From: Tyler Hanway Date: Wed, 4 Jun 2025 00:37:00 -0500 Subject: [PATCH] Add tests for pages and api routes --- app/[...githubPath]/page.test.tsx | 245 ++++++++++++++++++++++++++++++ app/[...githubPath]/page.tsx | 1 + app/[org]/[repo]/page.test.tsx | 97 ++++++++++++ app/api/stargazers/route.test.ts | 190 +++++++++++++++++++++++ app/api/stargazers/route.ts | 40 ++++- 5 files changed, 567 insertions(+), 6 deletions(-) create mode 100644 app/[...githubPath]/page.test.tsx create mode 100644 app/[org]/[repo]/page.test.tsx create mode 100644 app/api/stargazers/route.test.ts diff --git a/app/[...githubPath]/page.test.tsx b/app/[...githubPath]/page.test.tsx new file mode 100644 index 0000000..686fdfb --- /dev/null +++ b/app/[...githubPath]/page.test.tsx @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { redirect } from "next/navigation"; +import GitHubPathPage from "./page"; + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/utils/github", () => ({ + parseGitHubURL: vi.fn(), +})); + +import { parseGitHubURL } from "@/utils/github"; + +const mockedRedirect = vi.mocked(redirect); +const mockedParseGitHubURL = vi.mocked(parseGitHubURL); + +describe("GitHubPathPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("when GitHub path is valid", () => { + it("redirects to correct org/repo route for simple repository", async () => { + mockedParseGitHubURL.mockReturnValue("facebook/react"); + + const params = Promise.resolve({ githubPath: ["facebook", "react"] }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/facebook/react" + ); + expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react"); + }); + + it("redirects for repository with special characters", async () => { + mockedParseGitHubURL.mockReturnValue("vercel/next.js"); + + const params = Promise.resolve({ githubPath: ["vercel", "next.js"] }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/vercel/next.js" + ); + expect(mockedRedirect).toHaveBeenCalledWith("/vercel/next.js"); + }); + + it("redirects for repository with hyphens", async () => { + mockedParseGitHubURL.mockReturnValue("facebook/react-native"); + + const params = Promise.resolve({ + githubPath: ["facebook", "react-native"], + }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/facebook/react-native" + ); + expect(mockedRedirect).toHaveBeenCalledWith("/facebook/react-native"); + }); + + it("handles complex paths with additional segments", async () => { + mockedParseGitHubURL.mockReturnValue("microsoft/typescript"); + + const params = Promise.resolve({ + githubPath: ["microsoft", "typescript", "tree", "main", "src"], + }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/microsoft/typescript/tree/main/src" + ); + expect(mockedRedirect).toHaveBeenCalledWith("/microsoft/typescript"); + }); + + it("handles single segment paths", async () => { + mockedParseGitHubURL.mockReturnValue("nodejs/node"); + + const params = Promise.resolve({ githubPath: ["nodejs", "node"] }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/nodejs/node" + ); + expect(mockedRedirect).toHaveBeenCalledWith("/nodejs/node"); + }); + }); + + describe("when GitHub path is invalid", () => { + it("returns error message when parseGitHubURL returns null", async () => { + mockedParseGitHubURL.mockReturnValue(null); + + const params = Promise.resolve({ githubPath: ["invalid"] }); + + const result = await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/invalid" + ); + expect(mockedRedirect).not.toHaveBeenCalled(); + // Check that it returns JSX with the error message + expect(result).toBeDefined(); + expect(result.type).toBe("div"); + expect(result.props.children).toBe( + "Invalid GitHub URL. Please enter a valid GitHub repository." + ); + }); + + it("returns error message for empty path", async () => { + mockedParseGitHubURL.mockReturnValue(null); + + const params = Promise.resolve({ githubPath: [] }); + + const result = await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith("https://github.com/"); + expect(mockedRedirect).not.toHaveBeenCalled(); + // Check that it returns JSX with the error message + expect(result).toBeDefined(); + expect(result.type).toBe("div"); + expect(result.props.children).toBe( + "Invalid GitHub URL. Please enter a valid GitHub repository." + ); + }); + + it("returns error message for malformed repository names", async () => { + mockedParseGitHubURL.mockReturnValue(null); + + const params = Promise.resolve({ + githubPath: ["not-a-valid-repo-format"], + }); + + const result = await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/not-a-valid-repo-format" + ); + expect(mockedRedirect).not.toHaveBeenCalled(); + // Check that it returns JSX with the error message + expect(result).toBeDefined(); + expect(result.type).toBe("div"); + expect(result.props.children).toBe( + "Invalid GitHub URL. Please enter a valid GitHub repository." + ); + }); + }); + + describe("edge cases", () => { + it("handles URLs with encoded characters", async () => { + mockedParseGitHubURL.mockReturnValue("user/repo-with-special-chars"); + + const params = Promise.resolve({ + githubPath: ["user", "repo%20with%20spaces"], + }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/user/repo%20with%20spaces" + ); + expect(mockedRedirect).toHaveBeenCalledWith( + "/user/repo-with-special-chars" + ); + }); + + it("handles very long paths", async () => { + mockedParseGitHubURL.mockReturnValue("org/repo"); + + const longPath = [ + "org", + "repo", + "tree", + "main", + "src", + "components", + "ui", + "button", + ]; + const params = Promise.resolve({ githubPath: longPath }); + + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + `https://github.com/${longPath.join("/")}` + ); + expect(mockedRedirect).toHaveBeenCalledWith("/org/repo"); + }); + + it("handles path with only organization name", async () => { + mockedParseGitHubURL.mockReturnValue(null); + + const params = Promise.resolve({ githubPath: ["facebook"] }); + + const result = await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith( + "https://github.com/facebook" + ); + expect(mockedRedirect).not.toHaveBeenCalled(); + // Check that it returns JSX with the error message + expect(result).toBeDefined(); + expect(result.type).toBe("div"); + expect(result.props.children).toBe( + "Invalid GitHub URL. Please enter a valid GitHub repository." + ); + }); + }); + + describe("parseGitHubURL integration", () => { + it("properly constructs GitHub URLs for parsing", async () => { + const testCases = [ + { + input: ["microsoft", "typescript"], + expected: "https://github.com/microsoft/typescript", + }, + { + input: ["vercel", "next.js", "blob", "main", "README.md"], + expected: "https://github.com/vercel/next.js/blob/main/README.md", + }, + { + input: ["facebook", "react", "issues"], + expected: "https://github.com/facebook/react/issues", + }, + ]; + + for (const testCase of testCases) { + mockedParseGitHubURL.mockReturnValue("owner/repo"); + + const params = Promise.resolve({ githubPath: testCase.input }); + await GitHubPathPage({ params }); + + expect(mockedParseGitHubURL).toHaveBeenCalledWith(testCase.expected); + } + }); + }); +}); diff --git a/app/[...githubPath]/page.tsx b/app/[...githubPath]/page.tsx index 51f6bc6..209b3c9 100644 --- a/app/[...githubPath]/page.tsx +++ b/app/[...githubPath]/page.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { redirect } from "next/navigation"; import { parseGitHubURL } from "@/utils/github"; diff --git a/app/[org]/[repo]/page.test.tsx b/app/[org]/[repo]/page.test.tsx new file mode 100644 index 0000000..59cb97c --- /dev/null +++ b/app/[org]/[repo]/page.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { generateMetadata } from "./page"; + +describe("RepoPage", () => { + describe("generateMetadata", () => { + it("generates correct metadata for repository", async () => { + const params = Promise.resolve({ org: "facebook", repo: "react" }); + + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: "facebook/react Related Repos - GitRelate(d)", + }); + }); + + it("generates metadata for different repository names", async () => { + const testCases = [ + { + org: "microsoft", + repo: "typescript", + expected: "microsoft/typescript Related Repos - GitRelate(d)", + }, + { + org: "vercel", + repo: "next.js", + expected: "vercel/next.js Related Repos - GitRelate(d)", + }, + { + org: "nodejs", + repo: "node", + expected: "nodejs/node Related Repos - GitRelate(d)", + }, + { + org: "facebook", + repo: "react-native", + expected: "facebook/react-native Related Repos - GitRelate(d)", + }, + ]; + + for (const { org, repo, expected } of testCases) { + const params = Promise.resolve({ org, repo }); + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: expected, + }); + } + }); + + it("handles special characters in repository names", async () => { + const params = Promise.resolve({ + org: "user", + repo: "repo-with-special.chars_123", + }); + + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: "user/repo-with-special.chars_123 Related Repos - GitRelate(d)", + }); + }); + + it("handles repository names with dots", async () => { + const params = Promise.resolve({ org: "vercel", repo: "next.js" }); + + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: "vercel/next.js Related Repos - GitRelate(d)", + }); + }); + + it("handles repository names with underscores and hyphens", async () => { + const params = Promise.resolve({ org: "some-org", repo: "my_repo" }); + + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: "some-org/my_repo Related Repos - GitRelate(d)", + }); + }); + + it("handles long repository names", async () => { + const params = Promise.resolve({ + org: "organization-with-long-name", + repo: "repository-with-very-long-name-containing-multiple-words", + }); + + const metadata = await generateMetadata({ params }); + + expect(metadata).toEqual({ + title: + "organization-with-long-name/repository-with-very-long-name-containing-multiple-words Related Repos - GitRelate(d)", + }); + }); + }); +}); diff --git a/app/api/stargazers/route.test.ts b/app/api/stargazers/route.test.ts new file mode 100644 index 0000000..8c20851 --- /dev/null +++ b/app/api/stargazers/route.test.ts @@ -0,0 +1,190 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./route"; + +vi.mock("@/lib/github", () => ({ + getStargazers: vi.fn(), +})); + +import { getStargazers } from "@/lib/github"; + +const mockedGetStargazers = vi.mocked(getStargazers); + +describe("Stargazers API Route", () => { + const originalEnv = process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + // Restore original environment variable + if (originalEnv !== undefined) { + process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = originalEnv; + } else { + delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; + } + vi.resetAllMocks(); + }); + + describe("when client-side fetching is enabled", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "true"; + }); + + it("returns informational message about client-side fetching", async () => { + const request = new NextRequest( + "http://localhost:3000/api/stargazers?repo=owner/repo" + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + message: + "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.", + suggestion: + "This API endpoint is available but not recommended when client-side fetching is enabled.", + clientFetchingEnabled: true, + }); + + // Should not call the actual API function + expect(mockedGetStargazers).not.toHaveBeenCalled(); + }); + + it("returns the same message regardless of query parameters", async () => { + const request = new NextRequest("http://localhost:3000/api/stargazers"); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.clientFetchingEnabled).toBe(true); + expect(mockedGetStargazers).not.toHaveBeenCalled(); + }); + }); + + describe("when client-side fetching is disabled", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE = "false"; + }); + + it("returns stargazers data when repo parameter is provided", async () => { + const mockStargazers = [ + { login: "user1", avatar_url: "https://github.com/user1.png" }, + { login: "user2", avatar_url: "https://github.com/user2.png" }, + ]; + + mockedGetStargazers.mockResolvedValue(mockStargazers); + + const request = new NextRequest( + "http://localhost:3000/api/stargazers?repo=facebook/react" + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockStargazers); + expect(mockedGetStargazers).toHaveBeenCalledWith("facebook/react"); + }); + + it("returns 400 error when repo parameter is missing", async () => { + const request = new NextRequest("http://localhost:3000/api/stargazers"); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ error: "Repo parameter is required" }); + expect(mockedGetStargazers).not.toHaveBeenCalled(); + }); + + it("returns 400 error when repo parameter is empty", async () => { + const request = new NextRequest( + "http://localhost:3000/api/stargazers?repo=" + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toEqual({ error: "Repo parameter is required" }); + expect(mockedGetStargazers).not.toHaveBeenCalled(); + }); + + it("handles errors from getStargazers function", async () => { + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const error = new Error("GitHub API error"); + mockedGetStargazers.mockRejectedValue(error); + + const request = new NextRequest( + "http://localhost:3000/api/stargazers?repo=owner/repo" + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toEqual({ error: "Failed to fetch stargazers" }); + expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "Error fetching stargazers:", + error + ); + + consoleErrorSpy.mockRestore(); + }); + + it("handles various repo name formats", async () => { + const mockStargazers = [ + { login: "user1", avatar_url: "https://github.com/user1.png" }, + ]; + mockedGetStargazers.mockResolvedValue(mockStargazers); + + const testCases = [ + "microsoft/typescript", + "vercel/next.js", + "facebook/react-native", + "nodejs/node", + ]; + + for (const repo of testCases) { + const request = new NextRequest( + `http://localhost:3000/api/stargazers?repo=${repo}` + ); + const response = await GET(request); + + expect(response.status).toBe(200); + expect(mockedGetStargazers).toHaveBeenCalledWith(repo); + } + }); + }); + + describe("when client-side fetching environment variable is not set", () => { + beforeEach(() => { + delete process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE; + }); + + it("falls back to server-side implementation", async () => { + const mockStargazers = [ + { login: "user1", avatar_url: "https://github.com/user1.png" }, + ]; + mockedGetStargazers.mockResolvedValue(mockStargazers); + + const request = new NextRequest( + "http://localhost:3000/api/stargazers?repo=owner/repo" + ); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual(mockStargazers); + expect(mockedGetStargazers).toHaveBeenCalledWith("owner/repo"); + }); + }); +}); diff --git a/app/api/stargazers/route.ts b/app/api/stargazers/route.ts index b39c59e..4c10016 100644 --- a/app/api/stargazers/route.ts +++ b/app/api/stargazers/route.ts @@ -1,12 +1,40 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getStargazers } from '@/lib/github'; +import { getStargazers } from "@/lib/github"; +import { NextRequest, NextResponse } from "next/server"; export async function GET(req: NextRequest) { + // Check if client-side fetching is enabled + if (process.env.NEXT_PUBLIC_USE_CLIENT_CLICKHOUSE === "true") { + return NextResponse.json( + { + message: + "Client-side fetching is enabled. Data is fetched directly from ClickHouse in the browser for better performance.", + suggestion: + "This API endpoint is available but not recommended when client-side fetching is enabled.", + clientFetchingEnabled: true, + }, + { status: 200 } + ); + } + + // Client-side fetching is disabled, use original API implementation const { searchParams } = new URL(req.url); - const repo = searchParams.get('repo'); - if (!repo) return NextResponse.json({ error: 'Repo is required' }, { status: 400 }); + const repo = searchParams.get("repo"); - const stargazers = await getStargazers(repo); + if (!repo) { + return NextResponse.json( + { error: "Repo parameter is required" }, + { status: 400 } + ); + } - return NextResponse.json(stargazers); + try { + const stargazers = await getStargazers(repo); + return NextResponse.json(stargazers); + } catch (error) { + console.error("Error fetching stargazers:", error); + return NextResponse.json( + { error: "Failed to fetch stargazers" }, + { status: 500 } + ); + } }