Files
kandev/apps/web/components/task/use-remote-contribution-resolution.test.tsx
T
2026-08-12 19:12:35 +01:00

168 lines
5.8 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GitOperationResult } from "@/hooks/use-git-operations";
import type { RemoteContributionRelation } from "@/hooks/domains/session/remote-contribution-relation";
import type { TaskPR } from "@/lib/types/github";
import {
buildRemoteContributionResolutionTarget,
classifyRemoteContributionError,
useRemoteContributionResolution,
} from "./use-remote-contribution-resolution";
const mocks = vi.hoisted(() => ({
replaceRemoteContribution: vi.fn(),
useRemoteContribution: vi.fn(),
refreshProviderEvidence: vi.fn(),
}));
vi.mock("@/hooks/use-git-operations", () => ({
useGitOperations: () => ({
replaceRemoteContribution: mocks.replaceRemoteContribution,
useRemoteContribution: mocks.useRemoteContribution,
}),
}));
const TARGET = {
expectedRemoteHead: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
repo: "",
repositoryName: "workspace",
};
const successResult: GitOperationResult = {
success: true,
operation: "use_remote_contribution",
output: "",
recovery_branch: "kandev/recovery-123",
};
const relationWithProviderHead = {
providerHead: TARGET.expectedRemoteHead,
canReplaceRemote: true,
canUseRemote: true,
} as RemoteContributionRelation;
describe("useRemoteContributionResolution", () => {
beforeEach(() => {
mocks.replaceRemoteContribution.mockReset();
mocks.useRemoteContribution.mockReset();
mocks.refreshProviderEvidence.mockReset();
mocks.replaceRemoteContribution.mockResolvedValue({
...successResult,
operation: "replace_remote_contribution",
});
mocks.useRemoteContribution.mockResolvedValue(successResult);
mocks.refreshProviderEvidence.mockResolvedValue(TARGET.expectedRemoteHead);
});
it("keeps a typed replacement confirmation pending until it is confirmed", async () => {
const { result } = renderHook(() =>
useRemoteContributionResolution("session-1", mocks.refreshProviderEvidence),
);
act(() => result.current.requestReplace(TARGET));
expect(result.current.pending).toEqual({ action: "replace", ...TARGET });
await act(async () => {
await result.current.confirm();
});
expect(mocks.replaceRemoteContribution).toHaveBeenCalledWith(TARGET.expectedRemoteHead, "");
expect(mocks.refreshProviderEvidence).toHaveBeenCalledOnce();
expect(result.current.pending).toBeNull();
expect(result.current.lastResult?.operation).toBe("replace_remote_contribution");
});
it("uses one scoped adoption operation and exposes its recovery branch", async () => {
const { result } = renderHook(() => useRemoteContributionResolution("session-1"));
act(() => result.current.requestUse(TARGET));
await act(async () => {
await result.current.confirm();
});
expect(mocks.useRemoteContribution).toHaveBeenCalledWith(TARGET.expectedRemoteHead, "");
expect(result.current.lastResult?.recovery_branch).toBe("kandev/recovery-123");
});
it("refreshes the provider head after a lease mismatch before retry", async () => {
const refreshedHead = "cccccccccccccccccccccccccccccccccccccccc";
mocks.replaceRemoteContribution.mockResolvedValue({
...successResult,
success: false,
operation: "replace_remote_contribution",
error: "remote contribution head changed",
error_code: "lease_mismatch",
});
mocks.refreshProviderEvidence.mockResolvedValue(refreshedHead);
const { result } = renderHook(() =>
useRemoteContributionResolution("session-1", mocks.refreshProviderEvidence),
);
act(() => result.current.requestReplace(TARGET));
await act(async () => {
await result.current.confirm();
});
expect(mocks.refreshProviderEvidence).toHaveBeenCalledOnce();
expect(result.current.pending?.expectedRemoteHead).toBe(refreshedHead);
expect(result.current.error).toBe("lease_mismatch");
});
it("clears a pending confirmation without calling Git", () => {
const { result } = renderHook(() => useRemoteContributionResolution("session-1"));
act(() => result.current.requestUse(TARGET));
act(() => result.current.cancel());
expect(result.current.pending).toBeNull();
expect(mocks.useRemoteContribution).not.toHaveBeenCalled();
});
});
describe("classifyRemoteContributionError", () => {
it.each(["lease_mismatch"])("maps %s to a lease mismatch", (errorCode) => {
expect(classifyRemoteContributionError(errorCode)).toBe("lease_mismatch");
});
it("maps dirty-worktree error codes to the clean-tree instruction", () => {
expect(classifyRemoteContributionError("dirty_worktree")).toBe("dirty_worktree");
});
it("does not infer a category from provider error text", () => {
expect(classifyRemoteContributionError("remote contribution head changed")).toBe("generic");
});
it("keeps unrelated provider errors generic", () => {
expect(classifyRemoteContributionError("remote_unavailable")).toBe("generic");
expect(classifyRemoteContributionError(undefined)).toBe("generic");
});
});
describe("buildRemoteContributionResolutionTarget", () => {
it("uses the selected PR identity when the repository scope is empty", () => {
expect(
buildRemoteContributionResolutionTarget(
relationWithProviderHead,
"",
{ owner: "acme", repo: "widget" } as TaskPR,
"Remote repository",
),
).toEqual({
expectedRemoteHead: TARGET.expectedRemoteHead,
repo: "",
repositoryName: "acme/widget",
});
});
it("does not create a target without a resolvable contribution action", () => {
expect(
buildRemoteContributionResolutionTarget(
{ ...relationWithProviderHead, canReplaceRemote: false, canUseRemote: false },
"frontend",
null,
"Remote repository",
),
).toBeNull();
});
});