Skip to content

Commit 7fa4fe7

Browse files
basilttclaude
andcommitted
test: add Tavily provider tests and update isWebSearchEnabled coverage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d36a8a8 commit 7fa4fe7

1 file changed

Lines changed: 264 additions & 1 deletion

File tree

tests/web-search.test.ts

Lines changed: 264 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
import {
23
describe,
34
test,
@@ -30,11 +31,12 @@ import {
3031
webSearchInterceptor,
3132
prepareWebSearchPayload,
3233
} from "~/services/web-search/interceptor"
34+
import * as tavilyModule from "~/services/web-search/tavily"
3335
import {
3436
WEB_SEARCH_TOOL_NAMES,
3537
WEB_SEARCH_FUNCTION_TOOL,
3638
} from "~/services/web-search/tool-definition"
37-
import { BraveSearchError } from "~/services/web-search/types"
39+
import { BraveSearchError, WebSearchError } from "~/services/web-search/types"
3840

3941
describe("WEB_SEARCH_TOOL_NAMES", () => {
4042
test("contains web_search", () => {
@@ -800,3 +802,264 @@ describe("isWebSearchEnabled", () => {
800802
}
801803
})
802804
})
805+
806+
describe("searchTavily — result formatting", () => {
807+
afterEach(() => {
808+
mock.restore()
809+
})
810+
811+
test("formats results mapping content to description", async () => {
812+
const mockResponse = {
813+
results: [
814+
{ title: "T1", url: "https://t.com/1", content: "C1" },
815+
{ title: "T2", url: "https://t.com/2", content: "C2" },
816+
],
817+
}
818+
spyOn(globalThis, "fetch").mockResolvedValue(
819+
new Response(JSON.stringify(mockResponse), { status: 200 }),
820+
)
821+
822+
const results = await tavilyModule.searchTavily("test query", "fake-key")
823+
expect(results).toHaveLength(2)
824+
expect(results[0]).toEqual({
825+
title: "T1",
826+
url: "https://t.com/1",
827+
description: "C1",
828+
})
829+
expect(results[1]?.url).toBe("https://t.com/2")
830+
})
831+
832+
test("returns empty array when results is empty", async () => {
833+
const mockResponse = { results: [] }
834+
spyOn(globalThis, "fetch").mockResolvedValue(
835+
new Response(JSON.stringify(mockResponse), { status: 200 }),
836+
)
837+
838+
const results = await tavilyModule.searchTavily("nothing here", "fake-key")
839+
expect(results).toHaveLength(0)
840+
})
841+
842+
test("returns empty array when results key is absent", async () => {
843+
const mockResponse = {}
844+
spyOn(globalThis, "fetch").mockResolvedValue(
845+
new Response(JSON.stringify(mockResponse), { status: 200 }),
846+
)
847+
848+
const results = await tavilyModule.searchTavily("nothing", "fake-key")
849+
expect(results).toHaveLength(0)
850+
})
851+
852+
test("sends Authorization: Bearer header", async () => {
853+
const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
854+
new Response(JSON.stringify({ results: [] }), { status: 200 }),
855+
)
856+
857+
await tavilyModule.searchTavily("q", "my-secret-key")
858+
859+
expect(fetchSpy).toHaveBeenCalled()
860+
// Take the most recent call — it's the one our searchTavily just made
861+
const lastCall = fetchSpy.mock.calls.at(-1)
862+
expect(lastCall).toBeDefined()
863+
const headers = lastCall?.[1]?.headers as Record<string, string>
864+
expect(headers["Authorization"]).toBe("Bearer my-secret-key")
865+
})
866+
})
867+
868+
describe("searchTavily — error handling", () => {
869+
afterEach(() => {
870+
mock.restore()
871+
})
872+
873+
test("throws WebSearchError on non-200 response", async () => {
874+
spyOn(globalThis, "fetch").mockResolvedValue(
875+
new Response("Unauthorized", { status: 401 }),
876+
)
877+
878+
let threw: unknown
879+
try {
880+
await tavilyModule.searchTavily("q", "bad-key")
881+
} catch (e) {
882+
threw = e
883+
}
884+
expect(threw).toBeInstanceOf(WebSearchError)
885+
})
886+
887+
test("WebSearchError reason includes status code on non-200", async () => {
888+
spyOn(globalThis, "fetch").mockResolvedValue(
889+
new Response("Unauthorized", { status: 401 }),
890+
)
891+
892+
let threw: unknown
893+
try {
894+
await tavilyModule.searchTavily("q", "bad-key")
895+
} catch (e) {
896+
threw = e
897+
}
898+
expect(threw).toBeInstanceOf(WebSearchError)
899+
expect((threw as WebSearchError).reason).toContain("401")
900+
})
901+
902+
test("throws WebSearchError on network failure", async () => {
903+
spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error"))
904+
905+
let threw: unknown
906+
try {
907+
await tavilyModule.searchTavily("q", "key")
908+
} catch (e) {
909+
threw = e
910+
}
911+
expect(threw).toBeInstanceOf(WebSearchError)
912+
})
913+
914+
test("throws WebSearchError with 'request timed out' reason on AbortError", async () => {
915+
spyOn(globalThis, "fetch").mockRejectedValue(
916+
Object.assign(new Error("aborted"), { name: "AbortError" }),
917+
)
918+
919+
let threw: unknown
920+
try {
921+
await tavilyModule.searchTavily("q", "key")
922+
} catch (e) {
923+
threw = e
924+
}
925+
expect(threw).toBeInstanceOf(WebSearchError)
926+
expect((threw as WebSearchError).reason).toBe("request timed out")
927+
})
928+
})
929+
930+
describe("webSearchInterceptor — Tavily search path", () => {
931+
beforeEach(() => {
932+
state.tavilyApiKey = "tavily-test-key"
933+
state.braveApiKey = undefined
934+
})
935+
936+
afterEach(() => {
937+
state.tavilyApiKey = undefined
938+
mock.restore()
939+
})
940+
941+
test("calls Tavily and makes a second Copilot call when web_search is triggered", async () => {
942+
const firstResponse = makeCopilotResponse("tool_calls", [
943+
{
944+
id: "tc-ws",
945+
name: "web_search",
946+
arguments: '{"query":"latest AI news"}',
947+
},
948+
])
949+
const finalResponse = makeCopilotResponse("stop")
950+
const tavilyResults = [
951+
{
952+
title: "AI News",
953+
url: "https://ainews.com",
954+
description: "Latest AI developments",
955+
},
956+
]
957+
958+
const createSpy = spyOn(
959+
createChatCompletionsModule,
960+
"createChatCompletions",
961+
)
962+
.mockResolvedValueOnce(firstResponse)
963+
.mockResolvedValueOnce(finalResponse)
964+
spyOn(tavilyModule, "searchTavily").mockResolvedValue(tavilyResults)
965+
966+
const result = await webSearchInterceptor(makePayload())
967+
968+
expect(createSpy).toHaveBeenCalledTimes(2)
969+
expect(result).toEqual(finalResponse)
970+
expect(createSpy.mock.calls[0]?.[0]?.stream).toBe(false)
971+
})
972+
973+
test("prefers Tavily over Brave when both keys are set", async () => {
974+
state.tavilyApiKey = "tavily-key"
975+
state.braveApiKey = "brave-key"
976+
977+
const firstResponse = makeCopilotResponse("tool_calls", [
978+
{
979+
id: "tc-ws",
980+
name: "web_search",
981+
arguments: '{"query":"latest news"}',
982+
},
983+
])
984+
const finalResponse = makeCopilotResponse("stop")
985+
986+
spyOn(createChatCompletionsModule, "createChatCompletions")
987+
.mockResolvedValueOnce(firstResponse)
988+
.mockResolvedValueOnce(finalResponse)
989+
const tavilySpy = spyOn(tavilyModule, "searchTavily").mockResolvedValue([])
990+
const braveSpy = spyOn(braveModule, "searchBrave").mockResolvedValue([])
991+
992+
await webSearchInterceptor(makePayload())
993+
994+
expect(tavilySpy).toHaveBeenCalled()
995+
expect(braveSpy).not.toHaveBeenCalled()
996+
997+
state.braveApiKey = undefined
998+
})
999+
1000+
test("injects failure message when Tavily throws WebSearchError", async () => {
1001+
const firstResponse = makeCopilotResponse("tool_calls", [
1002+
{ id: "tc-ws", name: "web_search", arguments: '{"query":"q"}' },
1003+
])
1004+
const finalResponse = makeCopilotResponse("stop")
1005+
1006+
const createSpy = spyOn(
1007+
createChatCompletionsModule,
1008+
"createChatCompletions",
1009+
)
1010+
.mockResolvedValueOnce(firstResponse)
1011+
.mockResolvedValueOnce(finalResponse)
1012+
spyOn(tavilyModule, "searchTavily").mockRejectedValue(
1013+
new WebSearchError("HTTP 429"),
1014+
)
1015+
1016+
await webSearchInterceptor(makePayload())
1017+
1018+
expect(createSpy).toHaveBeenCalledTimes(2)
1019+
const secondCallMessages = createSpy.mock.calls[1]?.[0]?.messages
1020+
const toolMsg = secondCallMessages.find((m) => m.role === "tool")
1021+
expect(toolMsg?.content).toContain("Web search failed")
1022+
expect(toolMsg?.content).toContain("training data")
1023+
})
1024+
})
1025+
1026+
describe("isWebSearchEnabled — Tavily", () => {
1027+
test("returns false when neither key is set", () => {
1028+
const originalBrave = stateModule.state.braveApiKey
1029+
const originalTavily = stateModule.state.tavilyApiKey
1030+
stateModule.state.braveApiKey = undefined
1031+
stateModule.state.tavilyApiKey = undefined
1032+
try {
1033+
expect(stateModule.isWebSearchEnabled()).toBe(false)
1034+
} finally {
1035+
stateModule.state.braveApiKey = originalBrave
1036+
stateModule.state.tavilyApiKey = originalTavily
1037+
}
1038+
})
1039+
1040+
test("returns true when tavilyApiKey is set", () => {
1041+
const originalBrave = stateModule.state.braveApiKey
1042+
const originalTavily = stateModule.state.tavilyApiKey
1043+
stateModule.state.braveApiKey = undefined
1044+
stateModule.state.tavilyApiKey = "test-key"
1045+
try {
1046+
expect(stateModule.isWebSearchEnabled()).toBe(true)
1047+
} finally {
1048+
stateModule.state.braveApiKey = originalBrave
1049+
stateModule.state.tavilyApiKey = originalTavily
1050+
}
1051+
})
1052+
1053+
test("returns true when both keys are set", () => {
1054+
const originalBrave = stateModule.state.braveApiKey
1055+
const originalTavily = stateModule.state.tavilyApiKey
1056+
stateModule.state.braveApiKey = "brave-key"
1057+
stateModule.state.tavilyApiKey = "tavily-key"
1058+
try {
1059+
expect(stateModule.isWebSearchEnabled()).toBe(true)
1060+
} finally {
1061+
stateModule.state.braveApiKey = originalBrave
1062+
stateModule.state.tavilyApiKey = originalTavily
1063+
}
1064+
})
1065+
})

0 commit comments

Comments
 (0)