import React from "react"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; 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", () => ({ apiGet: vi.fn(), })); const mockApiGet = apiClient.apiGet 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(); mockApiGet.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 => { const user = userEvent.setup(); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[["); await waitFor(() => { expect(screen.getByText(/Start typing to search/)).toBeInTheDocument(); }); }); it("should perform debounced search when typing query", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); 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 }, }; mockApiGet.mockResolvedValue(mockResults); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); // Should not call API immediately expect(mockApiGet).not.toHaveBeenCalled(); // Fast-forward 300ms vi.advanceTimersByTime(300); await waitFor(() => { expect(mockApiGet).toHaveBeenCalledWith("/api/knowledge/search?q=test&limit=10"); }); await waitFor(() => { expect(screen.getByText("Test Entry")).toBeInTheDocument(); }); vi.useRealTimers(); }); it("should navigate results with arrow keys", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); 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 }, }; mockApiGet.mockResolvedValue(mockResults); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); 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"); }); vi.useRealTimers(); }); it("should insert link on Enter key", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); 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 }, }; mockApiGet.mockResolvedValue(mockResults); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); 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]]"); }); vi.useRealTimers(); }); it("should insert link on click", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); 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 }, }; mockApiGet.mockResolvedValue(mockResults); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); 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]]"); }); vi.useRealTimers(); }); it("should close dropdown on Escape key", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); await waitFor(() => { expect(screen.getByText(/Start typing to search/)).toBeInTheDocument(); }); // Press Escape fireEvent.keyDown(textarea, { key: "Escape" }); await waitFor(() => { expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument(); }); vi.useRealTimers(); }); it("should close dropdown when closing brackets are typed", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); await waitFor(() => { expect(screen.getByText(/Start typing to search/)).toBeInTheDocument(); }); // Type closing brackets await user.type(textarea, "]]"); await waitFor(() => { expect(screen.queryByText(/Start typing to search/)).not.toBeInTheDocument(); }); vi.useRealTimers(); }); it("should show 'No entries found' when search returns no results", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); mockApiGet.mockResolvedValue({ data: [], meta: { total: 0, page: 1, limit: 10, totalPages: 0 }, }); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[nonexistent"); vi.advanceTimersByTime(300); await waitFor(() => { expect(screen.getByText("No entries found")).toBeInTheDocument(); }); vi.useRealTimers(); }); it("should show loading state while searching", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); // Mock a slow API response let resolveSearch: (value: unknown) => void; const searchPromise = new Promise((resolve) => { resolveSearch = resolve; }); mockApiGet.mockReturnValue(searchPromise as Promise); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); 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(); }); vi.useRealTimers(); }); it("should display summary preview for entries", async (): Promise => { vi.useFakeTimers(); const user = userEvent.setup({ delay: null }); 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 }, }; mockApiGet.mockResolvedValue(mockResults); render(); const textarea = textareaRef.current; textarea.focus(); await user.type(textarea, "[[test"); vi.advanceTimersByTime(300); await waitFor(() => { expect(screen.getByText("This is a helpful summary")).toBeInTheDocument(); }); vi.useRealTimers(); }); });