import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/client/Api", () => ({ getApiBase: vi.fn(() => "http://localhost:3000"), })); vi.mock("../../../src/client/Auth", () => ({ getAuthHeader: vi.fn(async () => "Bearer test-token"), })); import { fetchClanDetail, fetchClanGames, fetchClanLeaderboard, fetchClanMembers, fetchClanRequests, fetchClans, } from "../../../src/client/ClanApi"; const okJson = (data: unknown, status = 200) => ({ ok: true, status, json: async () => data, }); const failRes = (status: number, data: unknown = {}) => ({ ok: false, status, json: async () => data, }); const mockFetch = (impl: (...args: unknown[]) => unknown) => vi.stubGlobal("fetch", vi.fn(impl)); beforeEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); }); describe("fetchClanLeaderboard", () => { const leaderboardData = { start: "2024-01-01T00:00:00.000Z", end: "2024-01-07T23:59:59.000Z", clans: [], }; it("returns parsed data on success", async () => { mockFetch(() => okJson(leaderboardData)); const result = await fetchClanLeaderboard(); expect(result).toEqual(leaderboardData); }); it("returns false on non-ok response", async () => { mockFetch(() => failRes(500)); const result = await fetchClanLeaderboard(); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("Network failure"))), ); const result = await fetchClanLeaderboard(); expect(result).toBe(false); }); it("returns false when Zod validation fails", async () => { mockFetch(() => okJson({ start: "bad-date", end: "bad-date", clans: [] })); const result = await fetchClanLeaderboard(); expect(result).toBe(false); }); }); describe("fetchClanDetail", () => { const clanInfo = { name: "Test Clan", tag: "TEST", description: "We test things", isOpen: false, createdAt: "2024-01-01T00:00:00.000Z", memberCount: 10, }; it("returns parsed data on success", async () => { mockFetch(() => okJson(clanInfo)); const result = await fetchClanDetail("TEST"); expect(result).toEqual(clanInfo); }); it("returns false on 404", async () => { mockFetch(() => failRes(404)); const result = await fetchClanDetail("TEST"); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("timeout"))), ); const result = await fetchClanDetail("TEST"); expect(result).toBe(false); }); it("returns false when Zod validation fails", async () => { mockFetch(() => okJson({ tag: 123, name: null, isOpen: "not-a-boolean" })); const result = await fetchClanDetail("TEST"); expect(result).toBe(false); }); }); describe("fetchClans", () => { const browseResponse = { results: [], total: 0, page: 1, limit: 20, }; it("passes page and limit as query params", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(browseResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClans(undefined, 3, 10); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("page")).toBe("3"); expect(url.searchParams.get("limit")).toBe("10"); }); it("passes search param when provided and long enough", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(browseResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClans("abc", 1, 20); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("search")).toBe("abc"); }); it("omits search param when too short and non-alphanumeric", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(browseResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClans("a", 1, 20); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.has("search")).toBe(false); }); it("returns false on failure", async () => { mockFetch(() => failRes(500)); const result = await fetchClans(); expect(result).toBe(false); }); it("returns false when Zod validation fails", async () => { mockFetch(() => okJson({ results: "not-an-array", total: "bad" })); const result = await fetchClans(); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("offline"))), ); const result = await fetchClans(); expect(result).toBe(false); }); }); describe("fetchClanMembers", () => { const membersResponse = { results: [ { publicId: "abc123", role: "leader", joinedAt: "2024-01-01T00:00:00.000Z", }, ], total: 1, page: 1, limit: 20, }; it("returns parsed data on success", async () => { mockFetch(() => okJson(membersResponse)); const result = await fetchClanMembers("TEST"); expect(result).toEqual(membersResponse); }); it("passes page and limit as query params", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(membersResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanMembers("TEST", 3, 50); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("page")).toBe("3"); expect(url.searchParams.get("limit")).toBe("50"); }); it("includes the optional pendingRequests field", async () => { mockFetch(() => okJson({ ...membersResponse, pendingRequests: 5 })); const result = await fetchClanMembers("TEST"); expect(result).not.toBe(false); if (result) expect(result.pendingRequests).toBe(5); }); it("returns false on non-ok response", async () => { mockFetch(() => failRes(500)); const result = await fetchClanMembers("TEST"); expect(result).toBe(false); }); it("returns false when Zod validation fails", async () => { mockFetch(() => okJson({ results: "not-array", total: "bad" })); const result = await fetchClanMembers("TEST"); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("offline"))), ); const result = await fetchClanMembers("TEST"); expect(result).toBe(false); }); it("sends Authorization header", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(membersResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanMembers("TEST"); const headers = fetchSpy.mock.calls[0]![1]?.headers as Record< string, string >; expect(headers.Authorization).toBe("Bearer test-token"); }); }); describe("fetchClanRequests", () => { const requestsResponse = { results: [ { publicId: "player1", createdAt: "2024-06-01T00:00:00.000Z", }, ], total: 1, page: 1, limit: 20, }; it("returns parsed data on success", async () => { mockFetch(() => okJson(requestsResponse)); const result = await fetchClanRequests("TEST"); expect(result).toEqual(requestsResponse); }); it("passes page and limit as query params", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(requestsResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanRequests("TEST", 2, 10); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("page")).toBe("2"); expect(url.searchParams.get("limit")).toBe("10"); }); it("returns false on non-ok response", async () => { mockFetch(() => failRes(403)); const result = await fetchClanRequests("TEST"); expect(result).toBe(false); }); it("returns false when Zod validation fails", async () => { mockFetch(() => okJson({ results: 42, total: "bad" })); const result = await fetchClanRequests("TEST"); expect(result).toBe(false); }); it("returns false on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("offline"))), ); const result = await fetchClanRequests("TEST"); expect(result).toBe(false); }); it("sends Authorization header", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(requestsResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanRequests("TEST"); const headers = fetchSpy.mock.calls[0]![1]?.headers as Record< string, string >; expect(headers.Authorization).toBe("Bearer test-token"); }); }); describe("fetchClanGames", () => { const gamesResponse = { results: [ { gameId: "g1", start: "2024-06-01T00:00:00.000Z", durationSeconds: 1234, map: "World", mode: "Team", playerTeams: "Duos", result: "victory", totalPlayers: 8, clanPlayers: [{ publicId: "p1", username: "alice", won: true }], }, ], nextCursor: "opaque-cursor-abc123", }; it("returns parsed data on success", async () => { mockFetch(() => okJson(gamesResponse)); const result = await fetchClanGames("TEST"); expect(result).toEqual(gamesResponse); }); it("accepts a null nextCursor (no more pages)", async () => { mockFetch(() => okJson({ ...gamesResponse, nextCursor: null })); const result = await fetchClanGames("TEST"); expect("error" in result).toBe(false); if (!("error" in result)) expect(result.nextCursor).toBeNull(); }); it("omits filter and cursor query params when not provided", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(gamesResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanGames("TEST"); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.has("filter")).toBe(false); expect(url.searchParams.has("cursor")).toBe(false); expect(url.pathname).toBe("/clans/TEST/games"); }); it("passes filter and cursor as query params", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(gamesResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanGames("TEST", { filter: "team", cursor: "opaque-cursor-abc123", }); const calledUrl = fetchSpy.mock.calls[0]![0] as string; const url = new URL(calledUrl); expect(url.searchParams.get("filter")).toBe("team"); expect(url.searchParams.get("cursor")).toBe("opaque-cursor-abc123"); }); it("URL-encodes the clan tag", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(gamesResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanGames("A/B"); const calledUrl = fetchSpy.mock.calls[0]![0] as string; // encodeURIComponent('/') === '%2F' expect(calledUrl).toContain("/clans/A%2FB/games"); }); it("returns { error: 'forbidden' } on 403", async () => { mockFetch(() => failRes(403)); const result = await fetchClanGames("TEST"); expect(result).toEqual({ error: "forbidden" }); }); it("returns { error: 'failed' } on other non-ok responses", async () => { mockFetch(() => failRes(500)); const result = await fetchClanGames("TEST"); expect(result).toEqual({ error: "failed" }); }); it("returns { error: 'failed' } when Zod validation fails", async () => { mockFetch(() => okJson({ results: "not-an-array", nextCursor: 42 })); const result = await fetchClanGames("TEST"); expect(result).toEqual({ error: "failed" }); }); it("returns { error: 'failed' } on network error", async () => { vi.stubGlobal( "fetch", vi.fn(() => Promise.reject(new Error("offline"))), ); const result = await fetchClanGames("TEST"); expect(result).toEqual({ error: "failed" }); }); it("sends Authorization header", async () => { const fetchSpy = vi.fn( (_input: string | URL | Request, _init?: RequestInit) => Promise.resolve(okJson(gamesResponse)), ); vi.stubGlobal("fetch", fetchSpy); await fetchClanGames("TEST"); const headers = fetchSpy.mock.calls[0]![1]?.headers as Record< string, string >; expect(headers.Authorization).toBe("Bearer test-token"); }); });