Files
kandev/apps/web/components/github/github-connection-dialog.test.tsx
T
2026-07-31 19:20:49 +01:00

315 lines
12 KiB
TypeScript

import { useState } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "@/components/toast-provider";
import type { TaskGitCredentialsState } from "@/hooks/domains/github/use-task-git-credentials";
import type { GitHubAppRegistrationCatalogItem, GitHubStatus } from "@/lib/types/github";
import { GitHubAppConnectionPanel } from "./github-app-connection-panel";
import { GitHubConnectionDialog } from "./github-connection-dialog";
const mocks = vi.hoisted(() => ({
mobile: false,
registrations: [] as GitHubAppRegistrationCatalogItem[],
setConnection: vi.fn(),
}));
const changeConnectionLabel = "Change connection";
const registrationDisplayName = "Work automation";
const githubAppLabel = "GitHub App";
const saveChangesLabel = "Save changes";
const WORKSPACE_ID = "workspace-1";
const DESKTOP_CONNECTION_TEST_ID = "github-connection-desktop";
const taskAccess: TaskGitCredentialsState = {
mode: "managed",
loading: false,
error: false,
save: vi.fn(),
};
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({ isMobile: mocks.mobile }),
}));
vi.mock("@/hooks/domains/github/use-github-app-registrations", () => ({
useGitHubAppRegistrations: (workspaceId: string) => ({
workspaceId,
catalog: { workspace_id: workspaceId, registrations: mocks.registrations },
registrations: mocks.registrations,
selected: mocks.registrations.find((item) => item.selected) ?? null,
loaded: true,
loading: false,
error: null,
mutating: false,
refresh: vi.fn(),
startManifest: vi.fn(),
prepareImport: vi.fn(),
importRegistration: vi.fn(),
rename: vi.fn(),
remove: vi.fn(),
startInstall: vi.fn(),
}),
}));
vi.mock("./github-app-import-form", () => ({
GitHubAppImportForm: ({ onImported }: { onImported: (registrationId: string) => void }) => (
<button onClick={() => onImported("registration-1")}>Complete test import</button>
),
}));
vi.mock("@/lib/api/domains/github-api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/domains/github-api")>()),
setGitHubWorkspaceConnection: mocks.setConnection,
}));
const status: GitHubStatus = {
workspace_id: WORKSPACE_ID,
automation: {
workspace_id: WORKSPACE_ID,
source: "pat",
github_host: "github.com",
login: "octocat",
status: "active",
credential_generation: 1,
},
personal: null,
authenticated: true,
username: "octocat",
auth_method: "pat",
token_configured: true,
required_scopes: [],
};
const registration: GitHubAppRegistrationCatalogItem = {
id: "registration-1",
source: "managed",
display_name: registrationDisplayName,
github_host: "github.com",
app_id: 42,
client_id: "Iv1.test",
slug: "work-automation",
owner_login: "acme",
owner_type: "Organization",
visibility: "private",
public_base_url: "https://kandev.example",
credential_generation: 1,
status: "active",
webhook_status: "verified",
created_at: "2026-07-21T00:00:00Z",
updated_at: "2026-07-21T00:00:00Z",
selected: true,
binding_count: 3,
workspace_binding_count: 2,
shared: true,
manifest_callback_url: "https://kandev.example/manifest/callback",
install_callback_url: "https://kandev.example/install/callback",
personal_callback_url: "https://kandev.example/personal/callback",
webhook_url: "https://kandev.example/webhook",
};
function view(workspaceId = WORKSPACE_ID, currentTaskAccess = taskAccess) {
const onSaved = vi.fn();
return render(
<ToastProvider>
<GitHubConnectionDialog
status={{ ...status, workspace_id: workspaceId }}
workspaceId={workspaceId}
onSaved={onSaved}
taskAccess={currentTaskAccess}
/>
</ToastProvider>,
);
}
function PartialSaveHarness() {
const [mode, setMode] = useState<TaskGitCredentialsState["mode"]>("managed");
const save = vi.fn(async (nextMode: TaskGitCredentialsState["mode"]) => {
setMode(nextMode);
return true;
});
return (
<ToastProvider>
<GitHubConnectionDialog
status={status}
workspaceId={WORKSPACE_ID}
onSaved={vi.fn()}
taskAccess={{ mode, loading: false, error: false, save }}
/>
</ToastProvider>
);
}
beforeEach(() => {
mocks.mobile = false;
mocks.registrations = [registration];
mocks.setConnection.mockReset();
mocks.setConnection.mockResolvedValue(status.automation);
});
describe("GitHubConnectionDialog task access", () => {
it("saves connection and task access drafts with one action", async () => {
const save = vi.fn().mockResolvedValue(true);
const rendered = view(WORKSPACE_ID, { ...taskAccess, save });
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID);
const executor = screen
.getByTestId("github-task-access-option-executor")
.querySelector('[role="radio"]')!;
fireEvent.click(executor);
fireEvent.change(screen.getByLabelText("Personal access token"), {
target: { value: "ghp_replacement" },
});
expect(screen.queryByRole("button", { name: "Connect token" })).toBeNull();
expect(screen.queryByRole("button", { name: "Save task access" })).toBeNull();
expect(screen.getAllByRole("button", { name: saveChangesLabel })).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: saveChangesLabel }));
await waitFor(() =>
expect(mocks.setConnection).toHaveBeenCalledWith(WORKSPACE_ID, {
source: "pat",
token: "ghp_replacement",
}),
);
expect(save).toHaveBeenCalledWith("executor");
await waitFor(() => expect(dialog.getAttribute("data-state")).toBe("closed"));
expect(rendered.baseElement.textContent).toContain("GitHub access settings saved");
});
it("does not report a discarded task access save as successful", async () => {
const save = vi.fn().mockResolvedValue(false);
view(WORKSPACE_ID, { ...taskAccess, save });
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
const dialog = await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID);
const executor = screen
.getByTestId("github-task-access-option-executor")
.querySelector('[role="radio"]')!;
fireEvent.click(executor);
fireEvent.click(screen.getByRole("button", { name: saveChangesLabel }));
await waitFor(() => expect(save).toHaveBeenCalledWith("executor"));
expect(screen.queryByText("GitHub access settings saved")).toBeNull();
expect(dialog.getAttribute("data-state")).toBe("open");
});
it("preserves a failed PAT draft when task access saves successfully", async () => {
mocks.setConnection.mockRejectedValue(new Error("Token rejected"));
render(<PartialSaveHarness />);
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
const token = screen.getByLabelText("Personal access token") as HTMLInputElement;
fireEvent.change(token, { target: { value: "ghp_retry_me" } });
fireEvent.click(
screen.getByTestId("github-task-access-option-executor").querySelector('[role="radio"]')!,
);
fireEvent.click(screen.getByRole("button", { name: saveChangesLabel }));
await screen.findByText(/Some settings were saved/);
expect(token.value).toBe("ghp_retry_me");
});
});
afterEach(() => cleanup());
describe("GitHubConnectionDialog", () => {
it("uses a method list and shows workspace App choices and sharing impact", async () => {
view();
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
expect(await screen.findByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeTruthy();
expect(screen.getByLabelText("Connection method")).toBeTruthy();
expect(screen.getAllByText("Personal access token")).not.toHaveLength(0);
expect(screen.getByText("GitHub CLI account")).toBeTruthy();
fireEvent.click(screen.getByText(githubAppLabel, { selector: "span" }));
expect(await screen.findByText(registrationDisplayName)).toBeTruthy();
expect(screen.getByText(/Used by 2 workspaces/)).toBeTruthy();
expect(screen.getByRole("button", { name: /Install for this workspace/ })).toBeTruthy();
expect(screen.getByRole("button", { name: "Add existing App" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Create new App" })).toBeTruthy();
expect(screen.queryByTestId("github-app-system-setup-link")).toBeNull();
});
it("renders one bounded mobile drawer with a single scroll body", async () => {
mocks.mobile = true;
view();
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
const drawer = await screen.findByTestId("github-connection-mobile");
expect(drawer.className).toContain("100dvh");
expect(drawer.querySelectorAll(".overflow-y-auto")).toHaveLength(1);
expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull();
});
it("returns to the registration catalog after importing an App", async () => {
view();
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" }));
fireEvent.click(screen.getByRole("button", { name: "Add existing App" }));
fireEvent.click(screen.getByRole("button", { name: "Complete test import" }));
expect(await screen.findByText(registrationDisplayName)).toBeTruthy();
const installButton = screen.getByRole("button", { name: /Install for this workspace/ });
expect((installButton as HTMLButtonElement).disabled).toBe(false);
});
it("does not install invalid App registrations", async () => {
mocks.registrations = [
{
...registration,
status: "invalid",
last_error: "Credentials could not be restored.",
},
];
view();
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" }));
expect(await screen.findByText("Needs attention")).toBeTruthy();
expect(screen.getByText("Credentials could not be restored.")).toBeTruthy();
expect(
(screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("clears a selected App when it disappears from the catalog", async () => {
const rendered = render(
<ToastProvider>
<GitHubAppConnectionPanel workspaceId="workspace-1" />
</ToastProvider>,
);
expect(
(screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement)
.disabled,
).toBe(false);
mocks.registrations = [];
rendered.rerender(
<ToastProvider>
<GitHubAppConnectionPanel workspaceId="workspace-1" />
</ToastProvider>,
);
await waitFor(() =>
expect(
(screen.getByRole("button", { name: /Install for this workspace/ }) as HTMLButtonElement)
.disabled,
).toBe(true),
);
});
it("closes and resets local setup state when the workspace changes", async () => {
const rendered = view();
fireEvent.click(screen.getByRole("button", { name: changeConnectionLabel }));
fireEvent.click(await screen.findByText(githubAppLabel, { selector: "span" }));
expect(await screen.findByText(registrationDisplayName)).toBeTruthy();
rendered.rerender(
<ToastProvider>
<GitHubConnectionDialog
status={{ ...status, workspace_id: "workspace-2" }}
workspaceId="workspace-2"
onSaved={vi.fn()}
taskAccess={taskAccess}
/>
</ToastProvider>,
);
await waitFor(() => expect(screen.queryByTestId(DESKTOP_CONNECTION_TEST_ID)).toBeNull());
});
});