import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { hookFetch } from "@oh-my-pi/pi-utils"; import { runSearchQuery } from "../../src/web/search"; import { buildExaRequestBody, normalizeSearchType, searchExa, synthesizeAnswer, } from "../../src/web/search/providers/exa"; // ──────────────────────────────────────────────────────────── // Unit tests for pure helpers (no mocking needed) // ──────────────────────────────────────────────────────────── describe("normalizeSearchType", () => { it("returns 'auto' for undefined input", () => { expect(normalizeSearchType(undefined)).toBe("auto"); }); it("maps 'keyword' to 'fast'", () => { expect(normalizeSearchType("keyword")).toBe("fast"); }); it("passes through 'neural' unchanged", () => { expect(normalizeSearchType("neural")).toBe("neural"); }); it("passes through 'deep' unchanged", () => { expect(normalizeSearchType("deep")).toBe("deep"); }); it("passes through 'auto' unchanged", () => { expect(normalizeSearchType("auto")).toBe("auto"); }); it("passes through 'fast' unchanged", () => { expect(normalizeSearchType("fast")).toBe("fast"); }); }); describe("buildExaRequestBody", () => { it("builds correct minimal body with defaults", () => { const body = buildExaRequestBody({ query: "test query" }); expect(body).toEqual({ query: "test query", numResults: 10, type: "auto", contents: { summary: { query: "test query" } }, }); }); it("applies num_results override", () => { const body = buildExaRequestBody({ query: "q", num_results: 5 }); expect(body.numResults).toBe(5); }); it("normalizes keyword type to fast", () => { const body = buildExaRequestBody({ query: "q", type: "keyword" }); expect(body.type).toBe("fast"); }); it("includes domain filters when specified", () => { const body = buildExaRequestBody({ query: "q", include_domains: ["example.com"], exclude_domains: ["bad.com"], }); expect(body.includeDomains).toEqual(["example.com"]); expect(body.excludeDomains).toEqual(["bad.com"]); }); it("omits domain filters when arrays are empty", () => { const body = buildExaRequestBody({ query: "q", include_domains: [], exclude_domains: [], }); expect(body.includeDomains).toBeUndefined(); expect(body.excludeDomains).toBeUndefined(); }); it("includes date filters when specified", () => { const body = buildExaRequestBody({ query: "q", start_published_date: "2024-01-01", end_published_date: "2024-12-31", }); expect(body.startPublishedDate).toBe("2024-01-01"); expect(body.endPublishedDate).toBe("2024-12-31"); }); }); describe("synthesizeAnswer", () => { it("returns undefined when results array is empty", () => { expect(synthesizeAnswer([])).toBeUndefined(); }); it("returns undefined when no result has a summary", () => { const results = [ { title: "A", url: "https://a.com", summary: null }, { title: "B", url: "https://b.com", summary: undefined }, { title: "C", url: "https://c.com" }, ]; expect(synthesizeAnswer(results)).toBeUndefined(); }); it("returns undefined when summaries are empty strings", () => { const results = [ { title: "A", url: "https://a.com", summary: "" }, { title: "B", url: "https://b.com", summary: " " }, ]; expect(synthesizeAnswer(results)).toBeUndefined(); }); it("synthesizes answer from a single summary", () => { const results = [{ title: "Page One", url: "https://one.com", summary: "Summary of page one." }]; const answer = synthesizeAnswer(results); expect(answer).toBe("**Page One**: Summary of page one."); }); it("synthesizes answer from multiple summaries joined by double newlines", () => { const results = [ { title: "A", url: "https://a.com", summary: "Summary A" }, { title: "B", url: "https://b.com", summary: "Summary B" }, ]; const answer = synthesizeAnswer(results); expect(answer).toBe("**A**: Summary A\n\n**B**: Summary B"); }); it("skips results with missing summaries but includes ones that have them", () => { const results = [ { title: "NoSummary", url: "https://no.com", summary: null }, { title: "HasSummary", url: "https://yes.com", summary: "Good stuff" }, ]; const answer = synthesizeAnswer(results); expect(answer).toBe("**HasSummary**: Good stuff"); }); it("uses url as fallback title when title is missing", () => { const results = [{ url: "https://notitle.com", summary: "Content here" }]; const answer = synthesizeAnswer(results); expect(answer).toBe("**https://notitle.com**: Content here"); }); it("uses 'Untitled' when both title and url are missing", () => { const results = [{ summary: "Orphan content" }]; const answer = synthesizeAnswer(results); expect(answer).toBe("**Untitled**: Orphan content"); }); it("trims whitespace from summaries", () => { const results = [{ title: "T", url: "https://t.com", summary: " padded summary " }]; const answer = synthesizeAnswer(results); expect(answer).toBe("**T**: padded summary"); }); }); // ──────────────────────────────────────────────────────────── // Integration tests for searchExa (mock fetch + env) // ──────────────────────────────────────────────────────────── function makeMockExaResponse(overrides: Record = {}) { return { requestId: "req-123", resolvedSearchType: "auto", results: [ { title: "Page Alpha", url: "https://alpha.com", author: "Author A", publishedDate: "2024-06-01", text: "Full text of alpha", highlights: ["highlight alpha"], summary: "Alpha is about X.", }, { title: "Page Beta", url: "https://beta.com", author: null, publishedDate: null, text: null, highlights: null, summary: "Beta covers Y.", }, { title: "Page Gamma", url: "https://gamma.com", author: "Author G", publishedDate: "2024-07-15", text: "Gamma text", highlights: ["gamma hl"], summary: "Gamma discusses Z.", }, ], ...overrides, }; } describe("searchExa", () => { let capturedRequestBody: Record | null = null; beforeEach(() => { capturedRequestBody = null; process.env.EXA_API_KEY = "test-key-123"; }); afterEach(() => { vi.restoreAllMocks(); delete process.env.EXA_API_KEY; }); function mockFetch(responseBody: unknown, status = 200): Disposable { return hookFetch((_url, init) => { if (init?.body) { capturedRequestBody = JSON.parse(init.body as string); } return new Response(JSON.stringify(responseBody), { status, headers: { "Content-Type": "application/json" }, }); }); } it("populates answer from per-result summaries", async () => { using _hook = mockFetch(makeMockExaResponse()); const result = await searchExa({ query: "test query" }); expect(result.provider).toBe("exa"); expect(result.answer).toBeDefined(); expect(result.answer).toContain("**Page Alpha**: Alpha is about X."); expect(result.answer).toContain("**Page Beta**: Beta covers Y."); expect(result.answer).toContain("**Page Gamma**: Gamma discusses Z."); expect(result.requestId).toBe("req-123"); }); it("returns answer=undefined when no summaries are present", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [{ title: "No Summary", url: "https://nosummary.com", text: "some text" }] }), ); const result = await searchExa({ query: "no answer query" }); expect(result.provider).toBe("exa"); expect(result.answer).toBeUndefined(); expect(result.sources).toHaveLength(1); }); it("returns answer=undefined when results array is empty", async () => { using _hook = mockFetch(makeMockExaResponse({ results: [] })); const result = await searchExa({ query: "empty" }); expect(result.answer).toBeUndefined(); expect(result.sources).toHaveLength(0); }); it("returns answer=undefined when results is missing from response", async () => { using _hook = mockFetch({ requestId: "req-empty" }); const result = await searchExa({ query: "nothing" }); expect(result.answer).toBeUndefined(); expect(result.sources).toHaveLength(0); }); it("sends contents.summary in request body", async () => { using _hook = mockFetch(makeMockExaResponse()); await searchExa({ query: "check body" }); expect(capturedRequestBody).toBeDefined(); expect(capturedRequestBody!.contents).toEqual({ summary: { query: "check body" } }); }); it("sends correct full request shape", async () => { using _hook = mockFetch(makeMockExaResponse()); await searchExa({ query: "shape test", num_results: 5, type: "neural" }); expect(capturedRequestBody).toEqual({ query: "shape test", numResults: 5, type: "neural", contents: { summary: { query: "shape test" } }, }); }); it("prefers summary over text for snippet field", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [{ title: "Has Both", url: "https://both.com", text: "full text here", summary: "summary here" }], }), ); const result = await searchExa({ query: "snippet test" }); expect(result.sources[0].snippet).toBe("summary here"); }); it("falls back to text when summary is null", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [{ title: "Text Only", url: "https://text.com", text: "fallback text", summary: null }], }), ); const result = await searchExa({ query: "fallback" }); expect(result.sources[0].snippet).toBe("fallback text"); }); it("falls back to highlights when both summary and text are null", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [ { title: "Highlight Only", url: "https://hl.com", text: null, summary: null, highlights: ["hl1", "hl2"], }, ], }), ); const result = await searchExa({ query: "highlights" }); expect(result.sources[0].snippet).toBe("hl1 hl2"); }); it("skips results without url", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [ { title: "No URL", url: null, summary: "orphan" }, { title: "Has URL", url: "https://valid.com", summary: "valid" }, ], }), ); const result = await searchExa({ query: "url filter" }); expect(result.sources).toHaveLength(1); expect(result.sources[0].url).toBe("https://valid.com"); }); it("falls back to text when summary is empty string (not just null)", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [{ title: "Empty Summary", url: "https://empty.com", text: "real text", summary: "" }], }), ); const result = await searchExa({ query: "empty summary fallback" }); expect(result.sources[0].snippet).toBe("real text"); }); it("does not include url-less results in synthesized answer", async () => { using _hook = mockFetch( makeMockExaResponse({ results: [ { title: "No URL", url: null, summary: "ghost summary" }, { title: "Has URL", url: "https://valid.com", summary: "real summary" }, ], }), ); const result = await searchExa({ query: "url filter answer" }); expect(result.answer).toBeDefined(); expect(result.answer).not.toContain("ghost summary"); expect(result.answer).toContain("**Has URL**: real summary"); }); it("uses Exa MCP when API key is missing", async () => { delete process.env.EXA_API_KEY; const fetchSpy = vi.fn(async (_input, _init, _next) => { return new Response(JSON.stringify({ jsonrpc: "2.0", id: "mcp-1", result: makeMockExaResponse() }), { status: 200, headers: { "Content-Type": "application/json" }, }); }); using _hook = hookFetch(fetchSpy); const result = await searchExa({ query: "no key" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(3); const calledUrl = String(fetchSpy.mock.calls[0][0]); expect(calledUrl).toContain("https://mcp.exa.ai/mcp"); expect(calledUrl).toContain("tools=web_search_exa"); expect(calledUrl).not.toContain("exaApiKey="); }); it("accepts MCP structuredContent search payloads when API key is missing", async () => { delete process.env.EXA_API_KEY; using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-structured", result: { structuredContent: makeMockExaResponse() }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); const result = await searchExa({ query: "structured payload" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(3); expect(result.answer).toContain("**Page Alpha**: Alpha is about X."); }); it("accepts MCP text content JSON payloads when API key is missing", async () => { delete process.env.EXA_API_KEY; const payload = makeMockExaResponse(); const fetchSpy = vi.fn(async (_input, _init, _next) => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-content", result: { content: [{ type: "text", text: JSON.stringify(payload) }], }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); using _hook = hookFetch(fetchSpy); const result = await searchExa({ query: "content payload" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(3); expect(result.answer).toContain("**Page Beta**: Beta covers Y."); const calledUrl = String(fetchSpy.mock.calls[0][0]); expect(calledUrl).not.toContain("exaApiKey="); }); it("accepts MCP text content plain-text payloads when API key is missing", async () => { delete process.env.EXA_API_KEY; const payloadText = [ "Title: Plain Alpha", "URL: https://plain-alpha.com", "Author: Alpha Author", "Published Date: 2024-01-02", "Text: Alpha snippet", "", "Title: Plain Beta", "URL: https://plain-beta.com", "Text: Beta snippet", ].join("\n"); const fetchSpy = vi.fn(async (_input, _init, _next) => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-content-plain-text", result: { content: [{ type: "text", text: payloadText }] }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); using _hook = hookFetch(fetchSpy); const result = await searchExa({ query: "plain text content payload" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(2); expect(result.sources[0]).toMatchObject({ title: "Plain Alpha", url: "https://plain-alpha.com", author: "Alpha Author", snippet: "Alpha snippet", publishedDate: "2024-01-02", }); expect(result.sources[1]).toMatchObject({ title: "Plain Beta", url: "https://plain-beta.com", snippet: "Beta snippet", }); expect(result.answer).toBeUndefined(); const calledUrl = String(fetchSpy.mock.calls[0][0]); expect(calledUrl).not.toContain("exaApiKey="); }); it("splits MCP plain-text records with CRLF line endings", async () => { delete process.env.EXA_API_KEY; const payloadText = [ "Title: CRLF Alpha", "URL: https://crlf-alpha.com", "Text: First result", "", "Title: CRLF Beta", "URL: https://crlf-beta.com", "Text: Second result", ].join("\r\n"); using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-content-crlf", result: { content: [{ type: "text", text: payloadText }] }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); const result = await searchExa({ query: "crlf payload" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(2); expect(result.sources[0]?.url).toBe("https://crlf-alpha.com"); expect(result.sources[1]?.url).toBe("https://crlf-beta.com"); }); it("keeps 'Title:' lines inside Text body when parsing MCP plain-text content", async () => { delete process.env.EXA_API_KEY; const payloadText = [ "Title: Plain Alpha", "URL: https://plain-alpha.com", "Text: Alpha line 1", "Title: heading inside body", "Alpha line 2", "", "Title: Plain Beta", "URL: https://plain-beta.com", "Text: Beta snippet", ].join("\n"); using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-content-embedded-title", result: { content: [{ type: "text", text: payloadText }] }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); const result = await searchExa({ query: "embedded title line" }); expect(result.provider).toBe("exa"); expect(result.sources).toHaveLength(2); expect(result.sources[0]?.snippet).toContain("Title: heading inside body"); expect(result.sources[0]?.snippet).toContain("Alpha line 2"); }); it("runSearchQuery with provider=exa succeeds without EXA_API_KEY for MCP plain text content", async () => { delete process.env.EXA_API_KEY; const payloadText = [ "Title: Result One", "URL: https://result-one.com", "Text: First plain-text result", "", "Title: Result Two", "URL: https://result-two.com", "Text: Second plain-text result", ].join("\n"); using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-tool-plain-text", result: { content: [{ type: "text", text: payloadText }] }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); const result = await runSearchQuery({ query: "provider exa plain text", provider: "exa" }); expect(result.details.error).toBeUndefined(); expect(result.details.response.provider).toBe("exa"); expect(result.details.response.sources).toHaveLength(2); }); it("runSearchQuery with provider=exa succeeds without EXA_API_KEY for MCP structuredContent", async () => { delete process.env.EXA_API_KEY; using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-tool", result: { structuredContent: makeMockExaResponse() }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); const result = await runSearchQuery({ query: "provider exa", provider: "exa" }); expect(result.details.error).toBeUndefined(); expect(result.details.response.provider).toBe("exa"); expect(result.content[0]?.text).toContain("3 sources"); }); it("throws clear error when MCP content payload is not parseable JSON", async () => { delete process.env.EXA_API_KEY; using _hook = hookFetch(async () => { return new Response( JSON.stringify({ jsonrpc: "2.0", id: "mcp-bad-content", result: { content: [{ type: "text", text: "not-json" }], }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); }); await expect(searchExa({ query: "bad content" })).rejects.toThrow( "Exa MCP search returned unexpected response shape.", ); }); it("throws SearchProviderError on non-ok HTTP response", async () => { using _hook = mockFetch("Forbidden", 403); await expect(searchExa({ query: "forbidden" })).rejects.toThrow("exa: 403 forbidden"); }); });