diff --git a/package-lock.json b/package-lock.json index e685135e..d6110935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "eslint": "^9", "eslint-config-next": "^16.1.6", "jsdom": "^28.1.0", + "mitata": "^1.0.34", "server-only": "^0.0.1", "tailwindcss": "^4", "typescript": "^5", @@ -7420,6 +7421,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mitata": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/mitata/-/mitata-1.0.34.tgz", + "integrity": "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index bba07435..5b46f424 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "eslint": "^9", "eslint-config-next": "^16.1.6", "jsdom": "^28.1.0", + "mitata": "^1.0.34", "server-only": "^0.0.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/src/lib/__tests__/githubYearInReview.test.ts b/src/lib/__tests__/githubYearInReview.test.ts index f89b1ac8..20b8f84b 100644 --- a/src/lib/__tests__/githubYearInReview.test.ts +++ b/src/lib/__tests__/githubYearInReview.test.ts @@ -393,6 +393,21 @@ describe("fetchYearInReviewData safety checks", () => { }); describe("fetchYearInReviewData additional coverage", () => { + it("throws 500 GitHubApiError for non-Error thrown objects", async () => { + mockFetch.mockImplementation(() => { + throw "String error"; + }); + + await expect(fetchYearInReviewData("testuser", 2024, "fake-token")).rejects.toThrow(GitHubApiError); + try { + await fetchYearInReviewData("testuser", 2024, "fake-token"); + } catch (error) { + expect(error).toBeInstanceOf(GitHubApiError); + expect((error as GitHubApiError).status).toBe(500); + expect((error as GitHubApiError).message).toBe("Failed to fetch year in review data"); + } + }); + it("throws 500 GitHubApiError for unknown errors", async () => { mockFetch.mockImplementation(() => { throw new Error("Unexpected crash"); @@ -592,25 +607,123 @@ describe("githubYearInReview additional edge cases", () => { }); it("fetchCommitDatesForTopRepos handles missing defaultBranchRef or history", async () => { - mockFetch.mockImplementation(() => { - return Promise.resolve(jsonResponse({ - data: { - user: { - id: "123", - contributionsCollection: { - totalCommitContributions: 1, - contributionCalendar: { totalContributions: 1, weeks: [] }, - commitContributionsByRepository: [ - { repository: { owner: { login: "u" }, name: "r" }, contributions: { totalCount: 1 } } - ] + let callCount = 0; + mockFetch.mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ + data: { + user: { + id: "u1", + contributionsCollection: { + totalCommitContributions: 10, + totalPullRequestContributions: 0, + totalIssueContributions: 0, + totalPullRequestReviewContributions: 0, + contributionCalendar: { + totalContributions: 10, + weeks: [] + }, + commitContributionsByRepository: [ + { repository: { name: "repo", owner: { login: "u" } }, contributions: { totalCount: 10 } } + ] + } + } } - }, - repo0: { defaultBranchRef: null } // Missing defaultBranchRef + })); } - })); + return Promise.resolve(jsonResponse({ + data: { + repo0: { defaultBranchRef: null } + } + })); + } + return Promise.resolve(jsonResponse([], 200)); }); const data = await fetchYearInReviewData("user", 2024, "token"); expect(data.year).toBe(2024); }); + + it("fetchCommitDatesForTopRepos handles nodes without author or date", async () => { + let callCount = 0; + mockFetch.mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ + data: { + user: { + id: "u1", + contributionsCollection: { + totalCommitContributions: 10, + totalPullRequestContributions: 0, + totalIssueContributions: 0, + totalPullRequestReviewContributions: 0, + contributionCalendar: { + totalContributions: 10, + weeks: [] + }, + commitContributionsByRepository: [ + { repository: { name: "repo", owner: { login: "u" } }, contributions: { totalCount: 10 } } + ] + } + } + } + })); + } + return Promise.resolve(jsonResponse({ + data: { + repo0: { defaultBranchRef: { target: { history: { nodes: [{ author: null }, { author: { date: null } }, { author: { date: "2024-01-01T12:00:00Z" } }] } } } } + } + })); + } + return Promise.resolve(jsonResponse([], 200)); + }); + const data = await fetchYearInReviewData("user", 2024, "token"); + expect(data.year).toBe(2024); + }); + + it("fetchCommitDatesForTopRepos handles null nodes in history", async () => { + let callCount = 0; + mockFetch.mockImplementation((url) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ + data: { + user: { + id: "u1", + contributionsCollection: { + totalCommitContributions: 10, + totalPullRequestContributions: 0, + totalIssueContributions: 0, + totalPullRequestReviewContributions: 0, + contributionCalendar: { + totalContributions: 10, + weeks: [] + }, + commitContributionsByRepository: [ + { repository: { name: "repo", owner: { login: "u" } }, contributions: { totalCount: 10 } } + ] + } + } + } + })); + } + return Promise.resolve(jsonResponse({ + data: { + repo0: { defaultBranchRef: { target: { history: { nodes: [null, { author: { date: "2024-01-01T12:00:00Z" } }] } } } } + } + })); + } + return Promise.resolve(jsonResponse([], 200)); + }); + const data = await fetchYearInReviewData("user", 2024, "token"); + expect(data.year).toBe(2024); + }); }); diff --git a/src/lib/githubYearInReview.ts b/src/lib/githubYearInReview.ts index 23c4538d..a3539d07 100644 --- a/src/lib/githubYearInReview.ts +++ b/src/lib/githubYearInReview.ts @@ -196,16 +196,17 @@ async function fetchCommitDatesForTopRepos( const response = await graphql>(query, token, variables); const dates: string[] = []; + type RepoGraphQLNode = { + defaultBranchRef?: { target?: { history?: { nodes?: Array<{ author?: { date?: string } }> } } }; + }; + for (let i = 0; i < candidates.length; i++) { - const repoData = response[`repo${i}`] as Record | undefined; - const defaultBranchRef = repoData?.defaultBranchRef as Record | undefined; - const target = defaultBranchRef?.target as Record | undefined; - const history = target?.history as Record | undefined; - const historyNodes = (history?.nodes as Array> | undefined) || []; - for (const node of historyNodes) { - const author = node?.author as Record | undefined; - if (typeof author?.date === 'string') { - dates.push(author.date); + const nodes = (response[`repo${i}`] as RepoGraphQLNode | undefined)?.defaultBranchRef?.target?.history?.nodes; + if (!nodes) continue; + for (let j = 0; j < nodes.length; j++) { + const date = nodes[j]?.author?.date; + if (typeof date === 'string') { + dates.push(date); } } }