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});