import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import React from "react";
import { Team } from "@/types";
import type { Project } from "@/types";
import { toast } from "sonner";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// Radix Select's SelectValue sizing hook calls ResizeObserver, absent in
// jsdom — mirrors select-repo-picker.test.tsx's functional replacement.
vi.mock("@/components/ui/select", () => {
const Ctx = React.createContext<(v: string) => void>(() => {});
return {
Select: ({
onValueChange,
children,
}: {
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
{})}>
{children}
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
{children}
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
{children}
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const onValueChange = React.useContext(Ctx);
return (
);
},
};
});
const { useUpdateProject, mutateAsync } = vi.hoisted(() => ({
useUpdateProject: vi.fn(),
mutateAsync: vi.fn(),
}));
vi.mock("@/hooks/use-projects", () => ({ useUpdateProject }));
const {
getCredentialsStatus,
listInstallations,
listInstallationRepositories,
} = vi.hoisted(() => ({
getCredentialsStatus: vi.fn(async () => ({ has_credentials: true })),
listInstallations: vi.fn(async () => [{ id: 42, account_login: "acme" }]),
listInstallationRepositories: vi.fn(async () => [
{
full_name: "acme/widgets",
clone_url: "https://github.com/acme/widgets.git",
private: false,
},
]),
}));
vi.mock("@/lib/api", () => ({
githubAppApi: {
getCredentialsStatus,
listInstallations,
listInstallationRepositories,
},
}));
import { IdentityCard } from "../identity-card";
function makeProject(overrides: Partial = {}): Project {
return {
id: "proj-1",
name: "RoboCo API",
slug: "roboco-api",
git_url: "https://github.com/org/repo.git",
git_provider: "github",
github_installation_id: null,
default_branch: "main",
environments: null,
protected_branches: ["main"],
assigned_cell: Team.BACKEND,
has_git_token: true,
is_active: true,
test_command: null,
lint_command: null,
format_command: null,
typecheck_command: null,
build_command: null,
quality_command: null,
codegen_command: null,
ci_watch_enabled: false,
ci_watch_workflow: null,
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
created_by: "ceo",
created_at: "2026-01-01T00:00:00Z",
updated_at: null,
...overrides,
};
}
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return {ui};
}
function renderCard(project: Project) {
return render(withQueryClient());
}
describe("IdentityCard", () => {
beforeEach(() => {
vi.clearAllMocks();
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
});
it("renders the slug (read-only), name, and git URL fields", () => {
renderCard(makeProject());
expect(screen.getByLabelText("Slug")).toHaveValue("roboco-api");
expect(screen.getByLabelText("Slug")).toBeDisabled();
expect(screen.getByLabelText(/Project Name/i)).toHaveValue("RoboCo API");
expect(screen.getByLabelText(/Git URL/i)).toHaveValue(
"https://github.com/org/repo.git",
);
});
it("Save is disabled until a field is edited, then enables", () => {
renderCard(makeProject());
const save = screen.getByRole("button", { name: /^Save$/i });
expect(save).toBeDisabled();
fireEvent.change(screen.getByLabelText(/Project Name/i), {
target: { value: "Renamed" },
});
expect(save).not.toBeDisabled();
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
});
it("saves the edited name and git URL, sending an explicit installation id", async () => {
renderCard(makeProject());
fireEvent.change(screen.getByLabelText(/Project Name/i), {
target: { value: "Renamed API" },
});
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
projectId: string;
updates: Record;
};
expect(call.projectId).toBe("proj-1");
expect(call.updates).toEqual({
name: "Renamed API",
git_url: "https://github.com/org/repo.git",
git_provider: "github",
github_installation_id: null,
});
});
it("rejects an empty name or git URL without calling the mutation", () => {
renderCard(makeProject());
fireEvent.change(screen.getByLabelText(/Project Name/i), {
target: { value: "" },
});
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
expect(toast.error).toHaveBeenCalledWith("Name and Git URL are required");
expect(mutateAsync).not.toHaveBeenCalled();
});
it("unbinding the GitHub App marks the card dirty and sends an explicit null", async () => {
renderCard(makeProject({ github_installation_id: 42 }));
fireEvent.click(await screen.findByRole("button", { name: /Unbind/i }));
expect(screen.getByRole("button", { name: /^Save$/i })).not.toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
await vi.waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { github_installation_id?: number | null };
};
expect(call.updates.github_installation_id).toBeNull();
});
it("warns when unbinding would leave the project with no credentials at all", async () => {
renderCard(
makeProject({ github_installation_id: 42, has_git_token: false }),
);
expect(
screen.queryByText(/no GitHub App binding/i),
).not.toBeInTheDocument();
fireEvent.click(await screen.findByRole("button", { name: /Unbind/i }));
expect(screen.getByText(/no GitHub App binding/i)).toBeInTheDocument();
});
});