Files
kandev/apps/web/hooks/domains/settings/use-kubernetes-settings.test.tsx
T
2026-09-10 11:05:05 +01:00

249 lines
8.2 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Executor } from "@/lib/types/http";
const {
createExecutor,
createExecutorProfile,
deleteExecutor,
getKubernetesSessionImpact,
listKubernetesSessions,
testKubernetesConnection,
} = vi.hoisted(() => ({
createExecutor: vi.fn(),
createExecutorProfile: vi.fn(),
deleteExecutor: vi.fn(),
getKubernetesSessionImpact: vi.fn(),
listKubernetesSessions: vi.fn(),
testKubernetesConnection: vi.fn(),
}));
vi.mock("@/lib/api/domains/settings-api", () => ({
createExecutor,
createExecutorProfile,
deleteExecutor,
fetchExecutor: vi.fn(),
updateExecutor: vi.fn(),
}));
vi.mock("@/lib/api/domains/kubernetes-api", () => ({
getKubernetesSessionImpact,
listKubernetesSessions,
testKubernetesConnection,
}));
import {
createKubernetesExecutorPair,
KubernetesExecutorRollbackRejectedError,
kubernetesExecutorSettingsPath,
upsertExecutor,
useKubernetesDiagnostics,
useKubernetesSessionImpact,
useKubernetesSessions,
} from "./use-kubernetes-settings";
const EXECUTOR_ID = "executor-1";
const TIMESTAMP = "2026-08-24T00:00:00Z";
function executor(id: string, name = id): Executor {
return {
id,
name,
type: "k8s",
status: "active",
is_system: false,
config: {},
profiles: [],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}
beforeEach(() => {
createExecutor.mockReset();
createExecutorProfile.mockReset();
deleteExecutor.mockReset();
getKubernetesSessionImpact.mockReset();
listKubernetesSessions.mockReset();
testKubernetesConnection.mockReset();
});
describe("Kubernetes executor create orchestration", () => {
it("returns both created identities and builds the canonical connection route", async () => {
const createdExecutor = {
id: "executor/primary",
name: "Production cluster",
type: "k8s",
config: { namespace: "agents" },
};
const createdProfile = profile("profile-1", createdExecutor.id);
createExecutor.mockResolvedValueOnce(createdExecutor);
createExecutorProfile.mockResolvedValueOnce(createdProfile);
await expect(createKubernetesExecutorPair(createInput())).resolves.toEqual({
executor: createdExecutor,
profile: createdProfile,
});
expect(kubernetesExecutorSettingsPath(createdExecutor.id)).toBe(
"/settings/executors/k8s/executor%2Fprimary",
);
});
it("deletes the just-created executor when profile creation fails", async () => {
const profileFailure = new Error("profile failed");
createExecutor.mockResolvedValueOnce({
id: EXECUTOR_ID,
name: "Cluster",
type: "k8s",
config: {},
});
createExecutorProfile.mockRejectedValueOnce(profileFailure);
deleteExecutor.mockResolvedValueOnce({ success: true });
await expect(createKubernetesExecutorPair(createInput())).rejects.toBe(profileFailure);
expect(deleteExecutor).toHaveBeenCalledWith(EXECUTOR_ID);
});
it("reports both profile and rollback failures when compensation fails", async () => {
const profileFailure = new Error("profile failed");
const rollbackFailure = new Error("rollback failed");
createExecutor.mockResolvedValueOnce({
id: EXECUTOR_ID,
name: "Cluster",
type: "k8s",
config: {},
});
createExecutorProfile.mockRejectedValueOnce(profileFailure);
deleteExecutor.mockRejectedValueOnce(rollbackFailure);
const failure = await createKubernetesExecutorPair(createInput()).catch((cause) => cause);
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).errors).toEqual([profileFailure, rollbackFailure]);
});
it("treats a rejected rollback result as a compensation failure", async () => {
const profileFailure = new Error("profile failed");
createExecutor.mockResolvedValueOnce({
id: EXECUTOR_ID,
name: "Cluster",
type: "k8s",
config: {},
});
createExecutorProfile.mockRejectedValueOnce(profileFailure);
deleteExecutor.mockResolvedValueOnce({ success: false });
const failure = await createKubernetesExecutorPair(createInput()).catch((cause) => cause);
expect(failure).toBeInstanceOf(AggregateError);
expect((failure as AggregateError).errors[0]).toBe(profileFailure);
expect((failure as AggregateError).errors[1]).toBeInstanceOf(
KubernetesExecutorRollbackRejectedError,
);
});
it("upserts a created executor without duplicating the store row", () => {
expect(upsertExecutor([executor("one")], executor("two"))).toEqual([
executor("one"),
executor("two"),
]);
expect(upsertExecutor([executor("one")], executor("one", "Updated"))).toEqual([
executor("one", "Updated"),
]);
});
});
describe("Kubernetes diagnostic and session hooks", () => {
it("settles test loading and exposes the normalized diagnostic result", async () => {
const response = { success: true, steps: [], warnings: [] };
testKubernetesConnection.mockResolvedValueOnce(response);
const { result } = renderHook(() => useKubernetesDiagnostics());
let pending: Promise<unknown> | undefined;
act(() => {
pending = result.current.run({ config: { auth_mode: "in_cluster" } });
});
expect(result.current.testing).toBe(true);
await act(async () => pending);
expect(result.current.testing).toBe(false);
expect(result.current.result).toEqual(response);
expect(result.current.error).toBeNull();
});
it("clears stale results and loading state after a failed test", async () => {
const failure = new Error("forbidden");
testKubernetesConnection.mockRejectedValueOnce(failure);
const { result } = renderHook(() => useKubernetesDiagnostics());
await act(async () => {
await expect(result.current.run({ config: {} })).rejects.toBe(failure);
});
expect(result.current.testing).toBe(false);
expect(result.current.result).toBeNull();
expect(result.current.error).toBe(failure);
});
it("returns authoritative session rows from a manual refresh", async () => {
const rows = [{ session_id: "session-1", task_id: "task-1", restarts: 0 }];
listKubernetesSessions.mockResolvedValue(rows);
const { result } = renderHook(() => useKubernetesSessions(EXECUTOR_ID));
await waitFor(() => expect(result.current.sessions).toEqual(rows));
await expect(result.current.refresh()).resolves.toEqual(rows);
});
it("clears stale session rows when a refresh fails", async () => {
const rows = [{ session_id: "session-1", task_id: "task-1", restarts: 0 }];
const failure = new Error("status unavailable");
listKubernetesSessions.mockResolvedValueOnce(rows).mockRejectedValueOnce(failure);
const { result } = renderHook(() => useKubernetesSessions(EXECUTOR_ID));
await waitFor(() => expect(result.current.sessions).toEqual(rows));
await act(async () => {
await expect(result.current.refresh()).rejects.toBe(failure);
});
expect(result.current.sessions).toEqual([]);
expect(result.current.error).toBe(failure);
});
it("does not request Kubernetes sessions when the hook is disabled", async () => {
const { result } = renderHook(() => useKubernetesSessions("docker-1", false));
await expect(result.current.refresh()).resolves.toEqual([]);
expect(listKubernetesSessions).not.toHaveBeenCalled();
});
it("loads the authoritative session impact without refreshing detailed rows", async () => {
getKubernetesSessionImpact.mockResolvedValueOnce({ active_session_count: 4 });
const { result } = renderHook(() => useKubernetesSessionImpact(EXECUTOR_ID));
await expect(result.current.loadActiveSessionCount()).resolves.toBe(4);
expect(getKubernetesSessionImpact).toHaveBeenCalledWith(EXECUTOR_ID, { cache: "no-store" });
expect(listKubernetesSessions).not.toHaveBeenCalled();
});
});
function createInput() {
return {
name: "Cluster",
config: { auth_mode: "in_cluster" },
profileName: "Default",
profileConfig: { platform: "linux/amd64" },
};
}
function profile(id: string, executorId: string) {
return {
id,
executor_id: executorId,
name: "Default",
prepare_script: "",
cleanup_script: "",
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
}