import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), })); import { fetchAccessibleRepos, fetchIssueInfo, getTaskCIAutomationOptions, GitHubUnavailableError, updateTaskCIAutomationOptions, type AccessibleRepo, } from "./github-api"; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise>(); beforeEach(() => { fetchSpy.mockReset(); vi.stubGlobal("fetch", fetchSpy); }); afterEach(() => { vi.unstubAllGlobals(); }); function jsonResponse(body: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" }, ...init, }); } function lastCallUrl(): string { const call = fetchSpy.mock.calls.at(-1); if (!call) throw new Error("expected fetch to have been called"); return String(call[0]); } describe("fetchAccessibleRepos — URL & parsing", () => { it("builds the correct URL with both q and limit", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] })); await fetchAccessibleRepos({ q: "next", limit: 25 }); const url = lastCallUrl(); expect(url).toContain("/api/v1/github/repos"); expect(url).toContain("q=next"); expect(url).toContain("limit=25"); }); it("omits empty query and missing limit from the URL", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] })); await fetchAccessibleRepos({}); const url = lastCallUrl(); expect(url).toContain("/api/v1/github/repos"); expect(url).not.toContain("q="); expect(url).not.toContain("limit="); }); it("parses the 200 response and injects provider: 'github' on each entry", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ repos: [ { full_name: "kdlbs/kandev", owner: "kdlbs", name: "kandev", private: false, default_branch: "main", description: "Kandev mainline", pushed_at: "2026-05-20T10:00:00Z", }, { full_name: "acme/site", owner: "acme", name: "site", private: true, default_branch: "trunk", }, ], }), ); const repos: AccessibleRepo[] = await fetchAccessibleRepos({}); expect(repos).toHaveLength(2); expect(repos[0]).toMatchObject({ provider: "github", full_name: "kdlbs/kandev", owner: "kdlbs", name: "kandev", private: false, default_branch: "main", description: "Kandev mainline", pushed_at: "2026-05-20T10:00:00Z", }); expect(repos[1]).toMatchObject({ provider: "github", full_name: "acme/site", owner: "acme", name: "site", private: true, default_branch: "trunk", }); expect(repos[1].pushed_at).toBeUndefined(); expect(repos[1].description).toBeUndefined(); }); }); describe("fetchIssueInfo", () => { it("builds the encoded issue info endpoint and forwards options", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 1456, title: "Fix picker" })); await fetchIssueInfo("acme org", "site/repo", 1456, { cache: "no-store" }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/issues/acme%20org/site%2Frepo/1456/info", ); expect(call?.[1]).toMatchObject({ cache: "no-store" }); }); }); describe("fetchAccessibleRepos — errors & signal", () => { it("throws GitHubUnavailableError on 503 with code: github_not_configured", async () => { fetchSpy.mockResolvedValueOnce( new Response( JSON.stringify({ error: "GitHub is not configured.", code: "github_not_configured", }), { status: 503, headers: { "Content-Type": "application/json" } }, ), ); await expect(fetchAccessibleRepos({})).rejects.toBeInstanceOf(GitHubUnavailableError); }); it("throws a plain Error (not GitHubUnavailableError) on 503 without the github_not_configured code", async () => { fetchSpy.mockResolvedValueOnce( new Response(JSON.stringify({ error: "transient outage" }), { status: 503, headers: { "Content-Type": "application/json" }, }), ); const err = await fetchAccessibleRepos({}).catch((e) => e); expect(err).toBeInstanceOf(Error); expect(err).not.toBeInstanceOf(GitHubUnavailableError); }); it("throws a plain Error on 500", async () => { fetchSpy.mockResolvedValueOnce( new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" }, }), ); const err = await fetchAccessibleRepos({}).catch((e) => e); expect(err).toBeInstanceOf(Error); expect(err).not.toBeInstanceOf(GitHubUnavailableError); }); it("forwards AbortSignal: aborting causes the promise to reject", async () => { const controller = new AbortController(); fetchSpy.mockImplementationOnce((_input, init) => { return new Promise((_resolve, reject) => { const signal = init?.signal; if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; } signal?.addEventListener("abort", () => { reject(new DOMException("Aborted", "AbortError")); }); }); }); const promise = fetchAccessibleRepos({ signal: controller.signal }); controller.abort(); await expect(promise).rejects.toThrow(); }); }); describe("task CI automation options", () => { it("fetches options for a task", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ task_id: "task-1", auto_fix_enabled: true, auto_merge_enabled: false, auto_fix_prompt_override: null, effective_auto_fix_prompt: "Fix CI.", using_default_prompt: true, updated_at: "2026-06-18T10:00:00Z", pr_states: [], }), ); const response = await getTaskCIAutomationOptions("task-1"); expect(lastCallUrl()).toBe("http://api.test/api/v1/github/tasks/task-1/ci-options"); expect(response.auto_fix_enabled).toBe(true); expect(response.using_default_prompt).toBe(true); }); it("patches task options and allows clearing the prompt override", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ task_id: "task-1", auto_fix_enabled: false, auto_merge_enabled: true, auto_fix_prompt_override: null, effective_auto_fix_prompt: "Default prompt", using_default_prompt: true, updated_at: "2026-06-18T10:01:00Z", pr_states: [], }), ); await updateTaskCIAutomationOptions("task-1", { auto_fix_enabled: false, auto_merge_enabled: true, auto_fix_prompt_override: null, }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/ci-options"); expect(call?.[1]?.method).toBe("PATCH"); expect(JSON.parse(String(call?.[1]?.body))).toEqual({ auto_fix_enabled: false, auto_merge_enabled: true, auto_fix_prompt_override: null, }); }); });