import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), })); import { clearGitHubToken, configureGitHubToken, copyGitHubWorkspaceSettings, createTaskPR, deleteTaskPR, deletePRWatch, deleteGitHubAppRegistration, disconnectGitHubPersonal, disconnectGitHubWorkspace, fetchAccessibleRepos, fetchGitHubCLIAccounts, fetchGitHubAppRegistrations, fetchGitHubStatus, fetchIssueInfo, fetchPRInfo, fetchRepoBranches, getPRFeedback, getPRStatus, getPRStatusesBatch, getRepoMergeMethods, getTaskCIAutomationOptions, GitHubUnavailableError, importGitHubAppRegistration, linkTaskIssue, listUserOrgs, listIssueWatches, listPRWatches, listReviewWatches, listWorkspaceTaskIssues, mergePR, prepareGitHubAppImport, renameGitHubAppRegistration, searchOrgRepos, setGitHubWorkspaceConnection, startGitHubAppInstall, startGitHubAppManifest, startGitHubPersonalConnect, submitPRReview, unlinkTaskIssue, updateTaskCIAutomationOptions, type AccessibleRepo, } from "./github-api"; import * as githubAuthApi from "./github-auth-api"; const workspaceID = "ws/1"; const appRegistrationID = "registration/one"; const publicKandevURL = "https://kandev.example"; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; const fetchSpy = vi.fn<(...args: [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("workspace GitHub authentication", () => { it("scopes status reads to the requested workspace", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ workspace_id: "workspace/one", automation: null, personal: null }), ); await fetchGitHubStatus("workspace/one", { cache: "no-store" }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/status?workspace_id=workspace%2Fone", ); expect(call?.[1]).toMatchObject({ cache: "no-store" }); }); it("selects an exact named CLI account", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "workspace one" })); await setGitHubWorkspaceConnection("workspace one", { source: "gh_cli", host: "github.example.com", login: "alice", }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/workspace-connection?workspace_id=workspace+one", ); expect(call?.[1]?.method).toBe("PUT"); expect(JSON.parse(String(call?.[1]?.body))).toEqual({ source: "gh_cli", host: "github.example.com", login: "alice", }); }); it("lists all CLI accounts without collapsing hosts", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ accounts: [ { host: "github.com", login: "alice", active: true, state: "active" }, { host: "github.example.com", login: "alice", active: false, state: "inactive" }, ], }), ); await expect(fetchGitHubCLIAccounts(workspaceID)).resolves.toHaveLength(2); expect(lastCallUrl()).toBe( "http://api.test/api/v1/github/auth/gh-cli/accounts?workspace_id=ws%2F1", ); }); it("uses workspace-scoped App and personal lifecycle routes", async () => { fetchSpy .mockResolvedValueOnce( jsonResponse({ URL: "https://github.com/apps/kandev/installations/new" }), ) .mockResolvedValueOnce(jsonResponse({ url: "https://github.com/login/oauth/authorize" })) .mockResolvedValueOnce(jsonResponse({ disconnected: true })) .mockResolvedValueOnce(jsonResponse({ disconnected: true })) .mockResolvedValueOnce(jsonResponse({ disconnected: true })); await startGitHubAppInstall(workspaceID, appRegistrationID); await startGitHubPersonalConnect(workspaceID); await disconnectGitHubPersonal(workspaceID); await disconnectGitHubWorkspace(workspaceID); expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([ ["http://api.test/api/v1/github/app/install/start", "POST"], ["http://api.test/api/v1/github/personal-connection/start", "POST"], ["http://api.test/api/v1/github/personal-connection?workspace_id=ws%2F1", "DELETE"], ["http://api.test/api/v1/github/workspace-connection?workspace_id=ws%2F1", "DELETE"], ]); expect(JSON.parse(String(fetchSpy.mock.calls[0]?.[1]?.body))).toEqual({ workspace_id: workspaceID, app_registration_id: appRegistrationID, }); expect(JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body))).toEqual({ workspace_id: workspaceID, }); }); it("keeps compatibility token mutations workspace-scoped", async () => { fetchSpy .mockResolvedValueOnce(jsonResponse({ configured: true })) .mockResolvedValueOnce(jsonResponse({ cleared: true })); await configureGitHubToken(workspaceID, "github_pat_test"); await clearGitHubToken(workspaceID); expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([ ["http://api.test/api/v1/github/token?workspace_id=ws%2F1", "POST"], ["http://api.test/api/v1/github/token?workspace_id=ws%2F1", "DELETE"], ]); }); }); describe("workspace-scoped GitHub watches", () => { it("includes the workspace in PR and issue watch list and delete requests", async () => { fetchSpy .mockResolvedValueOnce(jsonResponse({ watches: [] })) .mockResolvedValueOnce(jsonResponse({ success: true })) .mockResolvedValueOnce(jsonResponse({ watches: [] })); await listPRWatches(workspaceID); await deletePRWatch("watch/1", workspaceID); await listIssueWatches(workspaceID); expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([ ["http://api.test/api/v1/github/watches/pr?workspace_id=ws%2F1", undefined], ["http://api.test/api/v1/github/watches/pr/watch/1?workspace_id=ws%2F1", "DELETE"], ["http://api.test/api/v1/github/watches/issue?workspace_id=ws%2F1", undefined], ]); }); it("includes the workspace in review watch list requests", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ watches: [] })); await listReviewWatches(workspaceID); expect(lastCallUrl()).toBe("http://api.test/api/v1/github/watches/review?workspace_id=ws%2F1"); }); }); describe("workspace GitHub App registrations", () => { it("exposes the registration catalog API", () => { expect(githubAuthApi).toMatchObject({ fetchGitHubAppRegistrations: expect.any(Function), startGitHubAppManifest: expect.any(Function), prepareGitHubAppImport: expect.any(Function), importGitHubAppRegistration: expect.any(Function), renameGitHubAppRegistration: expect.any(Function), deleteGitHubAppRegistration: expect.any(Function), }); }); it("uses workspace-scoped catalog and registration lifecycle routes", async () => { fetchSpy .mockResolvedValueOnce(jsonResponse({ workspace_id: workspaceID, registrations: [] })) .mockResolvedValueOnce( jsonResponse({ registration_id: appRegistrationID, workspace_id: workspaceID, state: "state", expires_at: "2026-07-20T22:00:00Z", revision: 1, registration_url: "https://github.com/organizations/acme/settings/apps/new", manifest: { name: "Kandev acme" }, }), ) .mockResolvedValueOnce( jsonResponse({ registration_id: appRegistrationID, public_base_url: publicKandevURL, webhook_url: "https://kandev.example/webhook", }), ) .mockResolvedValueOnce(jsonResponse({ id: appRegistrationID })) .mockResolvedValueOnce(jsonResponse({ id: appRegistrationID, display_name: "Renamed" })) .mockResolvedValueOnce(jsonResponse({ deleted: true })); await fetchGitHubAppRegistrations(workspaceID); await startGitHubAppManifest({ workspace_id: workspaceID, display_name: "Work App", owner_type: "organization", owner_login: "acme", visibility: "private", public_base_url: publicKandevURL, }); await prepareGitHubAppImport({ workspace_id: workspaceID, public_base_url: publicKandevURL, }); await importGitHubAppRegistration({ registration_id: "registration-1", workspace_id: workspaceID, display_name: "Existing App", github_host: "github.com", app_id: 42, client_id: "Iv1.client", client_secret: "client-secret", private_key: "private-key", webhook_secret: "webhook-secret", slug: "existing-app", owner_login: "acme", owner_type: "Organization", visibility: "private", public_base_url: publicKandevURL, }); await renameGitHubAppRegistration(appRegistrationID, "Renamed"); await deleteGitHubAppRegistration(appRegistrationID); expect(fetchSpy.mock.calls.map((call) => [String(call[0]), call[1]?.method])).toEqual([ ["http://api.test/api/v1/github/app/registrations?workspace_id=ws%2F1", undefined], ["http://api.test/api/v1/github/app/registrations/manifest/start", "POST"], ["http://api.test/api/v1/github/app/registrations/import/prepare", "POST"], ["http://api.test/api/v1/github/app/registrations/import", "POST"], ["http://api.test/api/v1/github/app/registrations/registration%2Fone", "PATCH"], ["http://api.test/api/v1/github/app/registrations/registration%2Fone", "DELETE"], ]); expect(fetchSpy.mock.calls[0]?.[1]?.cache).toBe("no-store"); expect(JSON.parse(String(fetchSpy.mock.calls[1]?.[1]?.body))).toEqual({ workspace_id: workspaceID, display_name: "Work App", owner_type: "organization", owner_login: "acme", visibility: "private", public_base_url: publicKandevURL, }); expect(JSON.parse(String(fetchSpy.mock.calls[2]?.[1]?.body))).toEqual({ workspace_id: workspaceID, public_base_url: publicKandevURL, }); expect(JSON.parse(String(fetchSpy.mock.calls[4]?.[1]?.body))).toEqual({ display_name: "Renamed", }); }); }); describe("workspace-scoped GitHub operations", () => { it("scopes PR reads, review, merge, and repository metadata", async () => { fetchSpy.mockImplementation(async () => jsonResponse({})); await getPRFeedback(workspaceID, "acme", "site", 42); await getPRStatus(workspaceID, "acme", "site", 42); await getPRStatusesBatch(workspaceID, [{ owner: "acme", repo: "site", number: 42 }]); await submitPRReview(workspaceID, { owner: "acme", repo: "site", number: 42 }, "APPROVE"); await mergePR(workspaceID, "acme", "site", 42, "squash"); await getRepoMergeMethods(workspaceID, "acme", "site"); await listUserOrgs(workspaceID); await searchOrgRepos(workspaceID, "acme", "site"); expect(fetchSpy.mock.calls.map((call) => String(call[0]))).toEqual([ "http://api.test/api/v1/github/prs/acme/site/42?workspace_id=ws%2F1", "http://api.test/api/v1/github/prs/acme/site/42/status?workspace_id=ws%2F1", "http://api.test/api/v1/github/prs/statuses", "http://api.test/api/v1/github/prs/acme/site/42/reviews?workspace_id=ws%2F1", "http://api.test/api/v1/github/prs/acme/site/42/merge?workspace_id=ws%2F1", "http://api.test/api/v1/github/repos/acme/site/merge-methods?workspace_id=ws%2F1", "http://api.test/api/v1/github/orgs?workspace_id=ws%2F1", "http://api.test/api/v1/github/repos/search?workspace_id=ws%2F1&org=acme&q=site", ]); expect(JSON.parse(String(fetchSpy.mock.calls[2]?.[1]?.body))).toEqual({ workspace_id: workspaceID, refs: [{ owner: "acme", repo: "site", number: 42 }], }); }); }); describe("remote repository reads", () => { it("retries a transient network failure when loading branches", async () => { fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed")); fetchSpy.mockResolvedValueOnce(jsonResponse({ branches: [{ name: "main" }] })); await expect(fetchRepoBranches(workspaceID, "acme", "site")).resolves.toEqual({ branches: [{ name: "main" }], }); expect(fetchSpy).toHaveBeenCalledTimes(2); }); it("retries a transient network failure when loading PR title metadata", async () => { fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed")); fetchSpy.mockResolvedValueOnce(jsonResponse({ number: 42, title: "Recovered" })); await expect(fetchPRInfo(workspaceID, "acme", "site", 42)).resolves.toMatchObject({ number: 42, title: "Recovered", }); expect(fetchSpy).toHaveBeenCalledTimes(2); }); }); describe("fetchAccessibleRepos — URL & parsing", () => { it("builds the correct URL with both q and limit", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] })); await fetchAccessibleRepos({ workspaceId: workspaceID, q: "next", limit: 25 }); const url = lastCallUrl(); expect(url).toContain("/api/v1/github/repos"); expect(url).toContain("q=next"); expect(url).toContain("limit=25"); expect(url).toContain("workspace_id=ws%2F1"); }); it("omits empty query and missing limit from the URL", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ repos: [] })); await fetchAccessibleRepos({ workspaceId: workspaceID }); 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({ workspaceId: workspaceID }); 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("workspace/1", "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?workspace_id=workspace%2F1", ); expect(call?.[1]).toMatchObject({ cache: "no-store" }); }); }); describe("task issue link helpers", () => { it("lists task issue links for a workspace", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ task_issues: {} })); await listWorkspaceTaskIssues("workspace/1", { cache: "no-store" }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/task-issues?workspace_id=workspace%2F1", ); expect(call?.[1]).toMatchObject({ cache: "no-store" }); }); it("links a GitHub pull request to a task", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ id: "task-pr-1", task_id: "task-1", repository_id: "task-repo-1", owner: "kdlbs", repo: "kandev", pr_number: 1471, pr_url: "https://github.com/kdlbs/kandev/pull/1471", pr_title: "Link references", }), ); await createTaskPR({ workspace_id: "workspace-1", task_id: "task-1", repository_id: "task-repo-1", pr_url: "https://github.com/kdlbs/kandev/pull/1471", }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe("http://api.test/api/v1/github/task-prs"); expect(call?.[1]?.method).toBe("POST"); expect(JSON.parse(String(call?.[1]?.body))).toEqual({ workspace_id: "workspace-1", task_id: "task-1", repository_id: "task-repo-1", pr_url: "https://github.com/kdlbs/kandev/pull/1471", }); }); it("links a GitHub issue to a task", async () => { fetchSpy.mockResolvedValueOnce( jsonResponse({ task_id: "task-1", owner: "kdlbs", repo: "kandev", issue_number: 1470, issue_url: "https://github.com/kdlbs/kandev/issues/1470", issue_title: "Link issue", }), ); await linkTaskIssue("task-1", { issue: "#1470", owner: "kdlbs", repo: "kandev", number: 1470, }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue"); expect(call?.[1]?.method).toBe("PUT"); expect(JSON.parse(String(call?.[1]?.body))).toEqual({ issue: "#1470", owner: "kdlbs", repo: "kandev", number: 1470, }); }); it("unlinks a GitHub issue from a task", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ unlinked: true })); await unlinkTaskIssue("task-1"); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe("http://api.test/api/v1/github/tasks/task-1/issue"); expect(call?.[1]?.method).toBe("DELETE"); }); }); describe("task PR association helpers", () => { it("deletes a task PR association scoped to the workspace", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ deleted: true })); await deleteTaskPR("association/1", "workspace/1"); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/task-prs/association%2F1?workspace_id=workspace%2F1", ); expect(call?.[1]?.method).toBe("DELETE"); }); }); 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({ workspaceId: "ws-1" })).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({ workspaceId: "ws-1" }).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({ workspaceId: "ws-1" }).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({ workspaceId: "ws-1", 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, }); }); }); describe("copyGitHubWorkspaceSettings", () => { it("POSTs targetWorkspaceId to /workspace-settings/copy scoped to the source", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ workspace_id: "ws-dst" })); await copyGitHubWorkspaceSettings("ws-dst", { workspaceId: "ws-src" }); const call = fetchSpy.mock.calls.at(-1); expect(String(call?.[0])).toBe( "http://api.test/api/v1/github/workspace-settings/copy?workspace_id=ws-src", ); expect(call?.[1]?.method).toBe("POST"); expect(JSON.parse(String(call?.[1]?.body))).toEqual({ targetWorkspaceId: "ws-dst" }); }); });