Files
kandev/apps/web/hooks/domains/gitlab/use-task-mr.test.ts
T
João Salavisa d44abe327b fix(web): stop refetching GitLab status on every tab refocus (#1257)
useGitLabAvailable registered a window "focus" listener that re-fired
GET /api/v1/gitlab/status on every browser tab switch. IntegrationsMenu
mounts globally, so the hammer was site-wide.

Route the hook through the store-cached useGitLabStatus so consumers
share a single fetch and the status no longer re-probes on focus.
Matches the useGitHubStatus pattern. Jira/Linear use the 90s poll in
useIntegrationAuthed and were unaffected.

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-02 23:32:38 +01:00

236 lines
8.4 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 { GitLabStatus, TaskMR } from "@/lib/types/gitlab";
const fetchGitLabStatusMock = vi.fn<[], Promise<GitLabStatus | null>>();
const listWorkspaceTaskMRsMock = vi.fn<
[string],
Promise<{ task_mrs: Record<string, TaskMR[]> } | null>
>();
vi.mock("@/lib/api/domains/gitlab-api", () => ({
fetchGitLabStatus: () => fetchGitLabStatusMock(),
listWorkspaceTaskMRs: (workspaceId: string) => listWorkspaceTaskMRsMock(workspaceId),
}));
import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr";
function wrapper({ children }: { children: ReactNode }) {
return createElement(StateProvider, null, children);
}
afterEach(() => cleanup());
function makeMR(overrides: Partial<TaskMR> = {}): TaskMR {
return {
id: "mr-1",
task_id: "task-1",
host: "https://gitlab.com",
project_path: "acme/api",
mr_iid: 1,
mr_url: "",
mr_title: "Test",
head_branch: "feat",
base_branch: "main",
author_username: "alice",
state: "open",
approval_state: "",
pipeline_state: "",
merge_status: "",
draft: false,
approval_count: 0,
required_approvals: 0,
pipeline_jobs_total: 0,
pipeline_jobs_pass: 0,
created_at: "",
updated_at: "",
...overrides,
};
}
function makeStatus(overrides: Partial<GitLabStatus> = {}): GitLabStatus {
return {
authenticated: true,
username: "alice",
auth_method: "pat",
host: "https://gitlab.com",
token_configured: true,
required_scopes: ["api"],
...overrides,
};
}
describe("useWorkspaceMRs", () => {
beforeEach(() => {
listWorkspaceTaskMRsMock.mockReset();
});
it("hydrates the store with the workspace's task MRs", async () => {
const mr = makeMR({ task_id: "task-1" });
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } });
const { result } = renderHook(
() => {
useWorkspaceMRs("ws-1");
return useAppStore((s) => s.taskMRs.byTaskId);
},
{ wrapper },
);
await waitFor(() => expect(result.current["task-1"]).toEqual([mr]));
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledWith("ws-1");
});
it("does not refetch when the workspace id stays the same", async () => {
listWorkspaceTaskMRsMock.mockResolvedValue({ task_mrs: {} });
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
wrapper,
initialProps: { ws: "ws-1" },
});
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
rerender({ ws: "ws-1" });
rerender({ ws: "ws-1" });
expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1);
});
it("clears MRs and invalidates in-flight requests when workspace becomes null", async () => {
// First fetch is slow — we will switch to null before it resolves to
// verify the in-flight result is dropped by the request-id guard.
let resolveFirst: (v: { task_mrs: Record<string, TaskMR[]> }) => void = () => {};
const firstPromise = new Promise<{ task_mrs: Record<string, TaskMR[]> }>((res) => {
resolveFirst = res;
});
listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise);
const { result, rerender } = renderHook(
({ ws }: { ws: string | null }) => {
useWorkspaceMRs(ws);
return useAppStore((s) => s.taskMRs.byTaskId);
},
{ wrapper, initialProps: { ws: "ws-1" as string | null } },
);
// Pre-populate the store so we can observe it being cleared.
const setInitial = renderHook(() => useAppStore((s) => s.setTaskMRs), { wrapper });
act(() => {
setInitial.result.current({ "task-1": [makeMR()] });
});
rerender({ ws: null });
await waitFor(() => expect(result.current).toEqual({}));
// The first fetch resolves *after* the null switch — its data must
// NOT land in the store.
const mr = makeMR({ task_id: "task-1" });
await act(async () => {
resolveFirst({ task_mrs: { "task-1": [mr] } });
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toEqual({});
});
it("clears fetchedRef on failure so a workspace switch can retry that id later", async () => {
listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom"));
const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), {
wrapper,
initialProps: { ws: "ws-1" },
});
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1));
// Bounce through another workspace and back to ws-1. Without the
// failure-path reset of fetchedRef, the second visit to ws-1 would
// be a no-op because the hook would still think the fetch succeeded.
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
rerender({ ws: "ws-2" });
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2));
listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} });
rerender({ ws: "ws-1" });
await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(3));
});
});
describe("useTaskMRs", () => {
it("returns the same array reference across renders when empty", () => {
const { result, rerender } = renderHook(() => useTaskMRs("task-empty"), { wrapper });
const first = result.current;
rerender();
rerender();
// If we returned `[]` literal each call, this would fail and the
// zustand selector would loop forever.
expect(result.current).toBe(first);
});
it("reads the task's MRs from the store", async () => {
const mr = makeMR({ task_id: "task-1" });
const { result } = renderHook(
() => {
const setTaskMRs = useAppStore((s) => s.setTaskMRs);
const mrs = useTaskMRs("task-1");
return { setTaskMRs, mrs };
},
{ wrapper },
);
act(() => {
result.current.setTaskMRs({ "task-1": [mr] });
});
expect(result.current.mrs).toEqual([mr]);
});
});
describe("useGitLabAvailable", () => {
beforeEach(() => {
fetchGitLabStatusMock.mockReset();
});
it("returns true when GitLab is authenticated", async () => {
fetchGitLabStatusMock.mockResolvedValue(makeStatus({ authenticated: true }));
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(result.current).toBe(true));
});
it("returns true when a token is configured but probe says unauthenticated", async () => {
// token_configured is a softer signal — the integration is set up but
// the probe might be stale. We want the integration to appear in menus.
fetchGitLabStatusMock.mockResolvedValue(
makeStatus({ authenticated: false, token_configured: true }),
);
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(result.current).toBe(true));
});
it("returns false when neither flag is set", async () => {
fetchGitLabStatusMock.mockResolvedValue(
makeStatus({ authenticated: false, token_configured: false }),
);
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
// The probe runs once on mount — give it a tick to land.
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it("returns false when the probe rejects (offline / no client)", async () => {
fetchGitLabStatusMock.mockRejectedValue(new Error("network down"));
const { result } = renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it("does not re-probe when the window regains focus", async () => {
// Regression guard: previously the hook re-fetched on every focus event,
// which hammered GET /api/v1/gitlab/status on every browser tab switch.
fetchGitLabStatusMock.mockResolvedValue(makeStatus());
renderHook(() => useGitLabAvailable(), { wrapper });
await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1));
act(() => {
window.dispatchEvent(new Event("focus"));
window.dispatchEvent(new Event("focus"));
});
await new Promise((r) => setTimeout(r, 10));
expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1);
});
});