rag-system / tests /test_api.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
9.79 kB
"""
API endpoint tests using FastAPI TestClient.
Tests:
- GET /health
- POST /ingest
- POST /query
- GET /collections
- DELETE /collection/{name}
- GET /cache/stats
- DELETE /cache
- Error handling (404, 422, 500)
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from api import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app, raise_server_exceptions=False)
# ── Health ────────────────────────────────────────────────────────────────────
class TestHealth:
def test_health_returns_200(self, client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
def test_health_response_structure(self, client: TestClient) -> None:
response = client.get("/health")
data = response.json()
assert "status" in data
assert "llm_backend" in data
assert "embedding_model" in data
assert data["status"] == "ok"
# ── Ingest ────────────────────────────────────────────────────────────────────
class TestIngest:
def test_ingest_success(self, client: TestClient) -> None:
from models import IngestResult
mock_result = IngestResult(
collection="test_kb",
source="test.txt",
chunks_added=10,
duplicates_skipped=2,
total_chunks_processed=12,
elapsed_seconds=0.5,
)
with patch("core.ingestion.ingest_document", return_value=mock_result):
response = client.post(
"/ingest",
json={
"file_path": "test.txt",
"collection": "test_kb",
},
)
assert response.status_code == 200
data = response.json()
assert data["chunks_added"] == 10
assert data["duplicates_skipped"] == 2
assert data["collection"] == "test_kb"
def test_ingest_missing_file_path(self, client: TestClient) -> None:
response = client.post("/ingest", json={"collection": "test_kb"})
assert response.status_code == 422 # Pydantic validation error
def test_ingest_file_not_found(self, client: TestClient) -> None:
with patch("core.ingestion.ingest_document", side_effect=FileNotFoundError("not found")):
response = client.post(
"/ingest",
json={
"file_path": "nonexistent_file_abc123.txt",
"collection": "kb",
},
)
assert response.status_code == 404
def test_ingest_processing_error(self, client: TestClient) -> None:
with patch(
"core.ingestion.ingest_document", side_effect=RuntimeError("ChromaDB upsert failed")
):
response = client.post(
"/ingest",
json={
"file_path": "broken.pdf",
"collection": "kb",
},
)
assert response.status_code == 500
# ── Query ─────────────────────────────────────────────────────────────────────
class TestQuery:
def test_query_success(self, client: TestClient) -> None:
from models import QueryResponse, SourceCitation
mock_result = QueryResponse(
question="What is RAG?",
answer="RAG is Retrieval-Augmented Generation [Source: docs.txt, chunk 0].",
sources=[
SourceCitation(
source="docs.txt",
chunk_index=0,
similarity_score=0.87,
excerpt="RAG stands for Retrieval-Augmented Generation...",
)
],
tokens_used=150,
latency_ms=320.5,
collection="kb",
llm_backend="ollama",
model_used="llama3.2",
cache_hit=False,
)
with patch("core.generation.answer_question", return_value=mock_result):
response = client.post(
"/query",
json={
"question": "What is RAG?",
"collection": "kb",
"top_k": 5,
},
)
assert response.status_code == 200
data = response.json()
assert "answer" in data
assert "sources" in data
assert "tokens_used" in data
assert "latency_ms" in data
assert data["cache_hit"] is False
def test_query_missing_question(self, client: TestClient) -> None:
response = client.post("/query", json={"collection": "kb"})
assert response.status_code == 422
def test_query_empty_question_rejected(self, client: TestClient) -> None:
response = client.post("/query", json={"question": "", "collection": "kb"})
assert response.status_code == 422
def test_query_top_k_clamped(self, client: TestClient) -> None:
"""top_k must be between 1 and 50."""
response = client.post(
"/query",
json={
"question": "test question",
"collection": "kb",
"top_k": 9999,
},
)
assert response.status_code == 422
def test_query_runtime_error_returns_500(self, client: TestClient) -> None:
with patch(
"core.generation.answer_question", side_effect=RuntimeError("LLM backend unavailable")
):
response = client.post(
"/query",
json={
"question": "Will this fail?",
"collection": "kb",
},
)
assert response.status_code == 500
# ── Collections ───────────────────────────────────────────────────────────────
class TestCollections:
def test_list_collections_empty(self, client: TestClient) -> None:
with patch("core.ingestion.list_collections", return_value=[]):
response = client.get("/collections")
assert response.status_code == 200
data = response.json()
assert data["collections"] == []
assert data["total"] == 0
def test_list_collections_returns_all(self, client: TestClient) -> None:
mock_cols = [
{"name": "kb1", "document_count": 42, "embedding_model": "all-MiniLM-L6-v2"},
{"name": "kb2", "document_count": 100, "embedding_model": "all-MiniLM-L6-v2"},
]
with patch("core.ingestion.list_collections", return_value=mock_cols):
response = client.get("/collections")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [c["name"] for c in data["collections"]]
assert "kb1" in names
assert "kb2" in names
# ── Delete collection ─────────────────────────────────────────────────────────
class TestDeleteCollection:
def test_delete_existing_collection(self, client: TestClient) -> None:
with patch("core.ingestion.delete_collection", return_value=True):
response = client.delete("/collection/my_kb")
assert response.status_code == 200
data = response.json()
assert data["deleted"] is True
assert data["name"] == "my_kb"
def test_delete_nonexistent_collection_returns_404(self, client: TestClient) -> None:
with patch("core.ingestion.delete_collection", return_value=False):
response = client.delete("/collection/ghost_kb")
assert response.status_code == 404
# ── Cache endpoints ───────────────────────────────────────────────────────────
class TestCache:
def test_cache_stats_returns_structure(self, client: TestClient) -> None:
response = client.get("/cache/stats")
assert response.status_code == 200
data = response.json()
assert "enabled" in data
assert "size" in data
assert "max_size" in data
assert "threshold" in data
def test_clear_cache_returns_message(self, client: TestClient) -> None:
response = client.delete("/cache")
assert response.status_code == 200
data = response.json()
assert "message" in data
# ── OpenAPI schema ────────────────────────────────────────────────────────────
class TestOpenAPI:
def test_openapi_schema_accessible(self, client: TestClient) -> None:
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
assert "paths" in schema
assert "/ingest" in schema["paths"]
assert "/query" in schema["paths"]
assert "/collections" in schema["paths"]
def test_docs_accessible(self, client: TestClient) -> None:
response = client.get("/docs")
assert response.status_code == 200