7de788ceed
* feat: add cross-integration entity references * fix(web): anchor entity reference menu to composer * fix: keep Kandev tasks under @ mentions * fix: address entity reference review findings * test(e2e): use option role for prompt menu * test(e2e): update passthrough mention roles * fix: preserve queued references and paginate Azure PRs
219 lines
6.7 KiB
TypeScript
219 lines
6.7 KiB
TypeScript
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { EntityReferenceSearchGroup } from "@/lib/types/entity-reference";
|
|
|
|
const searchEntityReferencesMock = vi.fn();
|
|
const WORKSPACE_ID = "workspace-1";
|
|
|
|
vi.mock("@/lib/api/domains/mentions-api", () => ({
|
|
searchEntityReferences: (...args: unknown[]) => searchEntityReferencesMock(...args),
|
|
}));
|
|
|
|
import { useEntityReferenceSearch } from "./use-entity-reference-search";
|
|
|
|
const groups: EntityReferenceSearchGroup[] = [
|
|
{
|
|
source: "kandev_tasks",
|
|
provider: "kandev",
|
|
kind: "task",
|
|
display_name: "Kandev tasks",
|
|
kind_label: "Task",
|
|
status: "ok",
|
|
results: [
|
|
{
|
|
version: 1,
|
|
ref: "mention:v1:kandev:task:task-2",
|
|
provider: "kandev",
|
|
kind: "task",
|
|
id: "task-2",
|
|
title: "Repair authentication",
|
|
url: "/t/task-2",
|
|
scope: WORKSPACE_ID,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
source: "github_issues",
|
|
provider: "github",
|
|
kind: "issue",
|
|
display_name: "GitHub issues",
|
|
kind_label: "Issue",
|
|
status: "timeout",
|
|
results: [],
|
|
},
|
|
];
|
|
|
|
async function flush(): Promise<void> {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
searchEntityReferencesMock.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("useEntityReferenceSearch request lifecycle", () => {
|
|
it("debounces scoped queries for 250 ms and preserves partial provider groups", async () => {
|
|
searchEntityReferencesMock.mockResolvedValueOnce({ query: "auth", groups });
|
|
|
|
const { result } = renderHook(() =>
|
|
useEntityReferenceSearch({
|
|
workspaceId: WORKSPACE_ID,
|
|
sessionId: "session-1",
|
|
query: "auth",
|
|
limit: 5,
|
|
}),
|
|
);
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(249));
|
|
expect(searchEntityReferencesMock).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await flush();
|
|
});
|
|
|
|
expect(searchEntityReferencesMock).toHaveBeenCalledTimes(1);
|
|
expect(searchEntityReferencesMock).toHaveBeenCalledWith(
|
|
{
|
|
workspaceId: WORKSPACE_ID,
|
|
query: "auth",
|
|
limit: 5,
|
|
},
|
|
{ cache: "no-store", init: { signal: expect.any(AbortSignal) } },
|
|
);
|
|
expect(result.current.groups).toEqual(groups);
|
|
expect(result.current.isSearching).toBe(false);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
|
|
it("returns a safe retryable aggregate error and retries the current search", async () => {
|
|
searchEntityReferencesMock.mockRejectedValueOnce(new Error("private upstream details"));
|
|
const { result } = renderHook(() =>
|
|
useEntityReferenceSearch({
|
|
workspaceId: WORKSPACE_ID,
|
|
sessionId: "session-1",
|
|
query: "auth",
|
|
}),
|
|
);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await flush();
|
|
});
|
|
|
|
expect(result.current.groups).toEqual([]);
|
|
expect(result.current.error).toEqual({
|
|
message: "Reference search failed. Try again.",
|
|
retryable: true,
|
|
});
|
|
|
|
searchEntityReferencesMock.mockResolvedValueOnce({ query: "auth", groups });
|
|
act(() => result.current.retry());
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await flush();
|
|
});
|
|
|
|
expect(searchEntityReferencesMock).toHaveBeenCalledTimes(2);
|
|
expect(result.current.groups).toEqual(groups);
|
|
expect(result.current.error).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useEntityReferenceSearch scope guards", () => {
|
|
it("aborts and ignores stale work after workspace, session, or query changes", async () => {
|
|
let resolveStale: (value: {
|
|
query: string;
|
|
groups: EntityReferenceSearchGroup[];
|
|
}) => void = () => {};
|
|
const staleRequest = new Promise<{ query: string; groups: EntityReferenceSearchGroup[] }>(
|
|
(resolve) => {
|
|
resolveStale = resolve;
|
|
},
|
|
);
|
|
const staleGroups = [{ ...groups[0]!, source: "stale_workspace_tasks" }];
|
|
searchEntityReferencesMock
|
|
.mockReturnValueOnce(staleRequest)
|
|
.mockResolvedValueOnce({ query: "oauth", groups });
|
|
|
|
const { result, rerender } = renderHook(
|
|
(props: { workspaceId: string; sessionId: string; query: string }) =>
|
|
useEntityReferenceSearch(props),
|
|
{
|
|
initialProps: {
|
|
workspaceId: WORKSPACE_ID,
|
|
sessionId: "session-1",
|
|
query: "auth",
|
|
},
|
|
},
|
|
);
|
|
await act(async () => vi.advanceTimersByTimeAsync(250));
|
|
const staleSignal = searchEntityReferencesMock.mock.calls[0]![1].init.signal as AbortSignal;
|
|
|
|
rerender({ workspaceId: "workspace-2", sessionId: "session-2", query: "oauth" });
|
|
expect(staleSignal.aborted).toBe(true);
|
|
expect(result.current.groups).toEqual([]);
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(250);
|
|
await flush();
|
|
});
|
|
expect(result.current.groups).toEqual(groups);
|
|
|
|
await act(async () => {
|
|
resolveStale({ query: "auth", groups: staleGroups });
|
|
await flush();
|
|
});
|
|
expect(result.current.groups).toEqual(groups);
|
|
});
|
|
|
|
it("does not search without an active workspace or non-empty query", async () => {
|
|
searchEntityReferencesMock.mockResolvedValue({ query: "", groups: [] });
|
|
const initialProps: {
|
|
workspaceId: string | null;
|
|
query: string;
|
|
enabled?: boolean;
|
|
} = { workspaceId: null, query: "auth", enabled: true };
|
|
const { rerender } = renderHook(
|
|
(props: { workspaceId: string | null; query: string; enabled?: boolean }) =>
|
|
useEntityReferenceSearch(props),
|
|
{ initialProps },
|
|
);
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(500));
|
|
expect(searchEntityReferencesMock).not.toHaveBeenCalled();
|
|
|
|
rerender({ workspaceId: WORKSPACE_ID, query: " ", enabled: true });
|
|
await act(async () => vi.advanceTimersByTimeAsync(500));
|
|
expect(searchEntityReferencesMock).not.toHaveBeenCalled();
|
|
|
|
rerender({ workspaceId: WORKSPACE_ID, query: "auth", enabled: false });
|
|
await act(async () => vi.advanceTimersByTimeAsync(500));
|
|
expect(searchEntityReferencesMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports searching only after debounce while request remains in flight", async () => {
|
|
searchEntityReferencesMock.mockReturnValueOnce(new Promise(() => {}));
|
|
const { result } = renderHook(() =>
|
|
useEntityReferenceSearch({
|
|
workspaceId: WORKSPACE_ID,
|
|
sessionId: "session-1",
|
|
query: "auth",
|
|
}),
|
|
);
|
|
|
|
expect(result.current.isSearching).toBe(false);
|
|
await act(async () => vi.advanceTimersByTimeAsync(249));
|
|
expect(result.current.isSearching).toBe(false);
|
|
await act(async () => vi.advanceTimersByTimeAsync(1));
|
|
expect(result.current.isSearching).toBe(true);
|
|
});
|
|
});
|