Files
kandev/apps/web/hooks/domains/github/use-task-ci-options.test.tsx
T
2026-06-20 10:05:25 +01:00

182 lines
6.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement, type ReactNode } from "react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { StateProvider, useAppStore } from "@/components/state-provider";
import type { TaskCIAutomationOptions } from "@/lib/types/github";
const apiMocks = vi.hoisted(() => ({
getOptionsMock: vi.fn(),
updateOptionsMock: vi.fn(),
}));
vi.mock("@/lib/api/domains/github-api", () => ({
getTaskCIAutomationOptions: apiMocks.getOptionsMock,
updateTaskCIAutomationOptions: apiMocks.updateOptionsMock,
}));
import { useTaskCIAutomationOptions } from "./use-task-ci-options";
function wrapper({ children }: { children: ReactNode }) {
return createElement(StateProvider, null, children);
}
function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
return {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default CI prompt",
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
...overrides,
};
}
beforeEach(() => {
apiMocks.getOptionsMock.mockReset();
apiMocks.updateOptionsMock.mockReset();
});
afterEach(() => {
cleanup();
});
describe("useTaskCIAutomationOptions", () => {
it("loads options for the task and stores the response", async () => {
apiMocks.getOptionsMock.mockResolvedValue(makeOptions({ auto_fix_enabled: true }));
const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper });
await waitFor(() => expect(result.current.options?.auto_fix_enabled).toBe(true));
expect(apiMocks.getOptionsMock).toHaveBeenCalledWith("task-1", { cache: "no-store" });
expect(result.current.loading).toBe(false);
});
it("patches options and supports resetting the task prompt override", async () => {
apiMocks.getOptionsMock.mockResolvedValue(
makeOptions({ auto_fix_prompt_override: "Custom prompt" }),
);
apiMocks.updateOptionsMock.mockResolvedValue(makeOptions({ auto_fix_prompt_override: null }));
const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper });
await waitFor(() => expect(result.current.options).not.toBeNull());
await act(async () => {
await result.current.resetPrompt();
});
expect(apiMocks.updateOptionsMock).toHaveBeenCalledWith(
"task-1",
{ auto_fix_prompt_override: null },
{ cache: "no-store" },
);
expect(result.current.options?.auto_fix_prompt_override).toBeNull();
expect(result.current.saving).toBe(false);
});
it("does not auto-retry after a load error", async () => {
apiMocks.getOptionsMock.mockRejectedValue(new Error("backend unavailable"));
const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper });
await waitFor(() => expect(result.current.error).toBe("backend unavailable"));
expect(apiMocks.getOptionsMock).toHaveBeenCalledTimes(1);
await act(async () => {
await Promise.resolve();
});
expect(apiMocks.getOptionsMock).toHaveBeenCalledTimes(1);
});
it("ignores stale refresh responses", async () => {
let resolveFirst: (value: TaskCIAutomationOptions) => void = () => {};
let resolveSecond: (value: TaskCIAutomationOptions) => void = () => {};
apiMocks.getOptionsMock
.mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
.mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve)));
const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper });
await waitFor(() => expect(apiMocks.getOptionsMock).toHaveBeenCalledTimes(1));
let secondRefresh: Promise<TaskCIAutomationOptions | null>;
await act(async () => {
secondRefresh = result.current.refresh();
});
resolveSecond(makeOptions({ auto_fix_enabled: true }));
await act(async () => {
await secondRefresh!;
});
resolveFirst(makeOptions({ auto_fix_enabled: false }));
await act(async () => {
await Promise.resolve();
});
expect(result.current.options?.auto_fix_enabled).toBe(true);
});
});
describe("useTaskCIAutomationOptions task switching", () => {
it("clears loading for the original task when switching tasks mid-refresh", async () => {
let resolveFirst: (value: TaskCIAutomationOptions) => void = () => {};
let resolveSecond: (value: TaskCIAutomationOptions) => void = () => {};
apiMocks.getOptionsMock
.mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
.mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve)));
const { result, rerender } = renderHook(
({ taskId }) => ({
hook: useTaskCIAutomationOptions(taskId),
automation: useAppStore((state) => state.taskCIAutomation),
}),
{ wrapper, initialProps: { taskId: "task-1" } },
);
await waitFor(() => expect(result.current.automation.loading["task-1"]).toBe(true));
rerender({ taskId: "task-2" });
await waitFor(() => expect(result.current.automation.loading["task-2"]).toBe(true));
resolveFirst(makeOptions({ task_id: "task-1" }));
await act(async () => {
await Promise.resolve();
});
expect(result.current.automation.loading["task-1"]).toBe(false);
expect(result.current.automation.loading["task-2"]).toBe(true);
resolveSecond(makeOptions({ task_id: "task-2" }));
await waitFor(() => expect(result.current.automation.loading["task-2"]).toBe(false));
});
});
describe("useTaskCIAutomationOptions updates", () => {
it("ignores stale update responses", async () => {
apiMocks.getOptionsMock.mockResolvedValue(makeOptions());
let resolveFirst: (value: TaskCIAutomationOptions) => void = () => {};
let resolveSecond: (value: TaskCIAutomationOptions) => void = () => {};
apiMocks.updateOptionsMock
.mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
.mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve)));
const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper });
await waitFor(() => expect(result.current.options).not.toBeNull());
let firstUpdate: Promise<TaskCIAutomationOptions | null>;
let secondUpdate: Promise<TaskCIAutomationOptions | null>;
await act(async () => {
firstUpdate = result.current.update({ auto_fix_enabled: true });
secondUpdate = result.current.update({ auto_merge_enabled: true });
});
resolveSecond(makeOptions({ auto_merge_enabled: true }));
await act(async () => {
await secondUpdate!;
});
resolveFirst(makeOptions({ auto_fix_enabled: true }));
await act(async () => {
await firstUpdate!;
});
expect(result.current.options?.auto_merge_enabled).toBe(true);
expect(result.current.options?.auto_fix_enabled).toBe(false);
});
});