Compare commits
2 Commits
2a16c98597
...
986584b759
| Author | SHA1 | Date | |
|---|---|---|---|
| 986584b759 | |||
|
|
cd75318f45 |
@@ -53,6 +53,7 @@
|
|||||||
| #47 | Implement readability-lxml extraction pipeline | Phase 6 | `COMPLETED` | Python | [issue-047.md](issue-047.md) |
|
| #47 | Implement readability-lxml extraction pipeline | Phase 6 | `COMPLETED` | Python | [issue-047.md](issue-047.md) |
|
||||||
| #48 | Implement summarization step via Model Gateway | Phase 6 | `COMPLETED` | Python | [issue-048.md](issue-048.md) |
|
| #48 | Implement summarization step via Model Gateway | Phase 6 | `COMPLETED` | Python | [issue-048.md](issue-048.md) |
|
||||||
| #49 | Implement Search gRPC endpoint | Phase 6 | `COMPLETED` | Python | [issue-049.md](issue-049.md) |
|
| #49 | Implement Search gRPC endpoint | Phase 6 | `COMPLETED` | Python | [issue-049.md](issue-049.md) |
|
||||||
|
| #50 | Integration tests for Search Service | Phase 6 | `COMPLETED` | Python | [issue-050.md](issue-050.md) |
|
||||||
|
|
||||||
## Status Legend
|
## Status Legend
|
||||||
|
|
||||||
|
|||||||
49
implementation-plans/issue-050.md
Normal file
49
implementation-plans/issue-050.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Implementation Plan — Issue #50: Integration tests for Search Service
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Issue | [#50](https://git.shahondin1624.de/llm-multiverse/llm-multiverse/issues/50) |
|
||||||
|
| Title | Integration tests for Search Service |
|
||||||
|
| Milestone | Phase 6: Search Service |
|
||||||
|
| Labels | — |
|
||||||
|
| Status | `COMPLETED` |
|
||||||
|
| Language | Python |
|
||||||
|
| Related Plans | issue-049.md, issue-046.md, issue-047.md, issue-048.md |
|
||||||
|
| Blocked by | #49 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Test: Search returns results with all fields populated
|
||||||
|
- [x] Test: Extraction produces clean text from HTML pages
|
||||||
|
- [x] Test: Summarization produces concise relevant summaries
|
||||||
|
- [x] Test: Handles unreachable URLs gracefully
|
||||||
|
- [x] Test: Audit logging for search operations
|
||||||
|
- [x] Tests run in CI (uses aioresponses + mock gRPC servers, no containers needed)
|
||||||
|
|
||||||
|
## Architecture Analysis
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
Integration tests wire together real service components (SearXNGClient, PageExtractor, Summarizer, SearchServiceImpl) with mocked external services:
|
||||||
|
- SearXNG HTTP API → mocked via `aioresponses`
|
||||||
|
- Model Gateway gRPC → mocked via in-process gRPC server
|
||||||
|
- Audit Service gRPC → mocked via in-process gRPC server
|
||||||
|
- Web pages for extraction → mocked via `aioresponses`
|
||||||
|
|
||||||
|
### Difference from Unit Tests
|
||||||
|
- `test_service.py` uses `AsyncMock` for all dependencies
|
||||||
|
- Integration tests use real component instances with only external HTTP/gRPC mocked
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `services/search/tests/test_integration.py` | Create | Integration tests |
|
||||||
|
| `implementation-plans/issue-050.md` | Create | Plan |
|
||||||
|
| `implementation-plans/_index.md` | Modify | Add entry |
|
||||||
|
|
||||||
|
## Deviation Log
|
||||||
|
|
||||||
|
| Deviation | Reason |
|
||||||
|
|---|---|
|
||||||
601
services/search/tests/test_integration.py
Normal file
601
services/search/tests/test_integration.py
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
"""Integration tests for the Search Service.
|
||||||
|
|
||||||
|
These tests wire together real service components with mocked external
|
||||||
|
services (SearXNG HTTP, Model Gateway gRPC, Audit Service gRPC, web pages).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
import pytest
|
||||||
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
|
from llm_multiverse.v1 import (
|
||||||
|
audit_pb2,
|
||||||
|
audit_pb2_grpc,
|
||||||
|
common_pb2,
|
||||||
|
model_gateway_pb2,
|
||||||
|
model_gateway_pb2_grpc,
|
||||||
|
search_pb2,
|
||||||
|
search_pb2_grpc,
|
||||||
|
)
|
||||||
|
from search_service.config import Config
|
||||||
|
from search_service.extractor import PageExtractor
|
||||||
|
from search_service.searxng import SearXNGClient
|
||||||
|
from search_service.service import SearchServiceImpl
|
||||||
|
from search_service.summarizer import Summarizer
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SearXNG mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEARXNG_URL = "http://searxng-test:8080"
|
||||||
|
SEARXNG_PATTERN = re.compile(r"^http://searxng-test:8080/search\?.*$")
|
||||||
|
|
||||||
|
SIMPLE_PAGE_HTML = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Test Article</title></head>
|
||||||
|
<body>
|
||||||
|
<article>
|
||||||
|
<h1>Understanding Python Async</h1>
|
||||||
|
<p>Python's asyncio library enables concurrent programming. It uses
|
||||||
|
coroutines and event loops to handle I/O-bound operations efficiently.</p>
|
||||||
|
<p>The async/await syntax was introduced in Python 3.5 and has become
|
||||||
|
the standard approach for writing asynchronous code.</p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNREACHABLE_PAGE_HTML = "" # Won't be served — simulates unreachable URL
|
||||||
|
|
||||||
|
|
||||||
|
def _searxng_response(results: list[dict]) -> dict:
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
def _searxng_result(
|
||||||
|
title: str, url: str, content: str, score: float = 1.0
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"url": url,
|
||||||
|
"content": content,
|
||||||
|
"score": score,
|
||||||
|
"engine": "google",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock Model Gateway gRPC server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MockModelGatewayServicer(model_gateway_pb2_grpc.ModelGatewayServiceServicer):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[model_gateway_pb2.InferenceRequest] = []
|
||||||
|
|
||||||
|
async def Inference(self, request, context): # noqa: N802
|
||||||
|
self.requests.append(request)
|
||||||
|
return model_gateway_pb2.InferenceResponse(
|
||||||
|
text=f"Summary of content about: {request.params.prompt[:50]}",
|
||||||
|
finish_reason="stop",
|
||||||
|
tokens_used=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def StreamInference(self, request, context): # noqa: N802
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
|
||||||
|
async def GenerateEmbedding(self, request, context): # noqa: N802
|
||||||
|
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock Audit Service gRPC server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MockAuditServicer(audit_pb2_grpc.AuditServiceServicer):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.entries: list[audit_pb2.AppendRequest] = []
|
||||||
|
|
||||||
|
async def Append(self, request, context): # noqa: N802
|
||||||
|
self.entries.append(request)
|
||||||
|
return audit_pb2.AppendResponse()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _session_context() -> common_pb2.SessionContext:
|
||||||
|
return common_pb2.SessionContext(
|
||||||
|
session_id="integration-session",
|
||||||
|
user_id="integration-user",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_grpc_server(servicer, add_fn):
|
||||||
|
server = grpc.aio.server()
|
||||||
|
add_fn(servicer, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
return server, port
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_pipeline_returns_populated_results() -> None:
|
||||||
|
"""Search returns results with all fields populated through the full pipeline."""
|
||||||
|
gw_servicer = MockModelGatewayServicer()
|
||||||
|
audit_servicer = MockAuditServicer()
|
||||||
|
|
||||||
|
gw_server, gw_port = await _start_grpc_server(
|
||||||
|
gw_servicer,
|
||||||
|
model_gateway_pb2_grpc.add_ModelGatewayServiceServicer_to_server,
|
||||||
|
)
|
||||||
|
audit_server, audit_port = await _start_grpc_server(
|
||||||
|
audit_servicer,
|
||||||
|
audit_pb2_grpc.add_AuditServiceServicer_to_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gw_channel = grpc.aio.insecure_channel(f"localhost:{gw_port}")
|
||||||
|
audit_channel = grpc.aio.insecure_channel(f"localhost:{audit_port}")
|
||||||
|
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
extractor = PageExtractor()
|
||||||
|
summarizer = Summarizer(gw_channel)
|
||||||
|
audit_stub = audit_pb2_grpc.AuditServiceStub(audit_channel)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config,
|
||||||
|
searxng=searxng,
|
||||||
|
extractor=extractor,
|
||||||
|
summarizer=summarizer,
|
||||||
|
audit_stub=audit_stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start search service gRPC server
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
with aioresponses() as m:
|
||||||
|
# Mock SearXNG
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result(
|
||||||
|
"Python Async Guide",
|
||||||
|
"https://example.com/async",
|
||||||
|
"Learn about Python async.",
|
||||||
|
score=0.9,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
# Mock the page fetch for extraction
|
||||||
|
m.get(
|
||||||
|
"https://example.com/async",
|
||||||
|
body=SIMPLE_PAGE_HTML,
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="python async programming",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 1
|
||||||
|
result = resp.results[0]
|
||||||
|
assert result.source_url == "https://example.com/async"
|
||||||
|
assert result.claim # title populated
|
||||||
|
assert result.summary # summary populated
|
||||||
|
assert result.confidence == pytest.approx(0.9)
|
||||||
|
assert resp.error_message == ""
|
||||||
|
|
||||||
|
# Model Gateway received summarization request
|
||||||
|
assert len(gw_servicer.requests) == 1
|
||||||
|
assert "python async programming" in gw_servicer.requests[0].params.prompt
|
||||||
|
|
||||||
|
# Audit entry recorded
|
||||||
|
assert len(audit_servicer.entries) == 1
|
||||||
|
assert audit_servicer.entries[0].entry.tool_name == "searxng"
|
||||||
|
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
await server.stop(0)
|
||||||
|
await gw_channel.close()
|
||||||
|
await audit_channel.close()
|
||||||
|
finally:
|
||||||
|
await gw_server.stop(0)
|
||||||
|
await audit_server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extraction_produces_clean_text() -> None:
|
||||||
|
"""Extraction pipeline produces clean text from HTML pages."""
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
extractor = PageExtractor()
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config, searxng=searxng, extractor=extractor, summarizer=None
|
||||||
|
)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result(
|
||||||
|
"HTML Test",
|
||||||
|
"https://example.com/html",
|
||||||
|
"A test page",
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
m.get(
|
||||||
|
"https://example.com/html",
|
||||||
|
body=SIMPLE_PAGE_HTML,
|
||||||
|
content_type="text/html",
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="async python",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 1
|
||||||
|
# Summary should contain extracted text (no summarizer, so raw content)
|
||||||
|
summary = resp.results[0].summary
|
||||||
|
assert "asyncio" in summary.lower() or "async" in summary.lower()
|
||||||
|
# Should NOT contain HTML tags
|
||||||
|
assert "<p>" not in summary
|
||||||
|
assert "<article>" not in summary
|
||||||
|
await channel.close()
|
||||||
|
finally:
|
||||||
|
await server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summarization_produces_concise_summaries() -> None:
|
||||||
|
"""Summarization via Model Gateway produces summaries."""
|
||||||
|
gw_servicer = MockModelGatewayServicer()
|
||||||
|
gw_server, gw_port = await _start_grpc_server(
|
||||||
|
gw_servicer,
|
||||||
|
model_gateway_pb2_grpc.add_ModelGatewayServiceServicer_to_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gw_channel = grpc.aio.insecure_channel(f"localhost:{gw_port}")
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
extractor = PageExtractor()
|
||||||
|
summarizer = Summarizer(gw_channel)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config, searxng=searxng, extractor=extractor, summarizer=summarizer
|
||||||
|
)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result("Page", "https://example.com/p1", "Snippet"),
|
||||||
|
_searxng_result("Page 2", "https://example.com/p2", "Snippet 2"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
m.get("https://example.com/p1", body=SIMPLE_PAGE_HTML, content_type="text/html")
|
||||||
|
m.get("https://example.com/p2", body=SIMPLE_PAGE_HTML, content_type="text/html")
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="async programming",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 2
|
||||||
|
# Both results should have summaries from Model Gateway
|
||||||
|
for r in resp.results:
|
||||||
|
assert r.summary
|
||||||
|
assert "Summary of content" in r.summary
|
||||||
|
|
||||||
|
# Model Gateway received 2 requests (one per result)
|
||||||
|
assert len(gw_servicer.requests) == 2
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
await server.stop(0)
|
||||||
|
await gw_channel.close()
|
||||||
|
finally:
|
||||||
|
await gw_server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unreachable_url_handled_gracefully() -> None:
|
||||||
|
"""Handles unreachable URLs gracefully — returns results with snippet fallback."""
|
||||||
|
gw_servicer = MockModelGatewayServicer()
|
||||||
|
gw_server, gw_port = await _start_grpc_server(
|
||||||
|
gw_servicer,
|
||||||
|
model_gateway_pb2_grpc.add_ModelGatewayServiceServicer_to_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gw_channel = grpc.aio.insecure_channel(f"localhost:{gw_port}")
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
extractor = PageExtractor(timeout=2.0)
|
||||||
|
summarizer = Summarizer(gw_channel)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config, searxng=searxng, extractor=extractor, summarizer=summarizer
|
||||||
|
)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result(
|
||||||
|
"Good Page",
|
||||||
|
"https://example.com/good",
|
||||||
|
"Good snippet",
|
||||||
|
score=0.9,
|
||||||
|
),
|
||||||
|
_searxng_result(
|
||||||
|
"Bad Page",
|
||||||
|
"https://unreachable.example.com",
|
||||||
|
"Bad snippet",
|
||||||
|
score=0.5,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
# Only mock the good page
|
||||||
|
m.get("https://example.com/good", body=SIMPLE_PAGE_HTML, content_type="text/html")
|
||||||
|
m.get(
|
||||||
|
"https://unreachable.example.com",
|
||||||
|
exception=ConnectionError("Connection refused"),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="test query",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 2
|
||||||
|
assert resp.error_message == ""
|
||||||
|
# Both results should have summaries (summarizer handles fallback)
|
||||||
|
assert resp.results[0].summary
|
||||||
|
assert resp.results[1].summary
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
await server.stop(0)
|
||||||
|
await gw_channel.close()
|
||||||
|
finally:
|
||||||
|
await gw_server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_audit_logging_records_search() -> None:
|
||||||
|
"""Audit Service receives search operation log entries."""
|
||||||
|
audit_servicer = MockAuditServicer()
|
||||||
|
audit_server, audit_port = await _start_grpc_server(
|
||||||
|
audit_servicer,
|
||||||
|
audit_pb2_grpc.add_AuditServiceServicer_to_server,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
audit_channel = grpc.aio.insecure_channel(f"localhost:{audit_port}")
|
||||||
|
audit_stub = audit_pb2_grpc.AuditServiceStub(audit_channel)
|
||||||
|
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config, searxng=searxng, extractor=None, summarizer=None,
|
||||||
|
audit_stub=audit_stub,
|
||||||
|
)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result("R1", "https://example.com/1", "S1"),
|
||||||
|
_searxng_result("R2", "https://example.com/2", "S2"),
|
||||||
|
_searxng_result("R3", "https://example.com/3", "S3"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="audit test query",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(audit_servicer.entries) == 1
|
||||||
|
entry = audit_servicer.entries[0].entry
|
||||||
|
assert entry.action == audit_pb2.AUDIT_ACTION_TOOL_INVOCATION
|
||||||
|
assert entry.tool_name == "searxng"
|
||||||
|
assert entry.result_status == "ok:3"
|
||||||
|
assert entry.metadata["query"] == "audit test query"
|
||||||
|
assert entry.session_id == "integration-session"
|
||||||
|
|
||||||
|
# Verify SessionContext was forwarded
|
||||||
|
ctx = audit_servicer.entries[0].context
|
||||||
|
assert ctx.session_id == "integration-session"
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
await server.stop(0)
|
||||||
|
await audit_channel.close()
|
||||||
|
finally:
|
||||||
|
await audit_server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_searxng_unavailable_returns_error() -> None:
|
||||||
|
"""SearXNG unavailability returns an error response, not an exception."""
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(config, searxng=searxng, extractor=None, summarizer=None)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(SEARXNG_PATTERN, status=503)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 0
|
||||||
|
assert "Search engine error" in resp.error_message
|
||||||
|
await channel.close()
|
||||||
|
finally:
|
||||||
|
await server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_results_ordering_preserved() -> None:
|
||||||
|
"""Results preserve the ordering from SearXNG (by score)."""
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(config, searxng=searxng, extractor=None, summarizer=None)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result("High", "https://example.com/high", "S1", score=0.9),
|
||||||
|
_searxng_result("Medium", "https://example.com/med", "S2", score=0.5),
|
||||||
|
_searxng_result("Low", "https://example.com/low", "S3", score=0.1),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="ordering test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 3
|
||||||
|
assert resp.results[0].claim == "High"
|
||||||
|
assert resp.results[1].claim == "Medium"
|
||||||
|
assert resp.results[2].claim == "Low"
|
||||||
|
assert resp.results[0].confidence > resp.results[2].confidence
|
||||||
|
await channel.close()
|
||||||
|
finally:
|
||||||
|
await server.stop(0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_gateway_down_falls_back_to_truncation() -> None:
|
||||||
|
"""When Model Gateway is unavailable, summarizer falls back to truncated content."""
|
||||||
|
config = Config(searxng_url=SEARXNG_URL)
|
||||||
|
searxng = SearXNGClient(SEARXNG_URL)
|
||||||
|
extractor = PageExtractor()
|
||||||
|
|
||||||
|
# Connect summarizer to a non-existent port
|
||||||
|
dead_channel = grpc.aio.insecure_channel("localhost:1")
|
||||||
|
summarizer = Summarizer(dead_channel, max_summary_length=100)
|
||||||
|
|
||||||
|
service = SearchServiceImpl(
|
||||||
|
config, searxng=searxng, extractor=extractor, summarizer=summarizer
|
||||||
|
)
|
||||||
|
|
||||||
|
server = grpc.aio.server()
|
||||||
|
search_pb2_grpc.add_SearchServiceServicer_to_server(service, server)
|
||||||
|
port = server.add_insecure_port("[::]:0")
|
||||||
|
await server.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with aioresponses() as m:
|
||||||
|
m.get(
|
||||||
|
SEARXNG_PATTERN,
|
||||||
|
payload=_searxng_response([
|
||||||
|
_searxng_result("Fallback", "https://example.com/fb", "Snippet"),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
m.get("https://example.com/fb", body=SIMPLE_PAGE_HTML, content_type="text/html")
|
||||||
|
|
||||||
|
channel = grpc.aio.insecure_channel(f"localhost:{port}")
|
||||||
|
stub = search_pb2_grpc.SearchServiceStub(channel)
|
||||||
|
resp = await stub.Search(
|
||||||
|
search_pb2.SearchRequest(
|
||||||
|
context=_session_context(),
|
||||||
|
query="fallback test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(resp.results) == 1
|
||||||
|
# Summary should be truncated extracted content (not empty)
|
||||||
|
assert resp.results[0].summary
|
||||||
|
assert len(resp.results[0].summary) <= 100
|
||||||
|
await channel.close()
|
||||||
|
|
||||||
|
await dead_channel.close()
|
||||||
|
finally:
|
||||||
|
await server.stop(0)
|
||||||
Reference in New Issue
Block a user