Initial commit: Research Bridge API with Podman support

This commit is contained in:
Henry
2026-03-14 12:45:36 +00:00
commit 1130305e71
29 changed files with 2451 additions and 0 deletions

134
tests/unit/test_models.py Normal file
View File

@@ -0,0 +1,134 @@
"""Unit tests for Pydantic models."""
import pytest
from pydantic import ValidationError
from src.models.schemas import (
HealthResponse,
ResearchRequest,
ResearchResponse,
SearchRequest,
SearchResponse,
SearchResult,
)
class TestSearchRequest:
"""Test cases for SearchRequest validation."""
def test_valid_request(self):
"""Test valid search request."""
request = SearchRequest(q="python asyncio", engines=["google"], page=1)
assert request.q == "python asyncio"
assert request.engines == ["google"]
assert request.page == 1
def test_default_engines(self):
"""Test default engines."""
request = SearchRequest(q="test")
assert "google" in request.engines
assert "bing" in request.engines
def test_empty_query_fails(self):
"""Test empty query fails validation."""
with pytest.raises(ValidationError) as exc_info:
SearchRequest(q="", engines=["google"])
assert "String should have at least 1 character" in str(exc_info.value)
def test_query_too_long_fails(self):
"""Test query exceeding max length fails."""
with pytest.raises(ValidationError) as exc_info:
SearchRequest(q="x" * 501, engines=["google"])
assert "String should have at most 500 characters" in str(exc_info.value)
def test_page_must_be_positive(self):
"""Test page number must be positive."""
with pytest.raises(ValidationError) as exc_info:
SearchRequest(q="test", page=0)
assert "Input should be greater than or equal to 1" in str(exc_info.value)
class TestResearchRequest:
"""Test cases for ResearchRequest validation."""
def test_valid_request(self):
"""Test valid research request."""
request = ResearchRequest(
query="python asyncio",
depth="deep",
sources=["web", "news"]
)
assert request.query == "python asyncio"
assert request.depth == "deep"
def test_default_values(self):
"""Test default values."""
request = ResearchRequest(query="test")
assert request.depth == "shallow"
assert request.sources == ["web"]
assert request.language == "en"
assert request.omit_raw is False
def test_invalid_depth_fails(self):
"""Test invalid depth fails."""
with pytest.raises(ValidationError):
ResearchRequest(query="test", depth="invalid")
def test_invalid_language_fails(self):
"""Test invalid language code fails."""
with pytest.raises(ValidationError):
ResearchRequest(query="test", language="english")
class TestSearchResult:
"""Test cases for SearchResult validation."""
def test_valid_result(self):
"""Test valid search result."""
result = SearchResult(
title="Python Documentation",
url="https://docs.python.org",
content="Python docs",
source="google",
score=0.95
)
assert result.title == "Python Documentation"
assert str(result.url) == "https://docs.python.org/"
def test_title_required(self):
"""Test title is required."""
with pytest.raises(ValidationError):
SearchResult(url="https://example.com", source="google")
def test_invalid_url_fails(self):
"""Test invalid URL fails."""
with pytest.raises(ValidationError):
SearchResult(title="Test", url="not-a-url", source="google")
class TestHealthResponse:
"""Test cases for HealthResponse."""
def test_valid_response(self):
"""Test valid health response."""
response = HealthResponse(
status="healthy",
searxng_connected=True,
kimi_coding_available=False
)
assert response.status == "healthy"
assert response.version == "0.1.0"
class TestResearchResponse:
"""Test cases for ResearchResponse."""
def test_phase1_response(self):
"""Test Phase 1 response without synthesis."""
response = ResearchResponse(
query="test",
depth="shallow",
synthesis=None,
metadata={"phase": "1"}
)
assert response.synthesis is None
assert response.metadata["phase"] == "1"

260
tests/unit/test_router.py Normal file
View File

