feat(backend): add FastAPI routes with SSE chat and reindex guard
This commit is contained in:
1 parent
99bc46f053
commit
7a0396422f
3 files changed
+555
No files matched your search
@@ -0,0 +1,215 @@
|
||||
"""Testes da API FastAPI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.config import Settings # noqa: E402
|
||||
from app.embedding import HashEmbedder # noqa: E402
|
||||
from app.main import create_app # noqa: E402
|
||||
from app.vector_store import InMemoryVectorStore # noqa: E402
|
||||
|
||||
|
||||
class StubProvider:
|
||||
async def stream(self, messages, system):
|
||||
yield "Resposta ancorada em Gênesis 1:1."
|
||||
|
||||
async def aclose(self):
|
||||
return None
|
||||
|
||||
|
||||
class FakeIndexer:
|
||||
"""Indexador controlado para os testes de API.
|
||||
|
||||
`release` permite segurar a reindexação em background, de forma que o teste
|
||||
do 409 seja determinístico.
|
||||
"""
|
||||
|
||||
def __init__(self, block: bool = False):
|
||||
self.calls: list[bool] = []
|
||||
self.release = threading.Event()
|
||||
self.release.set()
|
||||
if block:
|
||||
self.release.clear()
|
||||
|
||||
def reindex(self, force: bool = False):
|
||||
self.calls.append(force)
|
||||
self.release.wait(timeout=10)
|
||||
from app.indexer import IndexStatus
|
||||
|
||||
return IndexStatus(state="ready", pdfs_total=1, pdfs_indexed=1, chunks=42)
|
||||
|
||||
def status(self):
|
||||
from app.indexer import IndexStatus
|
||||
|
||||
return IndexStatus(state="ready", pdfs_total=1, pdfs_indexed=1, chunks=42)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path):
|
||||
return Settings(
|
||||
pdf_dir=tmp_path / "pdfs",
|
||||
vector_db_dir=tmp_path / "vector_db",
|
||||
embedding_model="hash-test",
|
||||
llm_provider="stub",
|
||||
cors_origins="http://localhost:8080",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings):
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
store = InMemoryVectorStore(embedder=embedder)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
provider=StubProvider(),
|
||||
indexer=FakeIndexer(),
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_health_returns_ok(client):
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["llm_provider"] == "stub"
|
||||
|
||||
|
||||
def test_status_reports_indexation(client):
|
||||
response = client.get("/api/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["state"] == "ready"
|
||||
assert body["chunks"] == 42
|
||||
|
||||
|
||||
def test_reindex_returns_202(client):
|
||||
response = client.post("/api/reindex")
|
||||
|
||||
assert response.status_code == 202
|
||||
assert response.json()["state"] == "indexing"
|
||||
|
||||
|
||||
def test_reindex_requires_token_when_configured(settings):
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
store = InMemoryVectorStore(embedder=embedder)
|
||||
settings.reindex_token = "segredo"
|
||||
app = create_app(settings=settings, store=store, embedder=embedder, provider=StubProvider())
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.post("/api/reindex").status_code == 401
|
||||
|
||||
authorized = client.post("/api/reindex", headers={"X-Reindex-Token": "segredo"})
|
||||
assert authorized.status_code == 202
|
||||
|
||||
|
||||
def test_concurrent_reindex_is_rejected_with_409(settings):
|
||||
settings.auto_index = False
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
store = InMemoryVectorStore(embedder=embedder)
|
||||
indexer = FakeIndexer(block=True)
|
||||
app = create_app(
|
||||
settings=settings,
|
||||
store=store,
|
||||
embedder=embedder,
|
||||
provider=StubProvider(),
|
||||
indexer=indexer,
|
||||
)
|
||||
|
||||
# O context manager mantém um único event loop entre as requisições; sem ele,
|
||||
# o TestClient fecha o loop a cada chamada e a tarefa de background seria
|
||||
# cancelada, liberando o lock.
|
||||
with TestClient(app) as client:
|
||||
try:
|
||||
first = client.post("/api/reindex")
|
||||
assert first.status_code == 202
|
||||
|
||||
second = client.post("/api/reindex")
|
||||
assert second.status_code == 409
|
||||
finally:
|
||||
indexer.release.set()
|
||||
|
||||
|
||||
def test_chat_rejects_empty_message(client):
|
||||
response = client.post("/api/chat", json={"message": ""})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_chat_rejects_message_above_the_limit(client):
|
||||
response = client.post("/api/chat", json={"message": "a" * 5000})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_chat_streams_sources_token_and_done(client):
|
||||
from app.bible_parser import VerseChunk
|
||||
|
||||
embedder = HashEmbedder(dimensions=64)
|
||||
store = client.app.state.services.store
|
||||
chunk = VerseChunk(
|
||||
book="Gênesis",
|
||||
chapter=1,
|
||||
verse=1,
|
||||
page=6,
|
||||
file="biblia.pdf",
|
||||
text="No princípio criou Deus os céus e a terra.",
|
||||
)
|
||||
store.upsert([chunk], embedder.encode([chunk.text]))
|
||||
|
||||
response = client.post(
|
||||
"/api/chat",
|
||||
json={"message": "Quem criou o mundo?", "history": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/event-stream")
|
||||
|
||||
body = response.text
|
||||
assert "event: sources" in body
|
||||
assert "event: token" in body
|
||||
assert "event: done" in body
|
||||
|
||||
sources_block = body.split("event: sources")[1].split("event:")[0]
|
||||
payload = json.loads(sources_block.split("data:")[1].strip())
|
||||
assert payload["sources"][0]["reference"] == "Gênesis 1:1"
|
||||
|
||||
|
||||
def test_chat_without_evidence_returns_the_fixed_message(client):
|
||||
response = client.post("/api/chat", json={"message": "fale sobre ações", "history": []})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event: token" in response.text
|
||||
assert "não aborda diretamente" in response.text
|
||||
|
||||
|
||||
def test_cors_is_restricted_to_configured_origin(client):
|
||||
response = client.get("/api/health", headers={"Origin": "http://localhost:8080"})
|
||||
|
||||
assert response.headers.get("access-control-allow-origin") == "http://localhost:8080"
|
||||
|
||||
|
||||
def test_cors_rejects_unknown_origin(client):
|
||||
response = client.get("/api/health", headers={"Origin": "http://malvado.example"})
|
||||
|
||||
assert "access-control-allow-origin" not in response.headers
|
||||
|
||||
|
||||
def test_sources_endpoint_returns_a_list(client):
|
||||
response = client.get("/api/sources")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "sources" in response.json()
|
||||
Reference in new issue
Block a user