main
  1/**
  2 * Tests for search backends.
  3 *
  4 * Run with: npx tsx --test backends.test.ts
  5 *
  6 * Tests cover:
  7 * - SearXNG backend (mocked HTTP)
  8 * - DuckDuckGo backend (mocked HTTP)
  9 * - ddgr backend (mocked exec)
 10 * - Fallback logic (searchWithFallback)
 11 * - Result formatting
 12 */
 13
 14import { describe, it, mock, beforeEach, afterEach } from "node:test";
 15import assert from "node:assert/strict";
 16import {
 17  SearXNGBackend,
 18  DuckDuckGoBackend,
 19  DdgrBackend,
 20  searchWithFallback,
 21  formatResults,
 22  type SearchBackend,
 23  type SearchResult,
 24} from "./backends";
 25
 26// ─── Helpers ──────────────────────────────────────────────────────────────
 27
 28/** Create a mock Response object */
 29function mockResponse(body: any, status = 200): Response {
 30  return {
 31    ok: status >= 200 && status < 300,
 32    status,
 33    json: async () => body,
 34    text: async () => JSON.stringify(body),
 35  } as Response;
 36}
 37
 38/** Create a failing backend for testing fallback */
 39class FailingBackend implements SearchBackend {
 40  name = "failing";
 41  error: string;
 42  constructor(error = "Backend unavailable") { this.error = error; }
 43  async isAvailable() { return false; }
 44  async search(): Promise<SearchResult[]> { throw new Error(this.error); }
 45}
 46
 47/** Create a succeeding backend for testing fallback */
 48class MockBackend implements SearchBackend {
 49  name: string;
 50  results: SearchResult[];
 51  constructor(name: string, results: SearchResult[]) {
 52    this.name = name;
 53    this.results = results;
 54  }
 55  async isAvailable() { return true; }
 56  async search() { return this.results; }
 57}
 58
 59// ─── SearXNG Backend ──────────────────────────────────────────────────────
 60
 61describe("SearXNGBackend", () => {
 62  let originalFetch: typeof globalThis.fetch;
 63
 64  beforeEach(() => {
 65    originalFetch = globalThis.fetch;
 66  });
 67
 68  afterEach(() => {
 69    globalThis.fetch = originalFetch;
 70  });
 71
 72  it("parses SearXNG JSON response correctly", async () => {
 73    globalThis.fetch = mock.fn(async () =>
 74      mockResponse({
 75        results: [
 76          { title: "NixOS Wiki", url: "https://wiki.nixos.org", content: "The NixOS wiki" },
 77          { title: "Nix Manual", url: "https://nix.dev", content: "Official docs" },
 78        ],
 79      }),
 80    ) as any;
 81
 82    const backend = new SearXNGBackend("https://search.sbr.pm");
 83    const results = await backend.search("nixos", 5);
 84
 85    assert.equal(results.length, 2);
 86    assert.equal(results[0].title, "NixOS Wiki");
 87    assert.equal(results[0].url, "https://wiki.nixos.org");
 88    assert.equal(results[0].snippet, "The NixOS wiki");
 89  });
 90
 91  it("respects maxResults parameter", async () => {
 92    globalThis.fetch = mock.fn(async () =>
 93      mockResponse({
 94        results: [
 95          { title: "Result 1", url: "https://example.com/1", content: "One" },
 96          { title: "Result 2", url: "https://example.com/2", content: "Two" },
 97          { title: "Result 3", url: "https://example.com/3", content: "Three" },
 98        ],
 99      }),
100    ) as any;
101
102    const backend = new SearXNGBackend("https://search.sbr.pm");
103    const results = await backend.search("test", 2);
104
105    assert.equal(results.length, 2);
106  });
107
108  it("throws on HTTP error", async () => {
109    globalThis.fetch = mock.fn(async () => mockResponse({}, 500)) as any;
110
111    const backend = new SearXNGBackend("https://search.sbr.pm");
112    await assert.rejects(
113      () => backend.search("test", 5),
114      { message: "SearXNG request failed with status 500" },
115    );
116  });
117
118  it("returns empty array when no results", async () => {
119    globalThis.fetch = mock.fn(async () => mockResponse({ results: [] })) as any;
120
121    const backend = new SearXNGBackend("https://search.sbr.pm");
122    const results = await backend.search("obscurequery", 5);
123
124    assert.equal(results.length, 0);
125  });
126
127  it("handles missing fields gracefully", async () => {
128    globalThis.fetch = mock.fn(async () =>
129      mockResponse({
130        results: [
131          { title: null, url: "https://example.com", content: null },
132          { url: "https://example.com/2" },
133        ],
134      }),
135    ) as any;
136
137    const backend = new SearXNGBackend("https://search.sbr.pm");
138    const results = await backend.search("test", 5);
139
140    assert.equal(results.length, 2);
141    assert.equal(results[0].title, "(no title)");
142    assert.equal(results[0].snippet, "");
143  });
144
145  it("strips trailing slash from base URL", async () => {
146    const fetchMock = mock.fn(async () => mockResponse({ results: [] }));
147    globalThis.fetch = fetchMock as any;
148
149    const backend = new SearXNGBackend("https://search.sbr.pm/");
150    await backend.search("test", 5);
151
152    const calledUrl = (fetchMock as any).mock.calls[0].arguments[0];
153    assert.ok(
154      calledUrl.startsWith("https://search.sbr.pm/search?"),
155      `URL should not have double slash before /search: ${calledUrl}`,
156    );
157  });
158
159  it("isAvailable returns true for healthy instance", async () => {
160    globalThis.fetch = mock.fn(async () => mockResponse({ results: [] })) as any;
161    const backend = new SearXNGBackend("https://search.sbr.pm");
162    assert.equal(await backend.isAvailable(), true);
163  });
164
165  it("isAvailable returns false for unreachable instance", async () => {
166    globalThis.fetch = mock.fn(async () => { throw new Error("ECONNREFUSED"); }) as any;
167    const backend = new SearXNGBackend("https://nonexistent.example.com");
168    assert.equal(await backend.isAvailable(), false);
169  });
170});
171
172// ─── DuckDuckGo Backend ───────────────────────────────────────────────────
173
174describe("DuckDuckGoBackend", () => {
175  let originalFetch: typeof globalThis.fetch;
176
177  beforeEach(() => {
178    originalFetch = globalThis.fetch;
179  });
180
181  afterEach(() => {
182    globalThis.fetch = originalFetch;
183  });
184
185  it("parses abstract and related topics", async () => {
186    globalThis.fetch = mock.fn(async () =>
187      mockResponse({
188        AbstractText: "NixOS is a Linux distribution",
189        AbstractURL: "https://nixos.org",
190        Heading: "NixOS",
191        RelatedTopics: [
192          { Text: "Nix package manager - A package manager", FirstURL: "https://nixos.org/nix" },
193          { Text: "NixOps - Deployment tool", FirstURL: "https://nixos.org/nixops" },
194        ],
195      }),
196    ) as any;
197
198    const backend = new DuckDuckGoBackend();
199    const results = await backend.search("nixos", 5);
200
201    assert.equal(results.length, 3);
202    assert.equal(results[0].title, "NixOS");
203    assert.equal(results[0].url, "https://nixos.org");
204    assert.equal(results[0].snippet, "NixOS is a Linux distribution");
205    assert.equal(results[1].title, "Nix package manager");
206    assert.equal(results[1].url, "https://nixos.org/nix");
207  });
208
209  it("handles response with no abstract", async () => {
210    globalThis.fetch = mock.fn(async () =>
211      mockResponse({
212        AbstractText: "",
213        RelatedTopics: [
214          { Text: "Some topic", FirstURL: "https://example.com" },
215        ],
216      }),
217    ) as any;
218
219    const backend = new DuckDuckGoBackend();
220    const results = await backend.search("query", 5);
221
222    assert.equal(results.length, 1);
223    assert.equal(results[0].url, "https://example.com");
224  });
225
226  it("handles empty response", async () => {
227    globalThis.fetch = mock.fn(async () =>
228      mockResponse({ AbstractText: "", RelatedTopics: [] }),
229    ) as any;
230
231    const backend = new DuckDuckGoBackend();
232    const results = await backend.search("obscurequery", 5);
233
234    assert.equal(results.length, 0);
235  });
236
237  it("respects maxResults", async () => {
238    globalThis.fetch = mock.fn(async () =>
239      mockResponse({
240        AbstractText: "Abstract",
241        AbstractURL: "https://example.com",
242        Heading: "Test",
243        RelatedTopics: [
244          { Text: "Topic 1", FirstURL: "https://example.com/1" },
245          { Text: "Topic 2", FirstURL: "https://example.com/2" },
246          { Text: "Topic 3", FirstURL: "https://example.com/3" },
247        ],
248      }),
249    ) as any;
250
251    const backend = new DuckDuckGoBackend();
252    const results = await backend.search("test", 2);
253
254    assert.equal(results.length, 2);
255  });
256
257  it("isAvailable always returns true", async () => {
258    const backend = new DuckDuckGoBackend();
259    assert.equal(await backend.isAvailable(), true);
260  });
261});
262
263// ─── ddgr Backend ─────────────────────────────────────────────────────────
264
265describe("DdgrBackend", () => {
266  it("parses ddgr JSON output", async () => {
267    const execFn = mock.fn(async () => ({
268      stdout: JSON.stringify([
269        { title: "NixOS", url: "https://nixos.org", abstract: "A Linux distribution" },
270        { title: "Nix", url: "https://nix.dev", abstract: "Package manager" },
271      ]),
272      stderr: "",
273      code: 0,
274    }));
275
276    const backend = new DdgrBackend(execFn as any);
277    const results = await backend.search("nixos", 5);
278
279    assert.equal(results.length, 2);
280    assert.equal(results[0].title, "NixOS");
281    assert.equal(results[0].url, "https://nixos.org");
282    assert.equal(results[0].snippet, "A Linux distribution");
283
284    // Verify ddgr was called with correct args
285    const call = (execFn as any).mock.calls[0];
286    assert.equal(call.arguments[0], "ddgr");
287    assert.deepEqual(call.arguments[1], ["--json", "-n", "5", "nixos"]);
288  });
289
290  it("throws on non-zero exit code", async () => {
291    const execFn = mock.fn(async () => ({
292      stdout: "",
293      stderr: "ddgr: command not found",
294      code: 127,
295    }));
296
297    const backend = new DdgrBackend(execFn as any);
298    await assert.rejects(() => backend.search("test", 5), /ddgr failed/);
299  });
300
301  it("isAvailable checks for ddgr binary", async () => {
302    const execFn = mock.fn(async () => ({ stdout: "/usr/bin/ddgr", stderr: "", code: 0 }));
303    const backend = new DdgrBackend(execFn as any);
304    assert.equal(await backend.isAvailable(), true);
305
306    const execFnMissing = mock.fn(async () => ({ stdout: "", stderr: "", code: 1 }));
307    const backendMissing = new DdgrBackend(execFnMissing as any);
308    assert.equal(await backendMissing.isAvailable(), false);
309  });
310});
311
312// ─── Fallback Logic ───────────────────────────────────────────────────────
313
314describe("searchWithFallback", () => {
315  const sampleResults: SearchResult[] = [
316    { title: "Result", url: "https://example.com", snippet: "A result" },
317  ];
318
319  it("uses first successful backend", async () => {
320    const backends = [
321      new MockBackend("primary", sampleResults),
322      new MockBackend("secondary", []),
323    ];
324
325    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
326
327    assert.equal(backend, "primary");
328    assert.equal(results.length, 1);
329    assert.equal(errors.length, 0);
330  });
331
332  it("falls back to second backend on failure", async () => {
333    const backends = [
334      new FailingBackend("SearXNG is down"),
335      new MockBackend("fallback", sampleResults),
336    ];
337
338    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
339
340    assert.equal(backend, "fallback");
341    assert.equal(results.length, 1);
342    assert.equal(errors.length, 1);
343    assert.ok(errors[0].includes("SearXNG is down"));
344  });
345
346  it("falls back through multiple failures", async () => {
347    const backends = [
348      new FailingBackend("Error 1"),
349      new FailingBackend("Error 2"),
350      new MockBackend("third", sampleResults),
351    ];
352
353    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
354
355    assert.equal(backend, "third");
356    assert.equal(results.length, 1);
357    assert.equal(errors.length, 2);
358  });
359
360  it("returns empty results when all backends fail", async () => {
361    const backends = [
362      new FailingBackend("Error 1"),
363      new FailingBackend("Error 2"),
364    ];
365
366    const { results, backend, errors } = await searchWithFallback(backends, "test", 5);
367
368    assert.equal(backend, "none");
369    assert.equal(results.length, 0);
370    assert.equal(errors.length, 2);
371  });
372
373  it("handles empty backends array", async () => {
374    const { results, backend, errors } = await searchWithFallback([], "test", 5);
375
376    assert.equal(backend, "none");
377    assert.equal(results.length, 0);
378    assert.equal(errors.length, 0);
379  });
380});
381
382// ─── Result Formatting ───────────────────────────────────────────────────
383
384describe("formatResults", () => {
385  it("formats results with all fields", () => {
386    const results: SearchResult[] = [
387      { title: "NixOS", url: "https://nixos.org", snippet: "A Linux distro" },
388      { title: "Nix", url: "https://nix.dev", snippet: "Package manager" },
389    ];
390
391    const output = formatResults(results, "SearXNG");
392
393    assert.ok(output.includes("Results from SearXNG:"));
394    assert.ok(output.includes("1. NixOS"));
395    assert.ok(output.includes("URL: https://nixos.org"));
396    assert.ok(output.includes("A Linux distro"));
397    assert.ok(output.includes("2. Nix"));
398  });
399
400  it("handles empty results", () => {
401    const output = formatResults([], "SearXNG");
402    assert.equal(output, "No results found.");
403  });
404
405  it("handles results with empty fields", () => {
406    const results: SearchResult[] = [
407      { title: "Title Only", url: "", snippet: "" },
408    ];
409
410    const output = formatResults(results, "DuckDuckGo");
411    assert.ok(output.includes("1. Title Only"));
412    assert.ok(!output.includes("URL:"));
413  });
414});