From 3703c7891884329a260152d1a02d6dd130d27f42 Mon Sep 17 00:00:00 2001 From: pitoi Date: Sun, 28 Jun 2026 20:04:41 +0000 Subject: [PATCH 1/3] Generated with Hive: Surface search errors in AddEdgePanel and API route --- .../unit/api/workspaces/lingo-routes.test.ts | 25 ++- .../components/lingo/AddEdgePanel.test.tsx | 178 ++++++++++++++++-- .../[slug]/lingo/nodes/search/route.ts | 4 +- .../learn/lingo/components/AddEdgePanel.tsx | 26 ++- 4 files changed, 204 insertions(+), 29 deletions(-) diff --git a/src/__tests__/unit/api/workspaces/lingo-routes.test.ts b/src/__tests__/unit/api/workspaces/lingo-routes.test.ts index 69b8300a97..5c16b02bdc 100644 --- a/src/__tests__/unit/api/workspaces/lingo-routes.test.ts +++ b/src/__tests__/unit/api/workspaces/lingo-routes.test.ts @@ -523,7 +523,7 @@ describe("GET /api/workspaces/[slug]/lingo/nodes/search", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - test("returns empty array when Jarvis returns non-2xx", async () => { + test("returns 502 with success:false when Jarvis returns non-2xx", async () => { mockGetWorkspaceSwarmAccess.mockResolvedValueOnce({ success: true, data: SWARM_DATA }); mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ error: "bad request" }), { status: 400 }), @@ -532,19 +532,34 @@ describe("GET /api/workspaces/[slug]/lingo/nodes/search", () => { `http://localhost/api/workspaces/${SLUG}/lingo/nodes/search?q=Swarm`, ); const res = await GET(req, { params: Promise.resolve({ slug: SLUG }) }); - expect(res.status).toBe(200); + expect(res.status).toBe(502); const body = await res.json(); - expect(body.success).toBe(true); - expect(body.data).toEqual([]); + expect(body.success).toBe(false); + expect(body.error).toBe("Search unavailable"); }); - test("returns empty array when Jarvis fetch throws", async () => { + test("returns 502 with success:false when Jarvis fetch throws", async () => { mockGetWorkspaceSwarmAccess.mockResolvedValueOnce({ success: true, data: SWARM_DATA }); mockFetch.mockRejectedValueOnce(new Error("Network error")); const req = makeAuthenticatedRequest( `http://localhost/api/workspaces/${SLUG}/lingo/nodes/search?q=test`, ); const res = await GET(req, { params: Promise.resolve({ slug: SLUG }) }); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.error).toBe("Search unavailable"); + }); + + test("swarm-not-configured still returns success:true with empty array (status 200)", async () => { + mockGetWorkspaceSwarmAccess.mockResolvedValueOnce({ + success: false, + error: { type: "SWARM_NOT_CONFIGURED" }, + }); + const req = makeAuthenticatedRequest( + `http://localhost/api/workspaces/${SLUG}/lingo/nodes/search?q=test`, + ); + const res = await GET(req, { params: Promise.resolve({ slug: SLUG }) }); expect(res.status).toBe(200); const body = await res.json(); expect(body.success).toBe(true); diff --git a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx index fb16a7e4a4..cf500fe08a 100644 --- a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx +++ b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx @@ -120,11 +120,11 @@ async function renderWithSearch(nodes: ReturnType[]) { mockFetch .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), // listing on open + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), // listing on open }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: nodes }), // search after typing + json: () => Promise.resolve({ success: true, data: nodes }), // search after typing }); render(); @@ -157,7 +157,7 @@ describe("AddEdgePanel", () => { it("calls listing endpoint immediately on mount", async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); render(); @@ -175,7 +175,7 @@ describe("AddEdgePanel", () => { const node = makeNode({ ref_id: "listed-1", name: "Listed Node", node_type: "Lingo" }); mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [node], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [node], hasMore: false } }), }); render(); @@ -192,17 +192,17 @@ describe("AddEdgePanel", () => { // First open → listing mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); // After typing → search mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: [] }), + json: () => Promise.resolve({ success: true, data: [] }), }); // After clearing → listing again mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); render(); @@ -233,7 +233,7 @@ describe("AddEdgePanel", () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); render(); @@ -256,12 +256,13 @@ describe("AddEdgePanel", () => { mockFetch .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ + success: true, data: [makeNode({ ref_id: "result-1", name: "Result Node" })], }), }); @@ -292,11 +293,11 @@ describe("AddEdgePanel", () => { mockFetch .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: [] }), + json: () => Promise.resolve({ success: true, data: [] }), }); render(); @@ -422,7 +423,7 @@ describe("AddEdgePanel", () => { it("shows all COMMON_EDGE_TYPES when no target is selected", async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); render(); @@ -466,8 +467,8 @@ describe("AddEdgePanel", () => { const lingoNode = makeNode({ ref_id: "lingo-2", name: "Lingo Node", node_type: "Lingo" }); mockFetch - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }) }) - .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [jdNode, lingoNode] }) }); + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, data: [jdNode, lingoNode] }) }); render(); await act(async () => { await Promise.resolve(); }); @@ -554,11 +555,150 @@ describe("AddEdgePanel", () => { }); }); + describe("Search error state", () => { + it("shows search-error when debounced search returns success:false", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }) + .mockResolvedValueOnce({ + ok: false, + json: () => Promise.resolve({ success: false, error: "Search unavailable" }), + }); + + render(); + await act(async () => { await Promise.resolve(); }); + + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "fail" } }); + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + + await waitFor(() => { + expect(screen.getByTestId("search-error")).toBeInTheDocument(); + expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it("shows search-error when debounced search fetch throws", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }) + .mockRejectedValueOnce(new Error("Network error")); + + render(); + await act(async () => { await Promise.resolve(); }); + + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "oops" } }); + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + + await waitFor(() => { + expect(screen.getByTestId("search-error")).toBeInTheDocument(); + expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it("clears searchError when query changes after an error", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }) + // first search → error + .mockRejectedValueOnce(new Error("Network error")) + // second search (query change) → still loading, we check error gone before resolve + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: [] }), + }); + + render(); + await act(async () => { await Promise.resolve(); }); + + // Trigger error + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument()); + + // Change query — error should clear (useEffect runs setSearchError(false) before debounce) + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "new" } }); + // After the synchronous state update (setSearchError(false)) but before debounce fires + await act(async () => { await Promise.resolve(); }); + + expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("clears searchError when panel is closed", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + + const onClose = vi.fn(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }) + .mockRejectedValueOnce(new Error("Network error")) + // after re-open + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }); + + const { rerender } = render(); + await act(async () => { await Promise.resolve(); }); + + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); + await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument()); + + // Close the panel via Cancel button + fireEvent.click(screen.getByText("Cancel")); + await act(async () => { await Promise.resolve(); }); + + // Re-open + rerender(); + await act(async () => { await Promise.resolve(); }); + + // searchError should be gone + expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it("shows search-error when listing endpoint (empty query) returns success:false", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: false, error: "Search unavailable" }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("search-error")).toBeInTheDocument(); + expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); + }); + }); + }); + describe("Confirm / POST edge", () => { it("confirm button is disabled when no target selected", async () => { mockFetch.mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); render(); @@ -573,12 +713,13 @@ describe("AddEdgePanel", () => { mockFetch .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), // listing on open + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), // listing on open }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ + success: true, data: [makeNode({ ref_id: "target-node", name: "Target" })], }), // search }) @@ -588,7 +729,7 @@ describe("AddEdgePanel", () => { }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), // listing after close resets query + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), // listing after close resets query }); const onEdgeCreated = vi.fn(); @@ -639,12 +780,13 @@ describe("AddEdgePanel", () => { mockFetch .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ data: { nodes: [], hasMore: false } }), + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }) .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ + success: true, data: [makeNode({ ref_id: "t1", name: "T1" })], }), }) diff --git a/src/app/api/workspaces/[slug]/lingo/nodes/search/route.ts b/src/app/api/workspaces/[slug]/lingo/nodes/search/route.ts index 3cbabfaa0d..e7ab970ca0 100644 --- a/src/app/api/workspaces/[slug]/lingo/nodes/search/route.ts +++ b/src/app/api/workspaces/[slug]/lingo/nodes/search/route.ts @@ -68,7 +68,7 @@ export async function GET( if (!response.ok) { console.warn(`[Lingo nodes/search] Jarvis returned ${response.status}`); - return NextResponse.json({ success: true, data: [] }); + return NextResponse.json({ success: false, error: "Search unavailable" }, { status: 502 }); } const data = await response.json(); @@ -83,6 +83,6 @@ export async function GET( return NextResponse.json({ success: true, data: nodes }); } catch (err) { console.error("[Lingo nodes/search] Jarvis fetch failed", err); - return NextResponse.json({ success: true, data: [] }); + return NextResponse.json({ success: false, error: "Search unavailable" }, { status: 502 }); } } diff --git a/src/app/w/[slug]/learn/lingo/components/AddEdgePanel.tsx b/src/app/w/[slug]/learn/lingo/components/AddEdgePanel.tsx index c759628cca..26ec66a756 100644 --- a/src/app/w/[slug]/learn/lingo/components/AddEdgePanel.tsx +++ b/src/app/w/[slug]/learn/lingo/components/AddEdgePanel.tsx @@ -54,6 +54,7 @@ export function AddEdgePanel({ const [edgeType, setEdgeType] = useState(DEFAULT_EDGE_TYPE); const [isSearching, setIsSearching] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [searchError, setSearchError] = useState(false); const debounceRef = useRef | null>(null); // Derive valid edge types based on selected target @@ -74,12 +75,17 @@ export function AddEdgePanel({ if (!isOpen) return; if (debounceRef.current) clearTimeout(debounceRef.current); + setSearchError(false); + if (!searchQuery.trim()) { setIsSearching(true); fetch(`/api/workspaces/${workspaceSlug}/lingo/nodes`) .then((r) => r.json()) - .then((json) => setSearchResults(Array.isArray(json.data?.nodes) ? json.data.nodes : [])) - .catch(() => setSearchResults([])) + .then((json) => { + if (!json.success) { setSearchError(true); setSearchResults([]); return; } + setSearchResults(Array.isArray(json.data?.nodes) ? json.data.nodes : []); + }) + .catch(() => { setSearchError(true); setSearchResults([]); }) .finally(() => setIsSearching(false)); return; } @@ -91,8 +97,14 @@ export function AddEdgePanel({ `/api/workspaces/${workspaceSlug}/lingo/nodes/search?q=${encodeURIComponent(searchQuery)}`, ); const data = await res.json(); - setSearchResults(Array.isArray(data?.data) ? data.data : []); + if (!data.success) { + setSearchError(true); + setSearchResults([]); + } else { + setSearchResults(Array.isArray(data?.data) ? data.data : []); + } } catch { + setSearchError(true); setSearchResults([]); } finally { setIsSearching(false); @@ -108,6 +120,7 @@ export function AddEdgePanel({ setSearchResults([]); setTargetNode(null); setEdgeType(DEFAULT_EDGE_TYPE); + setSearchError(false); onClose(); } @@ -202,7 +215,12 @@ export function AddEdgePanel({ ))} )} - {!isSearching && searchQuery.trim() && searchResults.length === 0 && ( + {!isSearching && searchError && ( +

+ Search unavailable — try again +

+ )} + {!isSearching && !searchError && searchQuery.trim() && searchResults.length === 0 && (

No nodes found for "{searchQuery}"

From 8ef32f0cefb8516ad0f0aac02a7ed830d36bb432 Mon Sep 17 00:00:00 2001 From: pitoi Date: Mon, 29 Jun 2026 17:52:23 +0000 Subject: [PATCH 2/3] Generated with Hive: Update AddEdgePanel tests for explicit search error handling --- .../components/lingo/AddEdgePanel.test.tsx | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx index 35d3ccf32a..0bbb1a6af6 100644 --- a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx +++ b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx @@ -84,6 +84,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); const defaultProps = { @@ -570,15 +571,13 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "fail" } }); - await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); - await waitFor(() => { - expect(screen.getByTestId("search-error")).toBeInTheDocument(); - expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); - }); + expect(screen.getByTestId("search-error")).toBeInTheDocument(); + expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); vi.useRealTimers(); }); @@ -594,15 +593,13 @@ describe("AddEdgePanel", () => { .mockRejectedValueOnce(new Error("Network error")); render(); - await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "oops" } }); - await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); - await waitFor(() => { - expect(screen.getByTestId("search-error")).toBeInTheDocument(); - expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); - }); + expect(screen.getByTestId("search-error")).toBeInTheDocument(); + expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); vi.useRealTimers(); }); @@ -615,25 +612,22 @@ describe("AddEdgePanel", () => { ok: true, json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }) - // first search → error .mockRejectedValueOnce(new Error("Network error")) - // second search (query change) → still loading, we check error gone before resolve .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, data: [] }), }); render(); - await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); // Trigger error fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); - await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); - await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument()); + await act(async () => { await vi.runAllTimersAsync(); }); + expect(screen.getByTestId("search-error")).toBeInTheDocument(); - // Change query — error should clear (useEffect runs setSearchError(false) before debounce) + // Change query — useEffect sets setSearchError(false) synchronously before debounce fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "new" } }); - // After the synchronous state update (setSearchError(false)) but before debounce fires await act(async () => { await Promise.resolve(); }); expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); @@ -652,18 +646,17 @@ describe("AddEdgePanel", () => { json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }) .mockRejectedValueOnce(new Error("Network error")) - // after re-open .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), }); const { rerender } = render(); - await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); - await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); - await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument()); + await act(async () => { await vi.runAllTimersAsync(); }); + expect(screen.getByTestId("search-error")).toBeInTheDocument(); // Close the panel via Cancel button fireEvent.click(screen.getByText("Cancel")); @@ -671,9 +664,8 @@ describe("AddEdgePanel", () => { // Re-open rerender(); - await act(async () => { await Promise.resolve(); }); + await act(async () => { await vi.runAllTimersAsync(); }); - // searchError should be gone expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); vi.useRealTimers(); From b73056f7c532b2e35296ffb22637f4b022a06f1a Mon Sep 17 00:00:00 2001 From: pitoi Date: Mon, 29 Jun 2026 18:37:33 +0000 Subject: [PATCH 3/3] Generated with Hive: Refactor AddEdgePanel tests for async fetch and search error handling --- .../components/lingo/AddEdgePanel.test.tsx | 183 +++++------------- 1 file changed, 52 insertions(+), 131 deletions(-) diff --git a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx index 0bbb1a6af6..942fd719cb 100644 --- a/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx +++ b/src/__tests__/unit/components/lingo/AddEdgePanel.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act, cleanup } from "@testing-library/react"; import React from "react"; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -69,7 +69,7 @@ vi.mock("@/components/ui/input", () => ({ // ─── Import after mocks ─────────────────────────────────────────────────────── -import { AddEdgePanel } from "@/app/w/[slug]/lingo/components/AddEdgePanel"; +import { AddEdgePanel } from "@/app/w/[slug]/learn/lingo/components/AddEdgePanel"; import { toast } from "sonner"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -78,10 +78,19 @@ let mockFetch: ReturnType; beforeEach(() => { mockFetch = vi.fn(); + // Default fallback: any unexpected/leftover fetch from a previous test resolves + // to an empty-but-valid listing response so it never consumes error-state mocks. + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), + }); vi.stubGlobal("fetch", mockFetch); }); -afterEach(() => { +afterEach(async () => { + // Unmount all components before restoring mocks so pending async chains + // in still-mounted components don't consume the next test's mock calls. + cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); @@ -115,9 +124,8 @@ function makeNode(overrides: Partial<{ } // Helper: render open panel, listing returns empty, search returns given nodes +// Uses real timers so the debounce+fetch chain fully completes before returning. async function renderWithSearch(nodes: ReturnType[]) { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -129,21 +137,15 @@ async function renderWithSearch(nodes: ReturnType[]) { }); render(); - await act(async () => { await Promise.resolve(); }); // let listing settle + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); // listing settled + fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "test" }, }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - await waitFor(() => { expect(screen.getByTestId("search-results")).toBeInTheDocument(); - }); - - vi.useRealTimers(); + }, { timeout: 2000 }); } // ─── Tests ───────────────────────────────────────────────────────────────────── @@ -188,8 +190,6 @@ describe("AddEdgePanel", () => { }); it("re-fetches listing when input is cleared", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - // First open → listing mockFetch.mockResolvedValueOnce({ ok: true, @@ -207,24 +207,21 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); - // Type something + // Type something — debounce fires after 300ms fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "hello" } }); - await act(async () => { vi.advanceTimersByTime(300); await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); - // Clear input + // Clear input — listing fires immediately fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "" } }); - await act(async () => { await Promise.resolve(); }); await waitFor(() => { const listingCalls = mockFetch.mock.calls.filter( (c) => (c[0] as string).endsWith("/lingo/nodes"), ); expect(listingCalls.length).toBe(2); - }); - - vi.useRealTimers(); + }, { timeout: 2000 }); }); }); @@ -238,22 +235,23 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); // listing settles + // Drain the listing fetch fully with runAllTimersAsync + await act(async () => { await vi.runAllTimersAsync(); }); const input = screen.getByTestId("node-search-input"); fireEvent.change(input, { target: { value: "test" } }); + // Advance only 200ms — debounce (300ms) should not fire yet act(() => { vi.advanceTimersByTime(200); }); // Only the listing call, no search call yet expect(mockFetch).toHaveBeenCalledTimes(1); - vi.useRealTimers(); + // Drain remaining timer so it doesn't leak into the next test + await act(async () => { await vi.runAllTimersAsync(); }); }); it("renders search results after search fires", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -269,28 +267,19 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "result" }, }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - await waitFor(() => { expect(screen.getByTestId("search-result-result-1")).toBeInTheDocument(); - }); - - vi.useRealTimers(); + }, { timeout: 2000 }); }); }); describe("Typed query", () => { it("search URL contains q= and no type= param", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -302,20 +291,13 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "pod" } }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - - await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2), { timeout: 2000 }); const searchUrl = mockFetch.mock.calls[1][0] as string; expect(searchUrl).toContain("q=pod"); expect(searchUrl).not.toContain("type="); - - vi.useRealTimers(); }); it("result card shows node_type badge (span.font-mono) and ref_id (p.font-mono)", async () => { @@ -462,8 +444,6 @@ describe("AddEdgePanel", () => { }); it("resets edgeType to first valid type when targetNode changes", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const jdNode = makeNode({ ref_id: "jd-2", name: "Def Node", node_type: "JargonDefinition" }); const lingoNode = makeNode({ ref_id: "lingo-2", name: "Lingo Node", node_type: "Lingo" }); @@ -472,32 +452,24 @@ describe("AddEdgePanel", () => { .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true, data: [jdNode, lingoNode] }) }); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "node" } }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - await waitFor(() => screen.getByTestId("search-result-jd-2")); + await waitFor(() => screen.getByTestId("search-result-jd-2"), { timeout: 2000 }); // Select JargonDefinition → edgeType should be HAS_DEFINITION fireEvent.click(screen.getByTestId("search-result-jd-2")); await waitFor(() => { const wrappers = screen.getAllByTestId("select-wrapper"); - const edgeWrapper = wrappers[0]; - expect(edgeWrapper).toHaveAttribute("data-value", "HAS_DEFINITION"); + expect(wrappers[0]).toHaveAttribute("data-value", "HAS_DEFINITION"); }); // Now select Lingo node → edgeType should reset to RELATED_TO fireEvent.click(screen.getByTestId("search-result-lingo-2")); await waitFor(() => { const wrappers = screen.getAllByTestId("select-wrapper"); - const edgeWrapper = wrappers[0]; - expect(edgeWrapper).toHaveAttribute("data-value", "RELATED_TO"); + expect(wrappers[0]).toHaveAttribute("data-value", "RELATED_TO"); }); - - vi.useRealTimers(); }); }); @@ -558,8 +530,6 @@ describe("AddEdgePanel", () => { describe("Search error state", () => { it("shows search-error when debounced search returns success:false", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -571,20 +541,15 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await vi.runAllTimersAsync(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "fail" } }); - await act(async () => { await vi.runAllTimersAsync(); }); - expect(screen.getByTestId("search-error")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument(), { timeout: 2000 }); expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); - - vi.useRealTimers(); }); it("shows search-error when debounced search fetch throws", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -593,20 +558,15 @@ describe("AddEdgePanel", () => { .mockRejectedValueOnce(new Error("Network error")); render(); - await act(async () => { await vi.runAllTimersAsync(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "oops" } }); - await act(async () => { await vi.runAllTimersAsync(); }); - expect(screen.getByTestId("search-error")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument(), { timeout: 2000 }); expect(screen.queryByTestId("no-results")).not.toBeInTheDocument(); - - vi.useRealTimers(); }); it("clears searchError when query changes after an error", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -619,25 +579,18 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await vi.runAllTimersAsync(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); // Trigger error fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); - await act(async () => { await vi.runAllTimersAsync(); }); - expect(screen.getByTestId("search-error")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument(), { timeout: 2000 }); // Change query — useEffect sets setSearchError(false) synchronously before debounce fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "new" } }); - await act(async () => { await Promise.resolve(); }); - - expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); - - vi.useRealTimers(); + await waitFor(() => expect(screen.queryByTestId("search-error")).not.toBeInTheDocument()); }); it("clears searchError when panel is closed", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - const onClose = vi.fn(); mockFetch @@ -652,23 +605,18 @@ describe("AddEdgePanel", () => { }); const { rerender } = render(); - await act(async () => { await vi.runAllTimersAsync(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "bad" } }); - await act(async () => { await vi.runAllTimersAsync(); }); - expect(screen.getByTestId("search-error")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByTestId("search-error")).toBeInTheDocument(), { timeout: 2000 }); - // Close the panel via Cancel button + // Close the panel via Cancel button — handleClose sets searchError to false fireEvent.click(screen.getByText("Cancel")); - await act(async () => { await Promise.resolve(); }); - // Re-open + // Re-open with fresh props rerender(); - await act(async () => { await vi.runAllTimersAsync(); }); - + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3)); expect(screen.queryByTestId("search-error")).not.toBeInTheDocument(); - - vi.useRealTimers(); }); it("shows search-error when listing endpoint (empty query) returns success:false", async () => { @@ -700,8 +648,6 @@ describe("AddEdgePanel", () => { }); it("calls POST with correct body on confirm", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -718,35 +664,23 @@ describe("AddEdgePanel", () => { .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), // POST - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, data: { nodes: [], hasMore: false } }), // listing after close resets query }); const onEdgeCreated = vi.fn(); const onClose = vi.fn(); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "target" }, }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - - await waitFor(() => screen.getByTestId("search-result-target-node")); + await waitFor(() => screen.getByTestId("search-result-target-node"), { timeout: 2000 }); fireEvent.click(screen.getByTestId("search-result-target-node")); - expect(screen.getByTestId("selected-target")).toBeInTheDocument(); - await act(async () => { - fireEvent.click(screen.getByTestId("confirm-add-edge")); - }); + fireEvent.click(screen.getByTestId("confirm-add-edge")); await waitFor(() => { expect(mockFetch).toHaveBeenCalledWith( @@ -762,13 +696,9 @@ describe("AddEdgePanel", () => { ); expect(onEdgeCreated).toHaveBeenCalled(); }); - - vi.useRealTimers(); }); it("shows error toast on POST failure", async () => { - vi.useFakeTimers({ shouldAdvanceTime: true }); - mockFetch .mockResolvedValueOnce({ ok: true, @@ -788,26 +718,17 @@ describe("AddEdgePanel", () => { }); render(); - await act(async () => { await Promise.resolve(); }); + await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1)); fireEvent.change(screen.getByTestId("node-search-input"), { target: { value: "T1" } }); - await act(async () => { - vi.advanceTimersByTime(300); - await Promise.resolve(); - }); - - await waitFor(() => screen.getByTestId("search-result-t1")); + await waitFor(() => screen.getByTestId("search-result-t1"), { timeout: 2000 }); fireEvent.click(screen.getByTestId("search-result-t1")); - await act(async () => { - fireEvent.click(screen.getByTestId("confirm-add-edge")); - }); + fireEvent.click(screen.getByTestId("confirm-add-edge")); await waitFor(() => { expect(toast.error).toHaveBeenCalledWith("Server error"); }); - - vi.useRealTimers(); }); }); });