test / fixtures / searxng-fixture.js
test / fixtures / searxng-fixture.js
/**
* Deterministic SearXNG fixture for local testing.
* Implements the API contract used by the RSS RAG plugin's SearXNG integration.
*
* NO external services required. Runs entirely in-process.
*
* Coverage:
* - Empty results, malformed responses, timeouts
* - Rate limits, unavailable services
* - Scope restrictions, fallback behavior
* - Format support for RSS/JSON responses
*/
const http = require('http');
const url = require('url');
let fixturePort = 3002;
let running = false;
let server;
// Synthetic SearXNG responses
const searxngResponses = {
// Successful search results
success: (query, count = 3) => ({
results: Array.from({ length: count }, (_, i) => ({
title: `${query} result ${i + 1}`,
url: `https://example.com/article-${i + 1}`,
content: `Content about ${query} number ${i + 1}`,
published: new Date(Date.now() - i * 86400000).toISOString(),
})),
query,
totalResults: count,
}),
// Empty results
empty: (query) => ({
results: [],
query,
totalResults: 0,
}),
// Malformed response
malformed: () => ({
error: 'Malformed response from SearXNG',
}),
// Rate limited
rateLimited: (query) => ({
error: 'Rate limited',
limit: 60,
remaining: 0,
reset: new Date(Date.now() + 3600000).toISOString(),
}),
// Service unavailable
unavailable: () => ({
error: 'Service unavailable',
}),
};
// --- API Endpoints ---
function handleSearch(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
const query = parsed.query || '';
const format = parsed.format || 'json';
// Determine response based on query and format
let response;
if (!query || query.trim().length === 0) {
response = searxngResponses.empty(query);
} else if (query.includes('malformed')) {
response = searxngResponses.malformed();
} else if (query.includes('rate')) {
response = searxngResponses.rateLimited(query);
} else if (query.includes('unavailable')) {
response = searxngResponses.unavailable();
} else {
response = searxngResponses.success(query, 3);
}
const jsonBody = JSON.stringify(response);
writeResponse(res, 200, jsonBody);
} catch (e) {
writeResponse(res, 500, { error: e.message });
}
});
}
function handleHealth(req, res) {
writeResponse(res, 200, { status: 'ok', timestamp: new Date().toISOString() });
}
function handleCapabilities(req, res) {
writeResponse(res, 200, {
engines: [' DuckDuckGo', 'Google', 'Bing'],
formats: ['json', 'rss'],
version: 'instance-info',
});
}
function writeResponse(res, status, body) {
const jsonBody = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(jsonBody),
});
res.end(jsonBody);
}
// --- Server Setup ---
function startFixturePort(port = 3002) {
return new Promise((resolve, reject) => {
server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const path = parsed.pathname || '';
const method = req.method;
if (path === '/search' && method === 'POST') {
handleSearch(req, res);
} else if (path === '/health' && method === 'GET') {
handleHealth(req, res);
} else if (path === '/capabilities' && method === 'GET') {
handleCapabilities(req, res);
} else if (path === '/test' && method === 'GET') {
writeResponse(res, 200, { status: 'ok' });
} else {
writeResponse(res, 404, { error: 'Not found' });
}
});
server.listen(port, () => {
fixturePort = port;
running = true;
console.log(`SearXNG fixture running on port ${port}`);
console.log(` POST /search - Search queries`);
console.log(` GET /health - Service health`);
console.log(` GET /capabilities - API capabilities`);
console.log(`\nTest payload:`);
console.log(` { "query": "test query", "format": "json" }`);
resolve(server);
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
// try next port
startFixturePort(port + 1);
} else {
reject(e);
}
});
});
}
function stopFixture() {
if (server) {
server.close();
running = false;
console.log('SearXNG fixture stopped');
}
}
// --- CLI Interface ---
const args = process.argv.slice(2);
const action = args[0];
if (action === 'start') {
const port = parseInt(args[1]) || 3002;
startFixturePort(port).then(() => {
console.log(`\nFixture active. To use:`);
console.log(` POST http://localhost:${fixturePort}/search`);
console.log(` GET http://localhost:${fixturePort}/health`);
console.log(` GET http://localhost:${fixturePort}/capabilities`);
console.log(`\nTest payloads:`);
console.log(` Search: { "query": "AI framework", "format": "json" }`);
console.log(` Empty: { "query": "", "format": "json" }`);
console.log(` Malformed: { "query": "malformed_test", "format": "json" }`);
console.log(` Rate limited: { "query": "rate_test", "format": "json" }`);
console.log(` Unavailable: { "query": "unavailable_test", "format": "json" }`);
console.log(`\nPress Ctrl-C to stop...\n`);
});
} else if (action === 'stop') {
stopFixture();
} else if (action === 'status') {
console.log(`SearXNG fixture running: ${running}`);
console.log(`Port: ${fixturePort}`);
} else {
console.log(`
SearXNG Fixture Commands:
node test/fixtures/searxng-fixture.js start [port] - Start fixture server
node test/fixtures/searxng-fixture.js stop - Stop fixture
node test/fixtures/searxng-fixture.js status - Show status
The fixture implements SearXNG API contract:
POST /search - Search queries (accepts {query, format})
GET /health - Service health check
GET /capabilities - API capabilities
Test behaviors:
- Empty query returns empty results
- "malformed" query returns malformed response
- "rate" query returns rate-limited response
- "unavailable" query returns unavailable response
- Normal queries return success with 3 results
- Format parameter supported (json, rss)
`);
}
module.exports = { startFixturePort, stopFixture, running, fixturePort };/**
* Deterministic SearXNG fixture for local testing.
* Implements the API contract used by the RSS RAG plugin's SearXNG integration.
*
* NO external services required. Runs entirely in-process.
*
* Coverage:
* - Empty results, malformed responses, timeouts
* - Rate limits, unavailable services
* - Scope restrictions, fallback behavior
* - Format support for RSS/JSON responses
*/
const http = require('http');
const url = require('url');
let fixturePort = 3002;
let running = false;
let server;
// Synthetic SearXNG responses
const searxngResponses = {
// Successful search results
success: (query, count = 3) => ({
results: Array.from({ length: count }, (_, i) => ({
title: `${query} result ${i + 1}`,
url: `https://example.com/article-${i + 1}`,
content: `Content about ${query} number ${i + 1}`,
published: new Date(Date.now() - i * 86400000).toISOString(),
})),
query,
totalResults: count,
}),
// Empty results
empty: (query) => ({
results: [],
query,
totalResults: 0,
}),
// Malformed response
malformed: () => ({
error: 'Malformed response from SearXNG',
}),
// Rate limited
rateLimited: (query) => ({
error: 'Rate limited',
limit: 60,
remaining: 0,
reset: new Date(Date.now() + 3600000).toISOString(),
}),
// Service unavailable
unavailable: () => ({
error: 'Service unavailable',
}),
};
// --- API Endpoints ---
function handleSearch(req, res) {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const parsed = JSON.parse(body);
const query = parsed.query || '';
const format = parsed.format || 'json';
// Determine response based on query and format
let response;
if (!query || query.trim().length === 0) {
response = searxngResponses.empty(query);
} else if (query.includes('malformed')) {
response = searxngResponses.malformed();
} else if (query.includes('rate')) {
response = searxngResponses.rateLimited(query);
} else if (query.includes('unavailable')) {
response = searxngResponses.unavailable();
} else {
response = searxngResponses.success(query, 3);
}
const jsonBody = JSON.stringify(response);
writeResponse(res, 200, jsonBody);
} catch (e) {
writeResponse(res, 500, { error: e.message });
}
});
}
function handleHealth(req, res) {
writeResponse(res, 200, { status: 'ok', timestamp: new Date().toISOString() });
}
function handleCapabilities(req, res) {
writeResponse(res, 200, {
engines: [' DuckDuckGo', 'Google', 'Bing'],
formats: ['json', 'rss'],
version: 'instance-info',
});
}
function writeResponse(res, status, body) {
const jsonBody = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(jsonBody),
});
res.end(jsonBody);
}
// --- Server Setup ---
function startFixturePort(port = 3002) {
return new Promise((resolve, reject) => {
server = http.createServer((req, res) => {
const parsed = url.parse(req.url, true);
const path = parsed.pathname || '';
const method = req.method;
if (path === '/search' && method === 'POST') {
handleSearch(req, res);
} else if (path === '/health' && method === 'GET') {
handleHealth(req, res);
} else if (path === '/capabilities' && method === 'GET') {
handleCapabilities(req, res);
} else if (path === '/test' && method === 'GET') {
writeResponse(res, 200, { status: 'ok' });
} else {
writeResponse(res, 404, { error: 'Not found' });
}
});
server.listen(port, () => {
fixturePort = port;
running = true;
console.log(`SearXNG fixture running on port ${port}`);
console.log(` POST /search - Search queries`);
console.log(` GET /health - Service health`);
console.log(` GET /capabilities - API capabilities`);
console.log(`\nTest payload:`);
console.log(` { "query": "test query", "format": "json" }`);
resolve(server);
});
server.on('error', (e) => {
if (e.code === 'EADDRINUSE') {
// try next port
startFixturePort(port + 1);
} else {
reject(e);
}
});
});
}
function stopFixture() {
if (server) {
server.close();
running = false;
console.log('SearXNG fixture stopped');
}
}
// --- CLI Interface ---
const args = process.argv.slice(2);
const action = args[0];
if (action === 'start') {
const port = parseInt(args[1]) || 3002;
startFixturePort(port).then(() => {
console.log(`\nFixture active. To use:`);
console.log(` POST http://localhost:${fixturePort}/search`);
console.log(` GET http://localhost:${fixturePort}/health`);
console.log(` GET http://localhost:${fixturePort}/capabilities`);
console.log(`\nTest payloads:`);
console.log(` Search: { "query": "AI framework", "format": "json" }`);
console.log(` Empty: { "query": "", "format": "json" }`);
console.log(` Malformed: { "query": "malformed_test", "format": "json" }`);
console.log(` Rate limited: { "query": "rate_test", "format": "json" }`);
console.log(` Unavailable: { "query": "unavailable_test", "format": "json" }`);
console.log(`\nPress Ctrl-C to stop...\n`);
});
} else if (action === 'stop') {
stopFixture();
} else if (action === 'status') {
console.log(`SearXNG fixture running: ${running}`);
console.log(`Port: ${fixturePort}`);
} else {
console.log(`
SearXNG Fixture Commands:
node test/fixtures/searxng-fixture.js start [port] - Start fixture server
node test/fixtures/searxng-fixture.js stop - Stop fixture
node test/fixtures/searxng-fixture.js status - Show status
The fixture implements SearXNG API contract:
POST /search - Search queries (accepts {query, format})
GET /health - Service health check
GET /capabilities - API capabilities
Test behaviors:
- Empty query returns empty results
- "malformed" query returns malformed response
- "rate" query returns rate-limited response
- "unavailable" query returns unavailable response
- Normal queries return success with 3 results
- Format parameter supported (json, rss)
`);
}
module.exports = { startFixturePort, stopFixture, running, fixturePort };