Files
kandev/apps/web/components/gitlab/gitlab-settings.test.tsx
T
2026-09-22 21:24:43 +01:00

379 lines
13 KiB
TypeScript

import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
import { GitLabIntegrationPage } from "./gitlab-settings";
import type { GitLabActionPresets, ReviewWatch } from "@/lib/types/gitlab";
const fetchGitLabStatusMock = vi.fn();
const setGitLabConfigMock = vi.fn();
const resetActionPresetsMock = vi.fn();
const updateActionPresetsMock = vi.fn();
const deleteReviewWatchMock = vi.fn();
const workspaceId = "workspace-1";
const defaultHost = "https://gitlab.com";
const credentialsContributorId = "gitlab-credentials";
const appState = {
prompts: { items: [], loaded: true, loading: false },
gitlabReviewWatches: { items: [] as ReviewWatch[], loaded: false, loading: false },
gitlabIssueWatches: { items: [], loaded: false, loading: false },
gitlabActionPresets: {
byWorkspaceId: {} as Record<string, GitLabActionPresets>,
loading: false,
},
setGitLabReviewWatches: vi.fn(),
setGitLabReviewWatchesLoading: vi.fn(),
addGitLabReviewWatch: vi.fn(),
updateGitLabReviewWatchInStore: vi.fn(),
removeGitLabReviewWatch: vi.fn(),
setGitLabIssueWatches: vi.fn(),
setGitLabIssueWatchesLoading: vi.fn(),
addGitLabIssueWatch: vi.fn(),
updateGitLabIssueWatchInStore: vi.fn(),
removeGitLabIssueWatch: vi.fn(),
setGitLabActionPresets: vi.fn(),
setGitLabActionPresetsLoading: vi.fn(),
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (value: typeof appState) => unknown) => selector(appState),
}));
vi.mock("@/components/integrations/workspace-scoped-section", () => ({
WorkspaceScopedSection: ({ children }: { children: (workspaceId: string) => React.ReactNode }) =>
children(workspaceId),
}));
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: vi.fn() }),
}));
vi.mock("@/components/settings/settings-save-provider", () => ({
useSettingsSaveContributor: vi.fn(),
SettingsSaveDirtyScope: ({ children }: { children: (isDirty: boolean) => React.ReactNode }) =>
children(false),
}));
vi.mock("@/components/settings/settings-prompt-editor", () => ({
SettingsPromptEditor: ({
value,
onChange,
ariaLabel,
testId,
}: {
value: string;
onChange: (value: string) => void;
ariaLabel?: string;
testId?: string;
}) => (
<textarea
aria-label={ariaLabel}
data-testid={testId}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("@/lib/api/domains/gitlab-api", () => ({
clearGitLabToken: vi.fn(),
configureGitLabHost: vi.fn(),
setGitLabConfig: (...args: unknown[]) => setGitLabConfigMock(...args),
fetchGitLabStatus: (...args: unknown[]) => fetchGitLabStatusMock(...args),
listReviewWatches: vi.fn().mockResolvedValue({ watches: [] }),
listIssueWatches: vi.fn().mockResolvedValue({ watches: [] }),
createReviewWatch: vi.fn(),
updateReviewWatch: vi.fn(),
deleteReviewWatch: (...args: unknown[]) => deleteReviewWatchMock(...args),
triggerReviewWatch: vi.fn(),
triggerAllReviewWatches: vi.fn(),
previewResetReviewWatch: vi.fn(),
resetReviewWatch: vi.fn(),
createIssueWatch: vi.fn(),
updateIssueWatch: vi.fn(),
deleteIssueWatch: vi.fn(),
triggerIssueWatch: vi.fn(),
triggerAllIssueWatches: vi.fn(),
previewResetIssueWatch: vi.fn(),
resetIssueWatch: vi.fn(),
getActionPresets: vi.fn().mockResolvedValue({ workspace_id: "workspace-1", mr: [], issue: [] }),
updateActionPresets: (...args: unknown[]) => updateActionPresetsMock(...args),
resetActionPresets: (...args: unknown[]) => resetActionPresetsMock(...args),
}));
afterEach(() => {
cleanup();
fetchGitLabStatusMock.mockReset();
setGitLabConfigMock.mockReset().mockResolvedValue({
workspace_id: workspaceId,
host: defaultHost,
auth_method: "glab_cli",
});
deleteReviewWatchMock.mockReset().mockResolvedValue({ deleted: true });
resetActionPresetsMock.mockReset().mockResolvedValue({
workspace_id: workspaceId,
mr: [],
issue: [],
});
updateActionPresetsMock
.mockReset()
.mockImplementation((_workspaceId, body) =>
Promise.resolve({ workspace_id: workspaceId, mr: body.mr ?? [], issue: body.issue ?? [] }),
);
appState.gitlabReviewWatches.items = [];
appState.gitlabActionPresets.byWorkspaceId = {};
vi.mocked(useSettingsSaveContributor).mockClear();
});
describe("GitLabIntegrationPage", () => {
it("marks the changed host and its owning card dirty", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: false,
auth_method: "none",
connection_error: "",
glab_version: "",
host: defaultHost,
token_configured: false,
username: "",
});
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
const host = await screen.findByDisplayValue(defaultHost);
expect(screen.getByText("Merge request review watches")).toBeTruthy();
expect(screen.getByText("Issue watches")).toBeTruthy();
expect(fetchGitLabStatusMock).toHaveBeenCalledWith({
cache: "no-store",
workspaceId,
});
const card = host.closest('[data-slot="card"]');
expect(card?.getAttribute("data-settings-dirty")).toBe("false");
const initialRevision = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([contributor]) => contributor)
.reverse()
.find((contributor) => contributor.id === credentialsContributorId)?.revision;
fireEvent.change(host, { target: { value: "https://gitlab.example.com" } });
expect(host.getAttribute("data-settings-dirty")).toBe("true");
await waitFor(() => expect(card?.getAttribute("data-settings-dirty")).toBe("true"));
await waitFor(() => {
const currentRevision = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([contributor]) => contributor)
.reverse()
.find((contributor) => contributor.id === credentialsContributorId)?.revision;
expect(currentRevision).not.toBe(initialRevision);
});
});
it("submits a fresh self-managed host and PAT together", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: false,
auth_method: "none",
connection_error: "",
host: defaultHost,
token_configured: false,
username: "",
});
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
fireEvent.change(await screen.findByDisplayValue(defaultHost), {
target: { value: "http://gitlab.internal" },
});
fireEvent.change(screen.getByPlaceholderText("glpat-xxxxxxxxxxxxxxxxxxxx"), {
target: { value: "glpat-fresh" },
});
const tokenContributor = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([contributor]) => contributor)
.reverse()
.find((contributor) => contributor.id === credentialsContributorId);
if (tokenContributor) await tokenContributor.save(tokenContributor.revision);
expect(setGitLabConfigMock).toHaveBeenCalledWith(
{
host: "http://gitlab.internal",
auth_method: "pat",
token: "glpat-fresh",
},
{ workspaceId },
);
});
});
describe("GitLabIntegrationPage workspace actions", () => {
it("uses an accessible destructive confirmation before deleting a watch", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: true,
auth_method: "pat",
connection_error: "",
host: defaultHost,
token_configured: true,
username: "alice",
});
appState.gitlabReviewWatches.items = [
{
id: "review-1",
workspace_id: workspaceId,
workflow_id: "workflow",
workflow_step_id: "step",
projects: [],
agent_profile_id: "",
executor_profile_id: "",
prompt: "review",
review_scope: "user",
custom_query: "",
enabled: true,
poll_interval_seconds: 300,
cleanup_policy: "auto",
created_at: "2026-01-01",
updated_at: "2026-01-01",
},
];
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
fireEvent.click((await screen.findAllByRole("button", { name: "Delete watch" }))[0]!);
expect(screen.getByRole("alertdialog", { name: /delete gitlab review watch/i })).toBeTruthy();
expect(screen.getByText(/delete every task created by this watch/i)).toBeTruthy();
expect(deleteReviewWatchMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Delete watch" }));
await waitFor(() =>
expect(deleteReviewWatchMock).toHaveBeenCalledWith("review-1", workspaceId),
);
});
it("can switch the workspace connection to glab CLI authentication", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: false,
auth_method: "none",
connection_error: "",
host: defaultHost,
token_configured: false,
username: "",
});
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
fireEvent.click(await screen.findByRole("combobox", { name: "Authentication method" }));
fireEvent.click(screen.getByRole("option", { name: "glab CLI" }));
const contributor = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([value]) => value)
.reverse()
.find((value) => value.id === credentialsContributorId);
if (contributor) await contributor.save(contributor.revision);
expect(setGitLabConfigMock).toHaveBeenCalledWith(
{ host: defaultHost, auth_method: "glab_cli" },
{ workspaceId },
);
expect(screen.queryByPlaceholderText("glpat-xxxxxxxxxxxxxxxxxxxx")).toBeNull();
});
it("labels configured but rejected credentials as reconnect required", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: false,
auth_method: "pat",
connection_error: "",
host: defaultHost,
token_configured: true,
username: "",
});
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
expect(await screen.findByText("Reconnect required")).toBeTruthy();
});
});
describe("GitLabIntegrationPage quick actions", () => {
it("exposes workspace quick-action reset controls", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: true,
auth_method: "pat",
connection_error: "",
host: defaultHost,
token_configured: true,
username: "alice",
});
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
expect(await screen.findByText("Quick actions")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Reset quick actions to defaults" }));
await waitFor(() => expect(resetActionPresetsMock).toHaveBeenCalledWith(workspaceId));
});
it("updates the workspace quick-action prompt through the shared save control", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: true,
auth_method: "pat",
connection_error: "",
host: defaultHost,
token_configured: true,
username: "alice",
});
appState.gitlabActionPresets.byWorkspaceId[workspaceId] = {
workspace_id: workspaceId,
mr: [
{
id: "review",
label: "Review",
hint: "Inspect",
icon: "eye",
prompt_template: "Review {{url}}",
},
],
issue: [],
};
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Action label 1" }), {
target: { value: "Deep review" },
});
const contributor = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([value]) => value)
.reverse()
.find((value) => value.id === `gitlab-action-presets:${workspaceId}`);
if (contributor) await contributor.save(contributor.revision);
expect(updateActionPresetsMock).toHaveBeenCalledWith(
workspaceId,
expect.objectContaining({ mr: [expect.objectContaining({ label: "Deep review" })] }),
);
});
it("prevents saving a quick action with a blank label", async () => {
fetchGitLabStatusMock.mockResolvedValue({
authenticated: true,
auth_method: "pat",
connection_error: "",
host: defaultHost,
token_configured: true,
username: "alice",
});
appState.gitlabActionPresets.byWorkspaceId[workspaceId] = {
workspace_id: workspaceId,
mr: [
{
id: "review",
label: "Review",
hint: "Inspect",
icon: "eye",
prompt_template: "Review {{url}}",
},
],
issue: [],
};
render(<GitLabIntegrationPage workspaceId={workspaceId} />);
fireEvent.change(await screen.findByRole("textbox", { name: "Action label 1" }), {
target: { value: " " },
});
const contributor = vi
.mocked(useSettingsSaveContributor)
.mock.calls.map(([value]) => value)
.reverse()
.find((value) => value.id === `gitlab-action-presets:${workspaceId}`);
expect(contributor?.canSave).toBe(false);
expect(contributor?.invalidReason).toContain("label and prompt");
});
});