Files
kandev/apps/web/components/linear/use-linear-issue-search.test.ts
T
João Salavisa 79a4534449 fix: gate gitlab/linear fetches on integration being configured (#1789)
* fix: gate gitlab/linear fetches on integration being configured

The GitLab and Linear pages fired merge-request/issue searches on mount
regardless of connection state, producing console errors when the
integration was unconfigured (two 500s on /gitlab, one 503 on /linear)
even though the UI already rendered the "not connected/configured" notice.

Gate the search fetch on the same connected/configured condition that
decides whether the results list renders:
- useGitLabSearch takes an `enabled` flag (converted to an options object
  to stay within the 5-param lint limit, matching useGitHubSearch); the
  page enables it only when status is loaded and GitLab is connected.
- Linear's inline issue-search hook is extracted to
  useLinearIssueSearch with an `enabled` flag, enabled only when the
  workspace is loaded, configured, and available.

Adds gating/wiring tests for both hooks.

* refactor: gate gitlab search at the effect level too

Mirror the Linear hook (which guards both its run callback and its debounce
effect) by adding the enabled guard to useGitLabSearch's fetch effect and
enabled to its dep array, so a disabled integration never schedules a fetch.
The callback keeps its own guard to also cover the exported refresh().

Addresses PR review feedback on the enabled-gate asymmetry.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 13:39:58 +01:00

113 lines
4.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import type { LinearIssue, LinearSearchResult } from "@/lib/types/linear";
const searchLinearIssuesMock =
vi.fn<(params: unknown, options?: unknown) => Promise<LinearSearchResult>>();
vi.mock("@/lib/api/domains/linear-api", () => ({
searchLinearIssues: (params: unknown, options?: unknown) =>
searchLinearIssuesMock(params, options),
}));
import { useLinearIssueSearch } from "./use-linear-issue-search";
afterEach(() => cleanup());
const WORKSPACE = "ws-1";
// Reset inline at the top of each test rather than in a beforeEach: a beforeEach
// hook shifts Vitest's post-test unhandled-rejection check so the debounced
// reject in the error case is mis-flagged.
function resetSearchMock() {
searchLinearIssuesMock.mockReset();
}
function fakeIssue(overrides: Partial<LinearIssue> = {}): LinearIssue {
return {
id: "1",
identifier: "ENG-1",
title: "Issue",
description: "",
stateId: "s1",
stateName: "Todo",
stateType: "unstarted",
stateCategory: "new",
teamId: "t1",
teamKey: "ENG",
priority: 0,
url: "https://linear.app/eng-1",
states: [],
...overrides,
};
}
function emptyResult(): LinearSearchResult {
return { issues: [], maxResults: 25, isLast: true };
}
describe("useLinearIssueSearch — gating", () => {
it("does not fetch while disabled (integration not configured)", async () => {
resetSearchMock();
searchLinearIssuesMock.mockResolvedValue(emptyResult());
const { result } = renderHook(() => useLinearIssueSearch(WORKSPACE, "", "", "me", false));
await new Promise((r) => setTimeout(r, 300));
expect(searchLinearIssuesMock).not.toHaveBeenCalled();
expect(result.current.loading).toBe(false);
expect(result.current.items).toEqual([]);
});
it("does not fetch when workspaceId is missing even if enabled", async () => {
resetSearchMock();
searchLinearIssuesMock.mockResolvedValue(emptyResult());
renderHook(() => useLinearIssueSearch(undefined, "", "", "me", true));
await new Promise((r) => setTimeout(r, 300));
expect(searchLinearIssuesMock).not.toHaveBeenCalled();
});
it("fetches once enabled flips from false to true", async () => {
resetSearchMock();
searchLinearIssuesMock.mockResolvedValue(emptyResult());
const { rerender } = renderHook(
({ enabled }: { enabled: boolean }) => useLinearIssueSearch(WORKSPACE, "", "", "me", enabled),
{ initialProps: { enabled: false } },
);
await new Promise((r) => setTimeout(r, 300));
expect(searchLinearIssuesMock).not.toHaveBeenCalled();
rerender({ enabled: true });
await waitFor(() => expect(searchLinearIssuesMock).toHaveBeenCalled());
});
});
describe("useLinearIssueSearch — fetch wiring", () => {
it("loads issues when enabled and forwards workspace + filters", async () => {
resetSearchMock();
const issue = fakeIssue();
searchLinearIssuesMock.mockResolvedValue({ issues: [issue], maxResults: 25, isLast: true });
const { result } = renderHook(() => useLinearIssueSearch(WORKSPACE, "bug", "ENG", "me", true));
await waitFor(() => expect(result.current.items).toEqual([issue]));
const [params, options] = searchLinearIssuesMock.mock.calls[0] as [
Record<string, unknown>,
Record<string, unknown>,
];
expect(params.query).toBe("bug");
expect(params.teamKey).toBe("ENG");
expect(params.assigned).toBe("me");
expect(options.workspaceId).toBe(WORKSPACE);
expect(result.current.loading).toBe(false);
});
it("surfaces error message without items", async () => {
resetSearchMock();
// Pre-attach a catch so the rejected promise is never treated as an
// unhandled rejection during the debounce window; the hook still observes
// the rejection when it awaits the same promise.
const rejected = Promise.reject(new Error("boom"));
rejected.catch(() => {});
searchLinearIssuesMock.mockReturnValue(rejected);
const { result } = renderHook(() => useLinearIssueSearch(WORKSPACE, "", "", "me", true));
await waitFor(() => expect(result.current.error).toBe("boom"));
expect(result.current.items).toEqual([]);
});
});