diff --git a/src/lib/__tests__/githubYearInReview.test.ts b/src/lib/__tests__/githubYearInReview.test.ts index 456afee..8e9fbf2 100644 --- a/src/lib/__tests__/githubYearInReview.test.ts +++ b/src/lib/__tests__/githubYearInReview.test.ts @@ -1,8 +1,32 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { getMostActiveDayFromCalendar, getMostActiveHour } from "@/lib/yearInReviewUtils"; import { fetchYearInReviewData } from "@/lib/githubYearInReview"; -import { GitHubApiError } from "@/lib/types"; +import { GitHubApiError, RateLimitError, UserNotFoundError } from "@/lib/types"; + +// "server-only" を事前にモック +vi.mock("server-only", () => ({})); + +export const mockFetch = vi.fn(); + +beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +export function jsonResponse(data: unknown, status = 200, headers: Record = {}): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + } as unknown as Response; +} describe("githubYearInReview helpers", () => { it("returns the hour with the highest summed activity", () => { @@ -26,7 +50,7 @@ describe("githubYearInReview helpers", () => { }); }); -describe("fetchYearInReviewData", () => { +describe("fetchYearInReviewData error paths", () => { it("throws GitHubApiError when token is not provided", async () => { await expect(fetchYearInReviewData("testuser", 2024)).rejects.toThrow(GitHubApiError); @@ -38,4 +62,90 @@ describe("fetchYearInReviewData", () => { expect((error as GitHubApiError).status).toBe(401); } }); + + it("throws UserNotFoundError when statsResponse.user is null", async () => { + let callCount = 0; + mockFetch.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ data: { user: null } })); // statsPromise + } + if (callCount === 2) { + return Promise.resolve(jsonResponse({ + data: { + user: { + contributionsCollection: { + commitContributionsByRepository: [] + } + } + } + })); // reposResponse + } + } + return Promise.resolve(jsonResponse([], 200)); + }); + + await expect(fetchYearInReviewData("nonexistent", 2024, "fake-token")).rejects.toThrow(UserNotFoundError); + }); + + it("throws UserNotFoundError when reposResponse.user is null", async () => { + let callCount = 0; + mockFetch.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ data: { user: { contributionsCollection: {} } } })); + } + if (callCount === 2) { + return Promise.resolve(jsonResponse({ data: { user: null } })); + } + } + return Promise.resolve(jsonResponse([], 200)); + }); + + await expect(fetchYearInReviewData("nonexistent", 2024, "fake-token")).rejects.toThrow(UserNotFoundError); + }); + + it("throws RateLimitError when API returns 403", async () => { + let callCount = 0; + mockFetch.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url; + if (urlStr.includes("/graphql")) { + callCount++; + // Let statsPromise succeed so it doesn't cause unhandled rejection + if (callCount === 1) { + return Promise.resolve(jsonResponse({ data: { user: { contributionsCollection: {} } } })); + } + // Let reposResponse fail + if (callCount === 2) { + return Promise.resolve(jsonResponse(null, 403, { "X-RateLimit-Reset": "1700000000" })); + } + } + return Promise.resolve(jsonResponse([], 200)); + }); + + await expect(fetchYearInReviewData("testuser", 2024, "fake-token")).rejects.toThrow(RateLimitError); + }); + + it("throws GitHubApiError when API returns other errors", async () => { + let callCount = 0; + mockFetch.mockImplementation((url: string | URL | Request) => { + const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : (url as Request).url; + if (urlStr.includes("/graphql")) { + callCount++; + if (callCount === 1) { + return Promise.resolve(jsonResponse({ data: { user: { contributionsCollection: {} } } })); + } + if (callCount === 2) { + return Promise.resolve(jsonResponse(null, 500)); + } + } + return Promise.resolve(jsonResponse([], 200)); + }); + + await expect(fetchYearInReviewData("testuser", 2024, "fake-token")).rejects.toThrow(GitHubApiError); + }); });