Files
fusion/packages/dashboard/app/hooks/__tests__/useSetupReadiness.test.ts
Fusion 1015365ca5 feat(FN-2189): include gh CLI auth in setup readiness
- Extend auth status API typing to include optional ghCli availability/authentication metadata.
- Update /api/auth/status to return ghCli readiness using isGhAvailable() and isGhAuthenticated().
- Treat setup GitHub readiness as satisfied when either GitHub OAuth or authenticated gh CLI is present.
- Add hook tests covering gh CLI authenticated, unauthenticated, combined OAuth, and missing-ghCli fallback cases.
2026-04-20 10:16:13 -07:00

213 lines
7.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useSetupReadiness, __test_clearCache } from "../useSetupReadiness";
import * as api from "../../api";
import type { AuthProvider } from "../../api";
vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(),
}));
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
function makeProvider(
id: string,
authenticated: boolean,
name = id,
type: AuthProvider["type"] = "oauth",
): AuthProvider {
return {
id,
name,
authenticated,
type,
};
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("useSetupReadiness", () => {
beforeEach(() => {
vi.clearAllMocks();
__test_clearCache();
});
it("returns hasAiProvider=true when at least one non-GitHub provider is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", false)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasAiProvider).toBe(true);
});
it("returns hasAiProvider=false when no non-GitHub providers are authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", false), makeProvider("github", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasAiProvider).toBe(false);
});
it("returns hasGithub=true when GitHub provider is authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(true);
});
it("returns hasGithub=true when gh CLI is authenticated but GitHub OAuth is not", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
ghCli: { available: true, authenticated: true },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(true);
expect(result.current.hasWarnings).toBe(false);
});
it("returns hasGithub=true when both gh CLI and GitHub OAuth are authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
ghCli: { available: true, authenticated: true },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(true);
});
it("returns hasGithub=false when gh CLI is available but not authenticated and GitHub OAuth is not connected", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
ghCli: { available: true, authenticated: false },
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(false);
});
it("returns hasGithub=false when ghCli is absent from response", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasGithub).toBe(false);
});
it("returns hasGithub=false when GitHub is missing or not authenticated", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true)],
});
const { result: missingGithub } = renderHook(() => useSetupReadiness("project-a"));
await waitFor(() => expect(missingGithub.current.loading).toBe(false));
expect(missingGithub.current.hasGithub).toBe(false);
__test_clearCache();
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("github", false)],
});
const { result: unauthenticatedGithub } = renderHook(() => useSetupReadiness("project-b"));
await waitFor(() => expect(unauthenticatedGithub.current.loading).toBe(false));
expect(unauthenticatedGithub.current.hasGithub).toBe(false);
});
it("returns hasWarnings=true when AI provider is missing", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("github", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasWarnings).toBe(true);
});
it("returns hasWarnings=true when GitHub is missing", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", false)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasWarnings).toBe(true);
});
it("returns hasWarnings=false when both AI provider and GitHub are connected", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
});
const { result } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasWarnings).toBe(false);
});
it("returns loading=true during initial fetch and false after completion", async () => {
const pending = deferred<{ providers: AuthProvider[] }>();
mockFetchAuthStatus.mockReturnValueOnce(pending.promise);
const { result } = renderHook(() => useSetupReadiness());
expect(result.current.loading).toBe(true);
await act(async () => {
pending.resolve({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
});
});
await waitFor(() => expect(result.current.loading).toBe(false));
});
it("cache prevents duplicate fetches across multiple consumers", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [makeProvider("anthropic", true), makeProvider("github", true)],
});
const { result: first } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(first.current.loading).toBe(false));
const { result: second } = renderHook(() => useSetupReadiness());
await waitFor(() => expect(second.current.loading).toBe(false));
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
});
});