Files
pastor-online/backend/tests/test_main.py
T
omp 2043d5ddcf refactor: troca PDF+OCR por corpus USFX em domínio público
- corpus/: Almeida (João Ferreira de Almeida) em USFX, domínio público,
  66 livros / 1189 capítulos / 31098 versículos, versionado com SHA-256
- app/corpus.py: parser USFX (texto no tail de <v/>), nomes canônicos dos
  livros, aliases (abreviaturas e numerais romanos), VerseChunk sem página
- indexer: descobre *.xml em CORPUS_DIR, progresso em versículos, lote
  configurável, manifesto incremental por SHA-256
- remove extraction.py, parallel.py, bible_parser.py, Tesseract e PyMuPDF
- campo page removido de Source/SearchResult/StatusResponse e do frontend
- embedder: propriedade name; status reporta o embedder em uso, não o do .env
- create_app constrói o embedder ANTES do store (o default HashEmbedder do
  VectorStore impedia o modelo configurado de ser carregado em produção)
- fallback do modelo agora aparece em /api/status.errors
- torch 2.6.0 + transformers 5.22.1 pinados (transformers 5.x exige torch>=2.6)
2026-11-02 11:16:07 -03:00

281 lines
8.4 KiB
Python

"""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