MiniSearch / server /webSearchService.test.ts
github-actions[bot]
Sync from https://github.com/felladrin/MiniSearch
9692fe7
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);
});
});