6635129b7c
- Updated 8 test mock functions to accept unused parameters for API compatibility.
617 lines
20 KiB
TypeScript
617 lines
20 KiB
TypeScript
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("includes contents.summary with the search query", () => {
|
|
const body = buildExaRequestBody({ query: "how does React work" });
|
|
expect(body.contents).toEqual({ summary: { query: "how does React work" } });
|
|
});
|
|
|
|
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("limits to MAX_ANSWER_SUMMARIES (3) results", () => {
|
|
const results = [
|
|
{ title: "A", url: "https://a.com", summary: "SA" },
|
|
{ title: "B", url: "https://b.com", summary: "SB" },
|
|
{ title: "C", url: "https://c.com", summary: "SC" },
|
|
{ title: "D", url: "https://d.com", summary: "SD" },
|
|
{ title: "E", url: "https://e.com", summary: "SE" },
|
|
];
|
|
const answer = synthesizeAnswer(results)!;
|
|
expect(answer.split("\n\n")).toHaveLength(3);
|
|
expect(answer).toContain("**A**: SA");
|
|
expect(answer).toContain("**C**: SC");
|
|
expect(answer).not.toContain("**D**");
|
|
});
|
|
|
|
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<string, unknown> = {}) {
|
|
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<string, unknown> | 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 API error (403)");
|
|
});
|
|
});
|