Files
kandev/apps/web/hooks/domains/github/use-pr-commits.test.ts
T

320 lines
11 KiB
TypeScript

import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { createElement, type ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import type { PRCommitInfo } from "@/lib/types/github";
import { createPRCommitsResource, type PRCommitsRequest } from "./pr-commits-resource";
import { resolvePRCommitsView, usePRCommits, type KeyedPRCommitsState } from "./use-pr-commits";
const requestMock = vi.hoisted(() => vi.fn());
let websocketClient: { request: typeof requestMock } | null = { request: requestMock };
const SHARED_COMMIT_SHA = "shared-sha";
const RETRY_COMMIT_SHA = "retry-sha";
const REFRESHED_COMMIT_SHA = "refreshed-sha";
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => websocketClient,
}));
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
beforeEach(() => {
requestMock.mockReset();
websocketClient = { request: requestMock };
});
afterEach(() => {
cleanup();
});
function wrapper({ children }: { children: ReactNode }) {
const initialState = {
workspaces: { activeId: "workspace-1" },
} as unknown as Partial<AppState>;
return createElement(StateProvider, { initialState, children });
}
function commit(sha: string): PRCommitInfo {
return {
sha,
message: sha,
author_login: "octocat",
author_date: "2026-08-04T12:00:00Z",
additions: 1,
deletions: 0,
files_changed: 1,
stats_available: false,
};
}
function resourceRequest(sourceKey: string, prNumber = 1): PRCommitsRequest {
return {
workspaceId: "workspace-1",
owner: "acme",
repo: "app",
prNumber,
sourceKey,
};
}
describe("usePRCommits request ownership", () => {
it("shares one in-flight request across concurrent consumers", async () => {
const pending = deferred<{ commits: PRCommitInfo[]; head_sha: string; complete: boolean }>();
requestMock.mockReturnValue(pending.promise);
const first = renderHook(() => usePRCommits("acme", "app", 1, "concurrent"), { wrapper });
const second = renderHook(() => usePRCommits("acme", "app", 1, "concurrent"), { wrapper });
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
await act(async () => {
pending.resolve({
commits: [commit(SHARED_COMMIT_SHA)],
head_sha: SHARED_COMMIT_SHA,
complete: true,
});
await pending.promise;
});
await waitFor(() => expect(first.result.current.providerHead).toBe(SHARED_COMMIT_SHA));
expect(second.result.current.providerHead).toBe(SHARED_COMMIT_SHA);
});
it("retries one failed request and exposes the successful retry", async () => {
requestMock
.mockRejectedValueOnce(new Error("temporary provider failure"))
.mockResolvedValueOnce({
commits: [commit(RETRY_COMMIT_SHA)],
head_sha: RETRY_COMMIT_SHA,
complete: true,
});
const { result } = renderHook(() => usePRCommits("acme", "app", 1, "retry"), { wrapper });
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(result.current.providerHead).toBe(RETRY_COMMIT_SHA));
});
it("keeps a final error internal and retries it for a later consumer", async () => {
vi.useFakeTimers();
try {
const requester = vi
.fn()
.mockRejectedValueOnce(new Error("first provider failure"))
.mockRejectedValueOnce(new Error("final provider failure"))
.mockResolvedValueOnce({
commits: [commit("recovered-sha")],
head_sha: "recovered-sha",
complete: true,
});
const resource = createPRCommitsResource(requester, { retryDelayMs: 10 });
const request = resourceRequest("final-error");
const unsubscribe = resource.subscribe(request.sourceKey, vi.fn());
const failed = resource.load(request);
await Promise.resolve();
await vi.advanceTimersByTimeAsync(10);
await expect(failed).resolves.toMatchObject({
commits: [],
providerHead: null,
providerCommitsComplete: false,
error: "final provider failure",
});
expect(resource.getSnapshot(request.sourceKey).error).toBe("final provider failure");
const recovered = resource.load(request);
await expect(recovered).resolves.toMatchObject({ providerHead: "recovered-sha" });
expect(requester).toHaveBeenCalledTimes(3);
unsubscribe();
} finally {
vi.useRealTimers();
}
});
});
describe("usePRCommits manual refresh", () => {
it("shares a manual refresh and publishes it to every consumer", async () => {
const refreshed = deferred<{
commits: PRCommitInfo[];
head_sha: string;
complete: boolean;
}>();
requestMock
.mockResolvedValueOnce({
commits: [commit("initial-sha")],
head_sha: "initial-sha",
complete: true,
})
.mockReturnValueOnce(refreshed.promise);
const first = renderHook(() => usePRCommits("acme", "app", 1, "manual"), { wrapper });
const second = renderHook(() => usePRCommits("acme", "app", 1, "manual"), { wrapper });
await waitFor(() => expect(first.result.current.providerHead).toBe("initial-sha"));
let firstRefresh: Promise<unknown> | undefined;
let secondRefresh: Promise<unknown> | undefined;
await act(async () => {
firstRefresh = first.result.current.refresh();
secondRefresh = second.result.current.refresh();
await Promise.resolve();
});
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2));
expect(firstRefresh).toBeDefined();
expect(secondRefresh).toBeDefined();
await act(async () => {
refreshed.resolve({
commits: [commit(REFRESHED_COMMIT_SHA)],
head_sha: REFRESHED_COMMIT_SHA,
complete: true,
});
await refreshed.promise;
});
await waitFor(() => expect(first.result.current.providerHead).toBe(REFRESHED_COMMIT_SHA));
expect(second.result.current.providerHead).toBe(REFRESHED_COMMIT_SHA);
});
});
describe("usePRCommits stale results and eviction", () => {
it("does not let an older sync-version response replace the active result", async () => {
const first = deferred<{ commits: PRCommitInfo[]; head_sha: string; complete: boolean }>();
const second = deferred<{ commits: PRCommitInfo[]; head_sha: string; complete: boolean }>();
requestMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const { result, rerender } = renderHook(
({ syncVersion }) => usePRCommits("acme", "app", 1, syncVersion),
{ initialProps: { syncVersion: "old-sync" }, wrapper },
);
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
rerender({ syncVersion: "new-sync" });
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2));
await act(async () => {
second.resolve({ commits: [commit("new-sha")], head_sha: "new-sha", complete: true });
await second.promise;
});
await waitFor(() => expect(result.current.providerHead).toBe("new-sha"));
await act(async () => {
first.resolve({ commits: [commit("old-sha")], head_sha: "old-sha", complete: true });
await first.promise;
});
expect(result.current.providerHead).toBe("new-sha");
});
it("evicts superseded and old cache entries", async () => {
const requester = vi.fn().mockImplementation(async (request: PRCommitsRequest) => ({
commits: [commit(request.sourceKey)],
head_sha: request.sourceKey,
complete: true,
}));
const resource = createPRCommitsResource(requester, { maxEntries: 2 });
await resource.load(resourceRequest("sync-a"));
await resource.load(resourceRequest("sync-b"));
requester.mockClear();
await resource.load(resourceRequest("sync-a"));
expect(requester).toHaveBeenCalledTimes(1);
await resource.load(resourceRequest("other-pr", 2));
await resource.load(resourceRequest("third-pr", 3));
requester.mockClear();
await resource.load(resourceRequest("sync-a"));
expect(requester).toHaveBeenCalledTimes(1);
});
});
describe("usePRCommits hook view", () => {
it("masks state unless the complete workspace and PR source key matches", () => {
const staleState: KeyedPRCommitsState = {
sourceKey: "workspace-1/acme/app/1/old-refresh",
commits: [commit("first-sha")],
providerHead: "first-sha",
providerCommitsComplete: true,
loading: false,
error: null,
};
expect(resolvePRCommitsView(staleState, "workspace-1/acme/other-app/2/new-refresh")).toEqual({
commits: [],
providerHead: null,
providerCommitsComplete: false,
loading: true,
error: null,
});
});
it("masks the previous PR while a rapid switch is loading", async () => {
const first = deferred<{ commits: PRCommitInfo[] }>();
const second = deferred<{ commits: PRCommitInfo[] }>();
requestMock.mockImplementation((_action: string, payload: { number: number }) =>
payload.number === 1 ? first.promise : second.promise,
);
const { result, rerender } = renderHook(
({ number }) => usePRCommits("acme", "app", number, "switch"),
{ initialProps: { number: 1 }, wrapper },
);
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
rerender({ number: 2 });
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(2));
await act(async () => {
second.resolve({ commits: [commit("second-sha")] });
await second.promise;
});
await waitFor(() => expect(result.current.commits[0]?.sha).toBe("second-sha"));
await act(async () => {
first.resolve({ commits: [commit("late-first-sha")] });
await first.promise;
});
expect(result.current.commits[0]?.sha).toBe("second-sha");
});
it("exposes the provider head and commit-list completeness", async () => {
requestMock.mockResolvedValue({
commits: [commit("provider-head")],
head_sha: "provider-head",
complete: true,
});
const { result } = renderHook(() => usePRCommits("acme", "app", 1, "head"), { wrapper });
await waitFor(() => expect(result.current.providerHead).toBe("provider-head"));
expect(result.current.providerCommitsComplete).toBe(true);
});
it("refreshes provider evidence on demand", async () => {
requestMock
.mockResolvedValueOnce({
commits: [commit("old-provider-head")],
head_sha: "old-provider-head",
complete: true,
})
.mockResolvedValueOnce({
commits: [commit("new-provider-head")],
head_sha: "new-provider-head",
complete: true,
});
const { result } = renderHook(() => usePRCommits("acme", "app", 1, "refresh"), { wrapper });
await waitFor(() => expect(result.current.providerHead).toBe("old-provider-head"));
await act(async () => {
await result.current.refresh();
});
expect(result.current.providerHead).toBe("new-provider-head");
expect(requestMock).toHaveBeenCalledTimes(2);
});
});