"""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", sources_total=1, sources_indexed=1, chunks=42) def status(self): from app.indexer import IndexStatus return IndexStatus(state="ready", sources_total=1, sources_indexed=1, chunks=42) @pytest.fixture def settings(tmp_path): return Settings( corpus_dir=tmp_path / "corpus", 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.corpus import VerseChunk embedder = HashEmbedder(dimensions=64) store = client.app.state.services.store chunk = VerseChunk( book="Gênesis", chapter=1, verse=1, file="biblia.usfx.xml", 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() def test_create_app_builds_the_configured_embedder(monkeypatch, tmp_path): """Produção: o modelo configurado precisa ser construído de fato. O `VectorStore` default para `HashEmbedder` quando nasce sem embedder. Se a aplicação criar o store primeiro e depois herdar o embedder dele, o modelo configurado nunca é carregado e a busca roda sem semântica nenhuma — com `/api/status` ainda anunciando o nome do modelo. """ import app.main as main_module built: list[str] = [] class FakeSentenceTransformer: def __init__(self, model_name, batch_size=32, device="cpu", module=None): built.append(model_name) self.name = model_name self.dimensions = 8 def encode(self, texts): return [[0.0] * 8 for _ in texts] monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", FakeSentenceTransformer) settings = Settings( corpus_dir=tmp_path / "corpus", vector_db_dir=tmp_path / "vector_db", embedding_model="modelo-configurado", llm_provider="stub", auto_index=False, ) app = main_module.create_app(settings=settings) services = app.state.services assert built == ["modelo-configurado"] assert services.embedder.name == "modelo-configurado" assert services.store.embedder.name == "modelo-configurado" assert services.embedder_warning is None def test_embedder_fallback_is_surfaced_in_status(monkeypatch, tmp_path): """Se o modelo não carrega, o status precisa dizer que a busca está sem semântica.""" import app.main as main_module def explode(*args, **kwargs): raise RuntimeError("PyTorch >= 2.6 is required but found 2.5.1") monkeypatch.setattr(main_module, "SentenceTransformerEmbedder", explode) settings = Settings( corpus_dir=tmp_path / "corpus", vector_db_dir=tmp_path / "vector_db", embedding_model="modelo-configurado", llm_provider="stub", auto_index=False, ) app = main_module.create_app( settings=settings, indexer=FakeIndexer(), provider=StubProvider() ) body = TestClient(app).get("/api/status").json() assert any("semântica" in error for error in body["errors"]) assert app.state.services.embedder.name == HashEmbedder.name def test_health_reports_the_llm_endpoint_in_use(client): """/api/health precisa mostrar a URL efetiva, não a que o .env parece dizer. Variável de shell vence o `.env` na interpolação do compose: um `OLLAMA_BASE_URL` herdado do ambiente apontando para `localhost` dentro do container derrubava o chat sem nenhum indício do endereço tentado. """ body = client.get("/api/health").json() assert body["llm_provider"] == "stub" assert body["llm_endpoint"] is None, "o stub não tem endpoint" def test_health_exposes_ollama_endpoint_without_credentials(tmp_path): """A endpoint vai para o health, mas credenciais na URL não podem vazar.""" from app.llm import OllamaProvider settings = Settings( corpus_dir=tmp_path / "corpus", vector_db_dir=tmp_path / "vector_db", embedding_model="hash-test", llm_provider="ollama", auto_index=False, ) provider = OllamaProvider( base_url="http://user:segredo@ollama:11434", model="m" ) app = create_app( settings=settings, store=InMemoryVectorStore(), embedder=HashEmbedder(dimensions=64), provider=provider, indexer=FakeIndexer(), ) body = TestClient(app).get("/api/health").json() assert "segredo" not in body["llm_endpoint"] assert body["llm_endpoint"].endswith("ollama:11434")