deer-flow/backend/tests/test_lightrag_client.py

369 lines
12 KiB
Python

import json
import httpx
import pytest
from deerflow.community.lightrag.client import (
LightRAGAPIError,
LightRAGClient,
LightRAGConnectionError,
LightRAGProtocolError,
)
@pytest.mark.anyio
async def test_query_data_sends_documented_body_and_api_key_header() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert request.method == "POST"
assert request.url == httpx.URL("http://lightrag.test/query/data")
assert request.headers["X-API-Key"] == "lightrag-secret"
return httpx.Response(
200,
json={
"status": "success",
"message": None,
"data": {"chunks": [], "references": [], "entities": [], "relationships": []},
},
)
client = LightRAGClient(
base_url="http://lightrag.test/",
api_key="lightrag-secret",
timeout=12,
transport=httpx.MockTransport(handler),
)
data = await client.query_data("annual leave", mode="hybrid", top_k=60)
assert data == {"chunks": [], "references": [], "entities": [], "relationships": []}
assert len(requests) == 1
assert json.loads(requests[0].content) == {"query": "annual leave", "mode": "hybrid", "top_k": 60}
@pytest.mark.anyio
async def test_query_data_omits_api_key_header_when_unauthenticated() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
assert "Authorization" not in request.headers
assert "X-API-Key" not in request.headers
return httpx.Response(200, json={"status": "success", "data": {"chunks": []}})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key=None,
transport=httpx.MockTransport(handler),
)
await client.query_data("annual leave")
assert len(requests) == 1
@pytest.mark.anyio
async def test_query_data_sends_chunk_top_k_only_when_configured() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"status": "success", "data": {"chunks": []}})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
await client.query_data("annual leave", mode="mix", top_k=15, chunk_top_k=8)
await client.query_data("annual leave", mode="mix", top_k=15)
assert json.loads(requests[0].content) == {"query": "annual leave", "mode": "mix", "top_k": 15, "chunk_top_k": 8}
assert json.loads(requests[1].content) == {"query": "annual leave", "mode": "mix", "top_k": 15}
@pytest.mark.anyio
async def test_query_data_rejects_unknown_mode_before_request() -> None:
called = False
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal called
called = True
return httpx.Response(500)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(ValueError, match="mode must be one of"):
await client.query_data("fallback search", mode="vector")
assert called is False
@pytest.mark.anyio
async def test_non_success_envelope_is_normalized_and_redacts_api_key() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"status": "failure", "message": "invalid credential lightrag-secret"},
)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError) as exc_info:
await client.query_data("annual leave")
assert "invalid credential" in str(exc_info.value)
assert "lightrag-secret" not in str(exc_info.value)
assert "[REDACTED]" in str(exc_info.value)
@pytest.mark.anyio
async def test_http_error_envelope_message_is_used() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
400,
json={"status": "failure", "message": "RAG query is too short"},
)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError, match="RAG query is too short"):
await client.query_data("ab")
@pytest.mark.anyio
async def test_http_error_detail_string_is_used() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "Invalid API key provided"})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError, match="Invalid API key provided"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_structured_validation_error_message_is_extracted_from_detail() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
422,
json={
"detail": [
{
"type": "value_error",
"loc": ["body", "query"],
"msg": "Value error, RAG query is too short. Enter at least 3 English characters or an equivalent combination where each Chinese, Japanese or Korean character counts as 2.",
}
]
},
)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError) as exc_info:
await client.query_data("ab")
assert "RAG query is too short" in str(exc_info.value)
assert "Value error," not in str(exc_info.value)
@pytest.mark.anyio
async def test_structured_validation_error_without_readable_msg_falls_back_to_protocol_error() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(422, json={"detail": [{"type": "missing", "loc": ["body"]}]})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGProtocolError, match=r"LightRAG request failed \(HTTP 422\)"):
await client.query_data("annual leave")
@pytest.mark.anyio
@pytest.mark.parametrize("body", [None, '{"detail": "Not Found"}'])
async def test_404_returns_upgrade_guidance_instead_of_plain_not_found(body: str | None) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, text=body, headers={"Content-Type": "application/json"} if body else None)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError, match="v1.4.9"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_pre_v149_flat_success_payload_returns_upgrade_error() -> None:
"""v1.4.8 answers 200 with a flat payload (no status/data envelope).
Pinning this shape matters: the documented minimum is v1.4.9, so a v1.4.8
server must fail with explicit upgrade guidance instead of a generic
"request failed" that hides the version incompatibility.
"""
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"entities": [{"entity_name": "LightRAG"}],
"relationships": [],
"chunks": [{"content": "v1.4.8 chunk without citation fields."}],
"metadata": {"query_mode": "hybrid"},
},
)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError, match="predates v1.4.9"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_http_error_text_body_cannot_echo_api_key() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal error: lightrag-secret")
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGProtocolError) as exc_info:
await client.query_data("annual leave")
assert str(exc_info.value) == "LightRAG request failed (HTTP 500)."
assert "lightrag-secret" not in str(exc_info.value)
@pytest.mark.anyio
async def test_server_500_detail_string_is_used_verbatim() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, json={"detail": "Internal server error"})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGAPIError, match="^Internal server error$"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_timeout_is_english_and_does_not_leak_api_key(caplog: pytest.LogCaptureFixture) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("timed out with lightrag-secret", request=request)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
timeout=2,
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGConnectionError) as exc_info:
await client.query_data("annual leave")
assert str(exc_info.value) == "LightRAG request timed out after 2 seconds."
assert "lightrag-secret" not in str(exc_info.value)
assert "lightrag-secret" not in caplog.text
@pytest.mark.anyio
async def test_request_error_is_normalized_and_redacts_api_key() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused with lightrag-secret", request=request)
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGConnectionError) as exc_info:
await client.query_data("annual leave")
assert "ConnectError" in str(exc_info.value)
assert "lightrag-secret" not in str(exc_info.value)
assert "[REDACTED]" in str(exc_info.value)
@pytest.mark.anyio
async def test_invalid_json_response_is_normalized_in_english() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="not-json")
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGProtocolError, match="LightRAG returned invalid JSON"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_non_object_json_payload_is_rejected() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=["not", "an", "object"])
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGProtocolError, match="non-object JSON payload"):
await client.query_data("annual leave")
@pytest.mark.anyio
async def test_non_dict_data_payload_is_rejected() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"status": "success", "data": ["not-a-dict"]})
client = LightRAGClient(
base_url="http://lightrag.test",
api_key="lightrag-secret",
transport=httpx.MockTransport(handler),
)
with pytest.raises(LightRAGProtocolError, match="invalid retrieval result"):
await client.query_data("annual leave")