Files
stack/apps/web/src/components/knowledge/__tests__/LinkAutocomplete.test.tsx
T
Jason Woltje cab8d690ab fix(#411): complete 2026-02-17 remediation sweep
Apply RLS context at task service boundaries, harden orchestrator/web integration and session startup behavior, re-enable targeted frontend tests, and lock vulnerable transitive dependencies so QA and security gates pass cleanly.
2026-02-17 14:19:15 -06:00

745 lines
22 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import React from "react";
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { LinkAutocomplete } from "../LinkAutocomplete";
import * as apiClient from "@/lib/api/client";
// Mock the API client
vi.mock("@/lib/api/client", () => ({
apiRequest: vi.fn(),
}));
const mockApiRequest = apiClient.apiRequest as ReturnType<typeof vi.fn>;
describe("LinkAutocomplete", (): void => {
let textareaRef: React.RefObject<HTMLTextAreaElement>;
let onInsertMock: ReturnType<typeof vi.fn>;
beforeEach((): void => {
// Create a real textarea element
const textarea = document.createElement("textarea");
textarea.style.width = "500px";
textarea.style.height = "300px";
document.body.appendChild(textarea);
textareaRef = { current: textarea };
onInsertMock = vi.fn();
// Reset mocks
vi.clearAllMocks();
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
});
afterEach((): void => {
// Clean up
if (textareaRef.current) {
document.body.removeChild(textareaRef.current);
}
vi.clearAllTimers();
});
it("should not show dropdown initially", (): void => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument();
});
it("should show dropdown when typing [[", async (): Promise<void> => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[ by setting value and triggering input event
act(() => {
textarea.value = "[[";
textarea.setSelectionRange(2, 2);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText(/Start typing to search/)).toBeInTheDocument();
});
});
it("should pass an AbortSignal to apiRequest for cancellation", async (): Promise<void> => {
vi.useFakeTimers();
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[abc
act(() => {
textarea.value = "[[abc";
textarea.setSelectionRange(5, 5);
fireEvent.input(textarea);
});
// Advance past debounce to fire the search
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Verify apiRequest was called with a signal
expect(mockApiRequest).toHaveBeenCalledTimes(1);
const callArgs = mockApiRequest.mock.calls[0] as [
string,
{ method: string; signal: AbortSignal },
];
expect(callArgs[1]).toHaveProperty("signal");
expect(callArgs[1].signal).toBeInstanceOf(AbortSignal);
vi.useRealTimers();
});
it("should abort previous in-flight request when a new search fires", async (): Promise<void> => {
vi.useFakeTimers();
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// First search: type [[foo
act(() => {
textarea.value = "[[foo";
textarea.setSelectionRange(5, 5);
fireEvent.input(textarea);
});
// Advance past debounce to fire the first search
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(mockApiRequest).toHaveBeenCalledTimes(1);
const firstCallArgs = mockApiRequest.mock.calls[0] as [
string,
{ method: string; signal: AbortSignal },
];
const firstSignal = firstCallArgs[1].signal;
expect(firstSignal.aborted).toBe(false);
// Second search: type [[foobar (user continues typing)
act(() => {
textarea.value = "[[foobar";
textarea.setSelectionRange(8, 8);
fireEvent.input(textarea);
});
// The first signal should be aborted immediately when debouncedSearch fires again
// (abort happens before the timeout, in the debounce function itself)
expect(firstSignal.aborted).toBe(true);
// Advance past debounce to fire the second search
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(mockApiRequest).toHaveBeenCalledTimes(2);
const secondCallArgs = mockApiRequest.mock.calls[1] as [
string,
{ method: string; signal: AbortSignal },
];
const secondSignal = secondCallArgs[1].signal;
expect(secondSignal.aborted).toBe(false);
vi.useRealTimers();
});
it("should abort in-flight request on unmount", async (): Promise<void> => {
vi.useFakeTimers();
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
const { unmount } = render(
<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />
);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Trigger a search
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
// Advance past debounce
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(mockApiRequest).toHaveBeenCalledTimes(1);
const callArgs = mockApiRequest.mock.calls[0] as [
string,
{ method: string; signal: AbortSignal },
];
const signal = callArgs[1].signal;
expect(signal.aborted).toBe(false);
// Unmount the component - should abort in-flight request
unmount();
expect(signal.aborted).toBe(true);
vi.useRealTimers();
});
it("should show error message when search fails", async (): Promise<void> => {
vi.useFakeTimers();
mockApiRequest.mockRejectedValue(new Error("Network error"));
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[fail
act(() => {
textarea.value = "[[fail";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
// Advance past debounce to fire the search
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Allow microtasks (promise rejection handler) to settle
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Should show PDA-friendly error message instead of "No entries found"
expect(screen.getByText("Search unavailable — please try again")).toBeInTheDocument();
// Verify "No entries found" is NOT shown (error takes precedence)
expect(screen.queryByText("No entries found")).not.toBeInTheDocument();
vi.useRealTimers();
});
it("should clear error message on successful search", async (): Promise<void> => {
vi.useFakeTimers();
// First search fails
mockApiRequest.mockRejectedValueOnce(new Error("Network error"));
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Trigger failing search
act(() => {
textarea.value = "[[fail";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Allow microtasks (promise rejection handler) to settle
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText("Search unavailable — please try again")).toBeInTheDocument();
// Second search succeeds
mockApiRequest.mockResolvedValueOnce({
data: [
{
id: "1",
slug: "test-entry",
title: "Test Entry",
summary: "A test entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
});
// Trigger successful search
act(() => {
textarea.value = "[[success";
textarea.setSelectionRange(9, 9);
fireEvent.input(textarea);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Allow microtasks (promise resolution handler) to settle
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
// Error message should be gone, results should show
expect(screen.queryByText("Search unavailable — please try again")).not.toBeInTheDocument();
expect(screen.getByText("Test Entry")).toBeInTheDocument();
vi.useRealTimers();
});
it("should not show error for aborted requests", async (): Promise<void> => {
vi.useFakeTimers();
// Make the API reject with an AbortError
const abortError = new DOMException("The operation was aborted.", "AbortError");
mockApiRequest.mockRejectedValue(abortError);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[abc
act(() => {
textarea.value = "[[abc";
textarea.setSelectionRange(5, 5);
fireEvent.input(textarea);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
// Should NOT show error message for aborted requests
// Allow a tick for the catch to process
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.queryByText("Search unavailable — please try again")).not.toBeInTheDocument();
vi.useRealTimers();
});
it("should perform debounced search when typing query", async (): Promise<void> => {
const mockResults = {
data: [
{
id: "1",
slug: "test-entry",
title: "Test Entry",
summary: "A test entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
};
mockApiRequest.mockResolvedValue(mockResults);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
// Should not call API immediately
expect(mockApiRequest).not.toHaveBeenCalled();
await waitFor(() => {
expect(mockApiRequest).toHaveBeenCalledWith(
"/api/knowledge/search?q=test&limit=10",
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expect.objectContaining({ method: "GET", signal: expect.any(AbortSignal) })
);
});
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
});
it("should navigate results with arrow keys", async (): Promise<void> => {
const mockResults = {
data: [
{
id: "1",
slug: "entry-one",
title: "Entry One",
summary: "First entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
{
id: "2",
slug: "entry-two",
title: "Entry Two",
summary: "Second entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 2, page: 1, limit: 10, totalPages: 1 },
};
mockApiRequest.mockResolvedValue(mockResults);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("Entry One")).toBeInTheDocument();
});
// First item should be selected (highlighted)
const firstItem = screen.getByText("Entry One").closest("li");
expect(firstItem).toHaveClass("bg-blue-50");
// Press ArrowDown
fireEvent.keyDown(textarea, { key: "ArrowDown" });
// Second item should now be selected
await waitFor(() => {
const secondItem = screen.getByText("Entry Two").closest("li");
expect(secondItem).toHaveClass("bg-blue-50");
});
// Press ArrowUp
fireEvent.keyDown(textarea, { key: "ArrowUp" });
// First item should be selected again
await waitFor(() => {
const firstItem = screen.getByText("Entry One").closest("li");
expect(firstItem).toHaveClass("bg-blue-50");
});
});
it("should insert link on Enter key", async (): Promise<void> => {
const mockResults = {
data: [
{
id: "1",
slug: "test-entry",
title: "Test Entry",
summary: "A test entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
};
mockApiRequest.mockResolvedValue(mockResults);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
// Press Enter to select
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
});
});
it("should insert link on click", async (): Promise<void> => {
const mockResults = {
data: [
{
id: "1",
slug: "test-entry",
title: "Test Entry",
summary: "A test entry",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
};
mockApiRequest.mockResolvedValue(mockResults);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("Test Entry")).toBeInTheDocument();
});
// Click on the result
fireEvent.click(screen.getByText("Test Entry"));
await waitFor(() => {
expect(onInsertMock).toHaveBeenCalledWith("[[test-entry|Test Entry]]");
});
});
it("should close dropdown on Escape key", async (): Promise<void> => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
});
// Press Escape
fireEvent.keyDown(textarea, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
});
});
it("should close dropdown when closing brackets are typed", async (): Promise<void> => {
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("↑↓ Navigate • Enter Select • Esc Cancel")).toBeInTheDocument();
});
// Type closing brackets
act(() => {
textarea.value = "[[test]]";
textarea.setSelectionRange(8, 8);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.queryByText("↑↓ Navigate • Enter Select • Esc Cancel")).not.toBeInTheDocument();
});
});
it("should show 'No entries found' when search returns no results", async (): Promise<void> => {
mockApiRequest.mockResolvedValue({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[nonexistent
act(() => {
textarea.value = "[[nonexistent";
textarea.setSelectionRange(13, 13);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("No entries found")).toBeInTheDocument();
});
});
it("should show loading state while searching", async (): Promise<void> => {
// Mock a slow API response
let resolveSearch: (value: {
data: unknown[];
meta: { total: number; page: number; limit: number; totalPages: number };
}) => void = () => undefined;
const searchPromise = new Promise<{
data: unknown[];
meta: { total: number; page: number; limit: number; totalPages: number };
}>((resolve) => {
resolveSearch = resolve;
});
mockApiRequest.mockReturnValue(searchPromise);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("Searching...")).toBeInTheDocument();
});
// Resolve the search
resolveSearch({
data: [],
meta: { total: 0, page: 1, limit: 10, totalPages: 0 },
});
await waitFor(() => {
expect(screen.queryByText("Searching...")).not.toBeInTheDocument();
});
});
it("should display summary preview for entries", async (): Promise<void> => {
const mockResults = {
data: [
{
id: "1",
slug: "test-entry",
title: "Test Entry",
summary: "This is a helpful summary",
workspaceId: "workspace-1",
content: "Content",
contentHtml: "<p>Content</p>",
status: "PUBLISHED" as const,
visibility: "PUBLIC" as const,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
tags: [],
},
],
meta: { total: 1, page: 1, limit: 10, totalPages: 1 },
};
mockApiRequest.mockResolvedValue(mockResults);
render(<LinkAutocomplete textareaRef={textareaRef} onInsert={onInsertMock} />);
const textarea = textareaRef.current;
if (!textarea) throw new Error("Textarea not found");
// Simulate typing [[test
act(() => {
textarea.value = "[[test";
textarea.setSelectionRange(6, 6);
fireEvent.input(textarea);
});
await waitFor(() => {
expect(screen.getByText("This is a helpful summary")).toBeInTheDocument();
});
});
});