/* 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; describe("LinkAutocomplete", (): void => { let textareaRef: React.RefObject; let onInsertMock: ReturnType; 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(); expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument(); }); it("should show dropdown when typing [[", async (): Promise => { render(); 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 => { vi.useFakeTimers(); mockApiRequest.mockResolvedValue({ data: [], meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, }); render(); 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 => { vi.useFakeTimers(); mockApiRequest.mockResolvedValue({ data: [], meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, }); render(); 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 => { vi.useFakeTimers(); mockApiRequest.mockResolvedValue({ data: [], meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, }); const { unmount } = render( ); 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 => { vi.useFakeTimers(); mockApiRequest.mockRejectedValue(new Error("Network error")); render(); 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 => { vi.useFakeTimers(); // First search fails mockApiRequest.mockRejectedValueOnce(new Error("Network error")); render(); 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: "

Content

", 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 => { vi.useFakeTimers(); // Make the API reject with an AbortError const abortError = new DOMException("The operation was aborted.", "AbortError"); mockApiRequest.mockRejectedValue(abortError); render(); 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 => { const mockResults = { data: [ { id: "1", slug: "test-entry", title: "Test Entry", summary: "A test entry", workspaceId: "workspace-1", content: "Content", contentHtml: "

Content

", 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(); 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 => { const mockResults = { data: [ { id: "1", slug: "entry-one", title: "Entry One", summary: "First entry", workspaceId: "workspace-1", content: "Content", contentHtml: "

Content

", 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: "

Content

", 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(); 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 => { const mockResults = { data: [ { id: "1", slug: "test-entry", title: "Test Entry", summary: "A test entry", workspaceId: "workspace-1", content: "Content", contentHtml: "

Content

", 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(); 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 => { const mockResults = { data: [ { id: "1", slug: "test-entry", title: "Test Entry", summary: "A test entry", workspaceId: "workspace-1", content: "Content", contentHtml: "

Content

", 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(); 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 => { render(); 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 => { render(); 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 => { mockApiRequest.mockResolvedValue({ data: [], meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, }); render(); 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 => { // 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(); 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 => { const mockResults = { data: [ { id: "1", slug: "test-entry", title: "Test Entry", summary: "This is a helpful summary", workspaceId: "workspace-1", content: "Content", contentHtml: "

Content

", 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(); 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(); }); }); });