@@ -0,0 +1,260 @@
"""Unit tests for API router."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi.testclient import TestClient
from src.api.app import create_app
@pytest.fixture
def client():
app = create_app()
return TestClient(app)
class TestHealthEndpoint:
"""Test cases for health endpoint."""
def test_health_searxng_healthy(self, client):
"""Test health check when SearXNG is up."""
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.health_check = AsyncMock(return_value=True)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "degraded" # No Kimi key configured in test
assert data["searxng_connected"] is True
assert data["kimi_coding_available"] is False
def test_health_searxng_down(self, client):
"""Test health check when SearXNG is down."""
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.health_check = AsyncMock(return_value=False)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "degraded"
assert data["searxng_connected"] is False
class TestSearchEndpoint:
"""Test cases for search endpoint."""
def test_search_get_success(self, client):
"""Test GET search with successful response."""
mock_response = Mock()
mock_response.query = "python"
mock_response.results = []
mock_response.total = 0
mock_response.page = 1
mock_response.metadata = {}
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.search = AsyncMock(return_value=mock_response)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
response = client.get("/search?q=python")
assert response.status_code == 200
data = response.json()
assert data["query"] == "python"
assert "results" in data
def test_search_post_success(self, client):
"""Test POST search with successful response."""
mock_response = Mock()
mock_response.query = "asyncio"
mock_response.results = []
mock_response.total = 0
mock_response.page = 1
mock_response.metadata = {}
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.search = AsyncMock(return_value=mock_response)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
response = client.post("/search", json={
"q": "asyncio",
"engines": ["google"],
"page": 1
})
assert response.status_code == 200
def test_search_validation_error(self, client):
"""Test search with invalid parameters."""
response = client.get("/search?q=a")
# Just test that it accepts the request
assert response.status_code in [200, 502] # 502 if no SearXNG
class TestResearchEndpoint:
"""Test cases for research endpoint (Phase 2 - with synthesis)."""
def test_research_phase2_with_synthesis(self, client):
"""Test research endpoint returns synthesis (Phase 2)."""
from src.models.schemas import SearchResult
import src.api.router as router_module
mock_search_response = Mock()
mock_search_response.results = [
SearchResult(title="Test", url="https://example.com", source="google")
]
mock_search_response.total = 1
mock_search_response.page = 1
mock_synthesis = Mock()
mock_synthesis.content = "This is a synthesized answer."
mock_synthesis.sources = [{"index": 1, "title": "Test", "url": "https://example.com"}]
mock_synthesis.tokens_used = 100
# Temporarily set API key in router module
original_key = router_module.KIMI_API_KEY
router_module.KIMI_API_KEY = "sk-test"
try:
with patch("src.api.router.SearXNGClient") as mock_search_class, \
patch("src.api.router.Synthesizer") as mock_synth_class:
# Mock SearXNG
mock_search_instance = AsyncMock()
mock_search_instance.search = AsyncMock(return_value=mock_search_response)
mock_search_instance.__aenter__ = AsyncMock(return_value=mock_search_instance)
mock_search_instance.__aexit__ = AsyncMock(return_value=None)
mock_search_class.return_value = mock_search_instance
# Mock Synthesizer
mock_synth_instance = AsyncMock()
mock_synth_instance.synthesize = AsyncMock(return_value=mock_synthesis)
mock_synth_instance.__aenter__ = AsyncMock(return_value=mock_synth_instance)
mock_synth_instance.__aexit__ = AsyncMock(return_value=None)
mock_synth_class.return_value = mock_synth_instance
response = client.post("/research", json={
"query": "python asyncio",
"depth": "shallow",
"sources": ["web"]
})
assert response.status_code == 200
data = response.json()
assert data["query"] == "python asyncio"
assert data["depth"] == "shallow"
assert data["synthesis"] == "This is a synthesized answer."
assert data["metadata"]["phase"] == "2"
assert len(data["sources"]) == 1
finally:
router_module.KIMI_API_KEY = original_key
def test_research_no_api_key_returns_message(self, client):
"""Test research endpoint without API key returns appropriate message."""
from src.models.schemas import SearchResult
mock_search_response = Mock()
mock_search_response.results = [
SearchResult(title="Test", url="https://example.com", source="google")
]
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.search = AsyncMock(return_value=mock_search_response)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
# Ensure no API key
with patch.dict("os.environ", {}, clear=True):
with patch("src.api.router.KIMI_API_KEY", None):
response = client.post("/research", json={
"query": "test",
"sources": ["web"]
})
assert response.status_code == 200
data = response.json()
assert "not configured" in data["synthesis"].lower() or "API key" in data["synthesis"]
def test_research_no_results(self, client):
"""Test research endpoint with no search results."""
mock_search_response = Mock()
mock_search_response.results = []
with patch("src.api.router.SearXNGClient") as mock_class:
mock_instance = AsyncMock()
mock_instance.search = AsyncMock(return_value=mock_search_response)
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
mock_instance.__aexit__ = AsyncMock(return_value=None)
mock_class.return_value = mock_instance
response = client.post("/research", json={
"query": "xyzabc123nonexistent",
"sources": ["web"]
})
assert response.status_code == 200
data = response.json()
assert "no results" in data["synthesis"].lower()
def test_research_with_omit_raw(self, client):
"""Test research endpoint with omit_raw=true."""
from src.models.schemas import SearchResult
import src.api.router as router_module
mock_search_response = Mock()
mock_search_response.results = [
SearchResult(title="Test", url="https://example.com", source="google")
]
mock_synthesis = Mock()
mock_synthesis.content = "Answer"
mock_synthesis.sources = []
mock_synthesis.tokens_used = 50
original_key = router_module.KIMI_API_KEY
router_module.KIMI_API_KEY = "sk-test"
try:
with patch("src.api.router.SearXNGClient") as mock_search_class, \
patch("src.api.router.Synthesizer") as mock_synth_class:
mock_search_instance = AsyncMock()
mock_search_instance.search = AsyncMock(return_value=mock_search_response)
mock_search_instance.__aenter__ = AsyncMock(return_value=mock_search_instance)
mock_search_instance.__aexit__ = AsyncMock(return_value=None)
mock_search_class.return_value = mock_search_instance
mock_synth_instance = AsyncMock()
mock_synth_instance.synthesize = AsyncMock(return_value=mock_synthesis)
mock_synth_instance.__aenter__ = AsyncMock(return_value=mock_synth_instance)
mock_synth_instance.__aexit__ = AsyncMock(return_value=None)
mock_synth_class.return_value = mock_synth_instance
response = client.post("/research", json={
"query": "test",
"omit_raw": True
})
assert response.status_code == 200
data = response.json()
assert data["raw_results"] is None
finally:
router_module.KIMI_API_KEY = original_key

View File

@@ -0,0 +1,140 @@
"""Unit tests for SearXNG client."""
import json
from unittest.mock import AsyncMock, Mock, patch
import httpx
import pytest
from httpx import Response
from src.models.schemas import SearchRequest
from src.search.searxng import SearXNGClient, SearXNGError
class TestSearXNGClient:
"""Test cases for SearXNGClient."""
@pytest.fixture
def client(self):
return SearXNGClient(base_url="http://test:8080")
@pytest.mark.asyncio
async def test_search_success(self, client):
"""Test successful search request."""
mock_response = {
"results": [
{
"title": "Test Result",
"url": "https://example.com",
"content": "Test content",
"engine": "google",
"score": 0.95
}
],
"number_of_results": 1,
"engines": ["google"]
}
mock_client = AsyncMock()
mock_response_obj = Mock()
mock_response_obj.status_code = 200
mock_response_obj.json = Mock(return_value=mock_response)
mock_response_obj.text = json.dumps(mock_response)
mock_response_obj.raise_for_status = Mock(return_value=None)
mock_client.get.return_value = mock_response_obj
with patch.object(client, '_client', mock_client):
request = SearchRequest(q="test query", engines=["google"], page=1)
result = await client.search(request)
assert result.query == "test query"
assert len(result.results) == 1
assert result.results[0].title == "Test Result"
assert result.results[0].source == "google"
@pytest.mark.asyncio
async def test_search_http_error(self, client):
"""Test handling of HTTP errors."""
mock_client = AsyncMock()
# Create proper HTTPStatusError with async side effect
async def raise_http_error(*args, **kwargs):
from httpx import Request, Response
mock_request = Mock(spec=Request)
mock_response = Response(status_code=404, text="Not found")
raise httpx.HTTPStatusError(
"Not found",
request=mock_request,
response=mock_response
)
mock_client.get.side_effect = raise_http_error
with patch.object(client, '_client', mock_client):
request = SearchRequest(q="test", engines=["google"], page=1)
with pytest.raises(SearXNGError) as exc_info:
await client.search(request)
assert "HTTP error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_search_connection_error(self, client):
"""Test handling of connection errors."""
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with patch.object(client, '_client', mock_client):
request = SearchRequest(q="test", engines=["google"], page=1)
with pytest.raises(SearXNGError) as exc_info:
await client.search(request)
assert "Request failed" in str(exc_info.value)
@pytest.mark.asyncio
async def test_search_invalid_json(self, client):
"""Test handling of invalid JSON response."""
mock_client = AsyncMock()
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.side_effect = json.JSONDecodeError("test", "", 0)
mock_response.text = "invalid json"
mock_response.raise_for_status = Mock(return_value=None)
mock_client.get.return_value = mock_response
with patch.object(client, '_client', mock_client):
request = SearchRequest(q="test", engines=["google"], page=1)
with pytest.raises(SearXNGError) as exc_info:
await client.search(request)
assert "Invalid JSON" in str(exc_info.value)
@pytest.mark.asyncio
async def test_health_check_success(self, client):
"""Test successful health check."""
mock_client = AsyncMock()
mock_client.get.return_value = Response(status_code=200)
with patch.object(client, '_client', mock_client):
result = await client.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_failure(self, client):
"""Test failed health check."""
mock_client = AsyncMock()
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
with patch.object(client, '_client', mock_client):
result = await client.health_check()
assert result is False
def test_build_url(self, client):
"""Test URL building with parameters."""
params = {"q": "test query", "format": "json", "engines": "google,bing"}
url = client._build_url(params)
assert url.startswith("http://test:8080/search")
assert "q=test+query" in url or "q=test%20query" in url
assert "format=json" in url
assert "engines=google%2Cbing" in url or "engines=google,bing" in url

View File

@@ -0,0 +1,185 @@
"""Unit tests for Synthesizer."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from src.llm.synthesizer import Synthesizer, SynthesizerError
from src.models.schemas import SearchResult
class TestSynthesizer:
"""Test cases for Synthesizer."""
@pytest.fixture
def synthesizer(self):
return Synthesizer(api_key="sk-test-key")
def test_init_without_api_key_raises(self):
"""Test that initialization without API key raises error."""
with patch.dict("os.environ", {}, clear=True):
with pytest.raises(SynthesizerError) as exc_info:
Synthesizer()
assert "API key required" in str(exc_info.value)
def test_init_with_env_var(self):
"""Test initialization with environment variable."""
with patch.dict("os.environ", {"RESEARCH_BRIDGE_KIMI_API_KEY": "sk-env-key"}):
synth = Synthesizer()
assert synth.api_key == "sk-env-key"
def test_default_headers_set(self):
"""Test that required User-Agent header is set."""
synth = Synthesizer(api_key="sk-test")
assert "User-Agent" in synth.DEFAULT_HEADERS
assert synth.DEFAULT_HEADERS["User-Agent"] == "KimiCLI/0.77"
def test_format_search_results(self, synthesizer):
"""Test formatting of search results."""
results = [
SearchResult(
title="Test Title",
url="https://example.com",
content="Test content",
source="google"
),
SearchResult(
title="Second Title",
url="https://test.com",
content=None,
source="bing"
)
]
formatted = synthesizer._format_search_results(results)
assert "[1] Test Title" in formatted
assert "URL: https://example.com" in formatted
assert "Test content" in formatted
assert "[2] Second Title" in formatted
assert "No snippet available" in formatted
def test_build_prompt(self, synthesizer):
"""Test prompt building."""
results = [
SearchResult(
title="Python Asyncio",
url="https://docs.python.org",
content="Asyncio docs",
source="google"
)
]
prompt = synthesizer._build_prompt("what is asyncio", results)
assert "User Query: what is asyncio" in prompt
assert "Python Asyncio" in prompt
assert "docs.python.org" in prompt
@pytest.mark.asyncio
async def test_synthesize_success(self, synthesizer):
"""Test successful synthesis."""
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message.content = "Asyncio is a library..."
mock_response.usage = Mock()
mock_response.usage.total_tokens = 100
mock_response.usage.prompt_tokens = 80
mock_response.usage.completion_tokens = 20
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
with patch.object(synthesizer, '_client', mock_client):
results = [
SearchResult(
title="Test",
url="https://example.com",
content="Content",
source="google"
)
]
result = await synthesizer.synthesize("test query", results)
assert result.content == "Asyncio is a library..."
assert result.tokens_used == 100
assert result.prompt_tokens == 80
assert result.completion_tokens == 20
assert len(result.sources) == 1
@pytest.mark.asyncio
async def test_synthesize_truncates_results(self, synthesizer):
"""Test that synthesis truncates to top 5 results."""
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message.content = "Answer"
mock_response.usage = None
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
# Create 10 results
results = [
SearchResult(
title=f"Result {i}",
url=f"https://example{i}.com",
content=f"Content {i}",
source="google"
)
for i in range(10)
]
with patch.object(synthesizer, '_client', mock_client):
result = await synthesizer.synthesize("test", results)
# Should only use first 5
assert len(result.sources) == 5
@pytest.mark.asyncio
async def test_synthesize_api_error(self, synthesizer):
"""Test handling of API errors."""
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(
side_effect=Exception("API Error")
)
with patch.object(synthesizer, '_client', mock_client):
results = [
SearchResult(
title="Test",
url="https://example.com",
content="Content",
source="google"
)
]
with pytest.raises(SynthesizerError) as exc_info:
await synthesizer.synthesize("test", results)
assert "Kimi API error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_health_check_success(self, synthesizer):
"""Test successful health check."""
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message.content = "Hi"
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
with patch.object(synthesizer, '_client', mock_client):
result = await synthesizer.health_check()
assert result is True
@pytest.mark.asyncio
async def test_health_check_failure(self, synthesizer):
"""Test failed health check."""
mock_client = AsyncMock()
mock_client.chat.completions.create = AsyncMock(
side_effect=Exception("Connection error")
)
with patch.object(synthesizer, '_client', mock_client):
result = await synthesizer.health_check()
assert result is False