171 lines
5.0 KiB
TypeScript
171 lines
5.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("@/lib/config", () => ({
|
|
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
|
|
}));
|
|
|
|
import { fetchAccessibleRepos, GitHubUnavailableError, type AccessibleRepo } from "./github-api";
|
|
|
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
type FetchInit = Parameters<typeof fetch>[1];
|
|
|
|
const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise<Response>>();
|
|
|
|
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("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();
|
|
});
|
|
});
|