Files
kandev/apps/web/hooks/domains/azure-devops/use-azure-devops-task-pull-requests.test.tsx
T
2026-07-21 08:40:26 +01:00

222 lines
8.3 KiB
TypeScript

import { createElement, type ReactNode } from "react";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import type { AzureDevOpsTaskPullRequest } from "@/lib/types/azure-devops";
const apiMocks = vi.hoisted(() => ({
list: vi.fn(),
sync: vi.fn(),
}));
vi.mock("@/lib/api/domains/azure-devops-api", () => ({
listWorkspaceAzureDevOpsTaskPullRequests: apiMocks.list,
syncAzureDevOpsTaskPullRequest: apiMocks.sync,
}));
import {
cacheAzureDevOpsTaskPullRequest,
useAzureDevOpsTaskPullRequests,
} from "./use-azure-devops-task-pull-requests";
function wrapper({ children }: { children: ReactNode }) {
return createElement(StateProvider, null, children);
}
function taskPullRequest(
overrides: Partial<AzureDevOpsTaskPullRequest> = {},
): AzureDevOpsTaskPullRequest {
const now = new Date().toISOString();
return {
id: "link-1",
taskId: "task-1",
repositoryId: "repo-1",
organizationUrl: "https://dev.azure.com/acme",
projectId: "project-1",
azureRepositoryId: "azure-repo-1",
pullRequestId: 42,
pullRequestUrl: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42",
title: "Initial title",
sourceBranch: "feature/azure",
targetBranch: "main",
authorId: "author-1",
authorName: "Ada",
status: "active",
isDraft: false,
lastSyncedAt: now,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((next, fail) => {
resolve = next;
reject = fail;
});
return { promise, reject, resolve };
}
beforeEach(() => {
apiMocks.list.mockReset();
apiMocks.sync.mockReset();
});
afterEach(cleanup);
describe("useAzureDevOpsTaskPullRequests", () => {
it("preserves a new association in the workspace snapshot across remounts", async () => {
apiMocks.list.mockResolvedValue({ taskPrs: {} });
const first = renderHook(() => useAzureDevOpsTaskPullRequests("workspace-cache", "task-1"), {
wrapper,
});
await waitFor(() => expect(apiMocks.list).toHaveBeenCalledTimes(1));
const linked = taskPullRequest();
act(() => cacheAzureDevOpsTaskPullRequest("workspace-cache", "task-1", linked));
first.unmount();
const second = renderHook(() => useAzureDevOpsTaskPullRequests("workspace-cache", "task-1"), {
wrapper,
});
await waitFor(() => expect(second.result.current).toEqual([linked]));
expect(apiMocks.list).toHaveBeenCalledTimes(1);
});
it("refreshes a stale association when its task chip is displayed", async () => {
const stale = taskPullRequest({
id: "link-stale",
title: "Stale title",
lastSyncedAt: "2020-01-01T00:00:00Z",
});
const refreshed = taskPullRequest({
id: "link-stale",
title: "Current title",
lastSyncedAt: new Date().toISOString(),
});
apiMocks.list.mockResolvedValue({ taskPrs: { "task-1": [stale] } });
apiMocks.sync.mockResolvedValue(refreshed);
const { result } = renderHook(
() => useAzureDevOpsTaskPullRequests("workspace-refresh", "task-1"),
{ wrapper },
);
await waitFor(() => expect(result.current[0]?.title).toBe("Current title"));
expect(apiMocks.sync).toHaveBeenCalledWith("workspace-refresh", "task-1", {
repositoryId: "repo-1",
pullRequestId: 42,
});
});
it("deduplicates concurrent workspace loads", async () => {
const pending = deferred<{ taskPrs: Record<string, AzureDevOpsTaskPullRequest[]> }>();
apiMocks.list.mockReturnValue(pending.promise);
const first = taskPullRequest({ id: "link-one", taskId: "task-1" });
const second = taskPullRequest({ id: "link-two", taskId: "task-2" });
const { result } = renderHook(
() => ({
first: useAzureDevOpsTaskPullRequests("workspace-concurrent", "task-1"),
second: useAzureDevOpsTaskPullRequests("workspace-concurrent", "task-2"),
}),
{ wrapper },
);
await waitFor(() => expect(apiMocks.list).toHaveBeenCalledTimes(1));
await act(async () => pending.resolve({ taskPrs: { "task-1": [first], "task-2": [second] } }));
await waitFor(() => expect(result.current).toEqual({ first: [first], second: [second] }));
});
it("retries a failed workspace load after remount", async () => {
apiMocks.list.mockRejectedValueOnce(new Error("offline"));
const first = renderHook(() => useAzureDevOpsTaskPullRequests("workspace-retry", "task-1"), {
wrapper,
});
await waitFor(() => expect(apiMocks.list).toHaveBeenCalledTimes(1));
first.unmount();
const linked = taskPullRequest({ id: "link-retry" });
apiMocks.list.mockResolvedValueOnce({ taskPrs: { "task-1": [linked] } });
const second = renderHook(() => useAzureDevOpsTaskPullRequests("workspace-retry", "task-1"), {
wrapper,
});
await waitFor(() => expect(second.result.current).toEqual([linked]));
expect(apiMocks.list).toHaveBeenCalledTimes(2);
});
it("replaces task associations when the workspace changes", async () => {
const first = taskPullRequest({ id: "link-workspace-a", title: "Workspace A" });
const second = taskPullRequest({ id: "link-workspace-b", title: "Workspace B" });
apiMocks.list.mockImplementation((workspaceId: string) =>
Promise.resolve({
taskPrs: { "task-1": [workspaceId === "workspace-switch-a" ? first : second] },
}),
);
const { result, rerender } = renderHook(
({ workspaceId }) => useAzureDevOpsTaskPullRequests(workspaceId, "task-1"),
{ initialProps: { workspaceId: "workspace-switch-a" }, wrapper },
);
await waitFor(() => expect(result.current).toEqual([first]));
rerender({ workspaceId: "workspace-switch-b" });
await waitFor(() => expect(result.current).toEqual([second]));
});
});
describe("useAzureDevOpsTaskPullRequests workspace switch guards", () => {
it("ignores a workspace load that completes after the workspace changes", async () => {
const firstLoad = deferred<{ taskPrs: Record<string, AzureDevOpsTaskPullRequest[]> }>();
const first = taskPullRequest({ id: "link-stale-load-a", title: "Workspace A" });
const second = taskPullRequest({ id: "link-stale-load-b", title: "Workspace B" });
apiMocks.list.mockImplementation((workspaceId: string) =>
workspaceId === "workspace-stale-load-a"
? firstLoad.promise
: Promise.resolve({ taskPrs: { "task-1": [second] } }),
);
const { result, rerender } = renderHook(
({ workspaceId }) => useAzureDevOpsTaskPullRequests(workspaceId, "task-1"),
{ initialProps: { workspaceId: "workspace-stale-load-a" }, wrapper },
);
await waitFor(() => expect(apiMocks.list).toHaveBeenCalledTimes(1));
rerender({ workspaceId: "workspace-stale-load-b" });
await waitFor(() => expect(result.current).toEqual([second]));
await act(async () => firstLoad.resolve({ taskPrs: { "task-1": [first] } }));
expect(result.current).toEqual([second]);
});
it("ignores a task PR refresh that completes after the workspace changes", async () => {
const refresh = deferred<AzureDevOpsTaskPullRequest>();
const first = taskPullRequest({
id: "link-stale-refresh",
title: "Workspace A",
lastSyncedAt: "2020-01-01T00:00:00Z",
});
const second = taskPullRequest({ id: "link-stale-refresh", title: "Workspace B" });
const refreshed = taskPullRequest({
id: "link-stale-refresh",
title: "Refreshed workspace A",
});
apiMocks.list.mockImplementation((workspaceId: string) =>
Promise.resolve({
taskPrs: { "task-1": [workspaceId === "workspace-stale-refresh-a" ? first : second] },
}),
);
apiMocks.sync.mockReturnValue(refresh.promise);
const { result, rerender } = renderHook(
({ workspaceId }) => useAzureDevOpsTaskPullRequests(workspaceId, "task-1"),
{ initialProps: { workspaceId: "workspace-stale-refresh-a" }, wrapper },
);
await waitFor(() => expect(apiMocks.sync).toHaveBeenCalledTimes(1));
rerender({ workspaceId: "workspace-stale-refresh-b" });
await waitFor(() => expect(result.current).toEqual([second]));
expect(apiMocks.sync).toHaveBeenCalledTimes(1);
await act(async () => refresh.resolve(refreshed));
expect(result.current).toEqual([second]);
});
});