import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi, } from "vitest"; import { 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")) .mockResolvedValueOnce(successResponse()); const status = await getWebSearchStatus(); expect(status).toBe(true); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[1]?.[0]).toContain("/search?"); }); 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("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", () => { let isolatedFetchSearXNG: typeof fetchSearXNG; beforeEach(async () => { vi.resetModules(); const module = await import("./webSearchService"); isolatedFetchSearXNG = module.fetchSearXNG; }); it("opens after exactly 5 non-retriable failures", async () => { fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 5; i++) { await isolatedFetchSearXNG("test", "text"); } const callsBeforeBreak = fetchMock.mock.calls.length; await isolatedFetchSearXNG("test", "text"); expect(fetchMock.mock.calls.length).toBe(callsBeforeBreak); }); it("does not open before 5 failures", async () => { fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 4; i++) { await isolatedFetchSearXNG("test", "text"); } const callsBefore = fetchMock.mock.calls.length; await isolatedFetchSearXNG("test", "text"); expect(fetchMock.mock.calls.length).toBe(callsBefore + 1); }); });