Spaces:
Running
Running
| 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<typeof fetch>; | |
| beforeEach(() => { | |
| originalFetch = global.fetch; | |
| fetchMock = vi.fn() as unknown as MockedFunction<typeof fetch>; | |
| global.fetch = fetchMock; | |
| }); | |
| afterEach(() => { | |
| global.fetch = originalFetch; | |
| }); | |
| describe("WebSearchService", () => { | |
| it("should report service not available when fetch throws", async () => { | |
| (global.fetch as MockedFunction<typeof fetch>).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<typeof fetch>).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<typeof fetch>) | |
| .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<typeof fetch>).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); | |
| }); | |
| }); | |