diff --git a/src/lib/__tests__/cardDataFetcher.test.ts b/src/lib/__tests__/cardDataFetcher.test.ts index 233cb21d..d75744c9 100644 --- a/src/lib/__tests__/cardDataFetcher.test.ts +++ b/src/lib/__tests__/cardDataFetcher.test.ts @@ -60,7 +60,7 @@ describe("fetchCardData", () => { name: "repo-2", stargazers_count: 5, forks_count: 1, - language: "TypeScript", + language: "JavaScript", html_url: "https://github.com/alice/repo-2", pushed_at: new Date().toISOString(), }, @@ -205,3 +205,79 @@ describe("fetchCardData", () => { } }); }); + + it("handles totalLanguageRepos === 0", async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse({ + login: "alice", + name: "Alice", + avatar_url: "https://example.com/a.png", + bio: "hello", + followers: 10, + following: 4, + public_repos: 12, + })) + .mockResolvedValueOnce(jsonResponse([ + { + name: "repo-1", + stargazers_count: 20, + forks_count: 2, + language: null, // No language + html_url: "https://github.com/alice/repo-1", + pushed_at: new Date().toISOString(), + } + ])); + + const { fetchCardData } = await import("@/lib/cardDataFetcher"); + const result = await fetchCardData("alice"); + + expect(result).not.toBeNull(); + expect(result?.languages).toHaveLength(0); + }); + + it("throws non-abort errors from fetch", async () => { + const error = new Error("Network error"); + mockFetch.mockRejectedValueOnce(error); + + const { fetchCardData } = await import("@/lib/cardDataFetcher"); + + await expect(fetchCardData("alice")).rejects.toThrow("Network error"); + }); + + it("throws GitHubApiError when response is not ok", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + headers: new Headers(), + text: () => Promise.resolve("Internal Server Error"), + } as unknown as Response) + .mockResolvedValueOnce(jsonResponse([])); + + const { fetchCardData } = await import("@/lib/cardDataFetcher"); + + await expect(fetchCardData("alice")).rejects.toMatchObject({ + name: "GitHubApiError", + message: "Internal Server Error", + status: 500, + }); + }); + + it("throws GitHubApiError with default message when response is not ok and text fails", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 500, + headers: new Headers(), + text: () => Promise.reject(new Error("Failed to read")), + } as unknown as Response) + .mockResolvedValueOnce(jsonResponse([])); + + const { fetchCardData } = await import("@/lib/cardDataFetcher"); + + await expect(fetchCardData("alice")).rejects.toMatchObject({ + name: "GitHubApiError", + message: "Unknown GitHub error", + status: 500, + }); + }); diff --git a/src/lib/cardDataFetcher.ts b/src/lib/cardDataFetcher.ts index 5bdc6b00..0a5faf0d 100644 --- a/src/lib/cardDataFetcher.ts +++ b/src/lib/cardDataFetcher.ts @@ -123,77 +123,6 @@ function toCardProfile(user: GitHubUser): CardProfileData { }; } -function toCardRepos(repos: GitHubRepo[]): CardRepoData[] { - return repos.map((repo) => ({ - name: repo.name, - stars: repo.stargazers_count, - forks: repo.forks_count, - language: repo.language, - url: repo.html_url, - pushedAt: repo.pushed_at, - })); -} - -function buildLanguageStats(repos: CardRepoData[]): CardLanguageData[] { - const bucket = new Map(); - for (const repo of repos) { - if (!repo.language) { - continue; - } - bucket.set(repo.language, (bucket.get(repo.language) ?? 0) + 1); - } - - const total = Array.from(bucket.values()).reduce((acc, value) => acc + value, 0); - - return Array.from(bucket.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 6) - .map(([name, count]) => ({ - name, - count, - percentage: total > 0 ? Math.round((count / total) * 1000) / 10 : 0, - })); -} - -function buildHeatmapFromRepoPushes(repos: CardRepoData[]): { days: { date: string; count: number }[]; maxCount: number } { - const today = new Date(); - const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); - start.setUTCDate(start.getUTCDate() - 41); - - const dayCounts = new Map(); - for (let i = 0; i < 42; i += 1) { - const date = new Date(start); - date.setUTCDate(start.getUTCDate() + i); - const key = date.toISOString().slice(0, 10); - dayCounts.set(key, 0); - } - - for (const repo of repos) { - if (!repo.pushedAt) { - continue; - } - - let key: string; - if (repo.pushedAt.length >= 10 && repo.pushedAt[4] === '-' && repo.pushedAt[7] === '-') { - key = repo.pushedAt.slice(0, 10); - } else { - const pushed = new Date(repo.pushedAt); - if (Number.isNaN(pushed.getTime())) { - continue; - } - key = pushed.toISOString().slice(0, 10); - } - - if (dayCounts.has(key)) { - dayCounts.set(key, (dayCounts.get(key) ?? 0) + 1); - } - } - - const days = Array.from(dayCounts.entries()).map(([date, count]) => ({ date, count })); - const maxCount = days.reduce((max, day) => Math.max(max, day.count), 0); - return { days, maxCount }; -} - function buildStreak(days: { date: string; count: number }[]): { current: number; longest: number } { let longest = 0; let activeRun = 0; @@ -233,10 +162,75 @@ export async function fetchCardData(username: string): Promise return null; } - const repos = toCardRepos(reposResult.data ?? []); - const languages = buildLanguageStats(repos); - const totalStars = repos.reduce((acc, repo) => acc + repo.stars, 0); - const heatmap = buildHeatmapFromRepoPushes(repos); + const rawRepos = reposResult.data ?? []; + + const repos: CardRepoData[] = []; + const languageBucket = new Map(); + let totalLanguageRepos = 0; + let totalStars = 0; + + const today = new Date(); + const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())); + start.setUTCDate(start.getUTCDate() - 41); + + const dayCounts = new Map(); + for (let i = 0; i < 42; i += 1) { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + i); + const key = date.toISOString().slice(0, 10); + dayCounts.set(key, 0); + } + + for (let i = 0; i < rawRepos.length; i++) { + const rawRepo = rawRepos[i]; + + const repo: CardRepoData = { + name: rawRepo.name, + stars: rawRepo.stargazers_count, + forks: rawRepo.forks_count, + language: rawRepo.language, + url: rawRepo.html_url, + pushedAt: rawRepo.pushed_at, + }; + repos.push(repo); + + totalStars += repo.stars; + + if (repo.language) { + languageBucket.set(repo.language, (languageBucket.get(repo.language) ?? 0) + 1); + totalLanguageRepos += 1; + } + + if (repo.pushedAt) { + let key: string | undefined; + if (repo.pushedAt.length >= 10 && repo.pushedAt[4] === '-' && repo.pushedAt[7] === '-') { + key = repo.pushedAt.slice(0, 10); + } else { + const pushed = new Date(repo.pushedAt); + if (Number.isNaN(pushed.getTime())) { + continue; + } + key = pushed.toISOString().slice(0, 10); + } + if (key && dayCounts.has(key)) { + dayCounts.set(key, (dayCounts.get(key) ?? 0) + 1); + } + } + } + + const languages: CardLanguageData[] = Array.from(languageBucket.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name, count]) => ({ + name, + count, + percentage: totalLanguageRepos > 0 ? Math.round((count / totalLanguageRepos) * 1000) / 10 : 0, + })); + + const days = Array.from(dayCounts.entries()).map(([date, count]) => ({ date, count })); + const maxCount = days.reduce((max, day) => Math.max(max, day.count), 0); + const heatmap = { days, maxCount }; + const streak = buildStreak(heatmap.days); return {