import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi, } from "vitest"; import { CircuitBreaker } from "./utils/circuitBreaker"; import { describeUnresponsiveEngines, fetchSearXNG, getWebSearchStatus, } from "./webSearchService"; function createMockResponse( text: string, ok = true, status?: number, ): Response { const resolvedStatus = status ?? (ok ? 200 : 503); return { ok, status: resolvedStatus, statusText: ok ? "OK" : "Error", headers: new Headers(), redirected: false, type: "basic" as ResponseType, url: "http://test.com", clone: function () { return this; }, body: null, bodyUsed: false, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), blob: () => Promise.resolve(new Blob()), formData: () => Promise.resolve(new FormData()), json: () => Promise.resolve(JSON.parse(text)), text: () => Promise.resolve(text), } as unknown as Response; } const successResponse = () => createMockResponse( JSON.stringify({ results: [ { title: "example", url: "https://example.com", content: "example content", category: "general", }, ], }), ); let originalFetch: typeof fetch; let fetchMock: MockedFunction; beforeEach(() => { originalFetch = global.fetch; fetchMock = vi.fn() as unknown as MockedFunction; global.fetch = fetchMock; }); afterEach(() => { global.fetch = originalFetch; }); describe("WebSearchService", () => { it("should report service not available when fetch throws", async () => { (global.fetch as MockedFunction).mockImplementation(() => { throw new Error("Network error"); }); const status = await getWebSearchStatus(); expect(status).toBe(false); }); it("should return false when health endpoint does not return OK", async () => { (global.fetch as MockedFunction).mockResolvedValue( createMockResponse("NOT_OK", false), ); const status = await getWebSearchStatus(); expect(status).toBe(false); }); it("should return true when health endpoint returns OK", async () => { (global.fetch as MockedFunction).mockResolvedValueOnce( createMockResponse("OK"), ); const status = await getWebSearchStatus(); expect(status).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); }); it("should return empty array on fetchSearXNG error", async () => { (global.fetch as MockedFunction).mockRejectedValue( new Error("Network failure"), ); const results = await fetchSearXNG("test query", "text"); expect(Array.isArray(results)).toBe(true); expect(results).toHaveLength(0); }); }); describe("describeUnresponsiveEngines", () => { it("returns null when there are no unresponsive engines", () => { expect(describeUnresponsiveEngines(undefined)).toBeNull(); expect(describeUnresponsiveEngines([])).toBeNull(); expect(describeUnresponsiveEngines("not-an-array")).toBeNull(); }); it("formats engine/reason pairs from SearXNG", () => { expect( describeUnresponsiveEngines([ ["google", "Timeout"], ["bing", "Suspended: Access denied"], ]), ).toBe("google (Timeout), bing (Suspended: Access denied)"); }); it("handles entries without a reason", () => { expect(describeUnresponsiveEngines([["duckduckgo"]])).toBe("duckduckgo"); }); it("falls back to string conversion for unexpected shapes", () => { expect(describeUnresponsiveEngines(["qwant"])).toBe("qwant"); }); }); describe("retry logic", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("retries on 500 and returns results on eventual success", async () => { fetchMock .mockResolvedValueOnce(createMockResponse("", false, 500)) .mockResolvedValueOnce(createMockResponse("", false, 500)) .mockResolvedValueOnce(successResponse()); const promise = fetchSearXNG("test", "text"); await vi.runAllTimersAsync(); const results = await promise; expect(fetchMock).toHaveBeenCalledTimes(3); expect(results).toHaveLength(1); }); it("returns empty array when all retries return 500", async () => { fetchMock.mockResolvedValue(createMockResponse("", false, 500)); const promise = fetchSearXNG("test", "text"); await vi.runAllTimersAsync(); const results = await promise; expect(fetchMock).toHaveBeenCalledTimes(4); expect(results).toHaveLength(0); }); }); describe("circuit breaker", () => { it("opens after exactly 5 non-retriable failures", async () => { const breaker = new CircuitBreaker({ failureThreshold: 5 }); fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 5; i++) { await fetchSearXNG("test", "text", 30, breaker); } const callsBeforeBreak = fetchMock.mock.calls.length; await fetchSearXNG("test", "text", 30, breaker); expect(fetchMock.mock.calls.length).toBe(callsBeforeBreak); }); it("does not open before 5 failures", async () => { const breaker = new CircuitBreaker({ failureThreshold: 5 }); fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 4; i++) { await fetchSearXNG("test", "text", 30, breaker); } const callsBefore = fetchMock.mock.calls.length; await fetchSearXNG("test", "text", 30, breaker); expect(fetchMock.mock.calls.length).toBe(callsBefore + 1); }); });