kirche-einfach/backend/tests/conftest.py

280 lines
8.7 KiB
Python

"""Shared pytest fixtures.
Tests run on a throwaway SQLite file (no external database required).
`DATABASE_URL` and `FLOWS_DIR` are pointed at a session temp dir *before* any
app module is imported, so settings pick them up at import time. The app
schema is migrated to head once per session via Alembic.
"""
import os
import tempfile
from pathlib import Path
import pytest
TEST_AREA = Path(tempfile.mkdtemp(prefix="kirche-tests-"))
# Must precede all `app.*` imports: Settings is instantiated at import time.
os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
BACKEND_DIR = Path(__file__).resolve().parent.parent
@pytest.fixture(scope="session")
def db_engine():
"""Engine on the test DB, migrated to head once per session."""
from alembic import command as alembic_command
from alembic.config import Config
from app import db as app_db
app_db.reset_engine()
engine = app_db.get_engine()
cfg = Config()
cfg.set_main_option("script_location", str(BACKEND_DIR / "migrations"))
alembic_command.upgrade(cfg, "head")
yield engine
engine.dispose()
app_db.reset_engine()
@pytest.fixture()
def session(db_engine):
"""Function-scoped session for direct DB access in tests."""
from sqlmodel import Session
with Session(db_engine) as s:
yield s
@pytest.fixture(autouse=True)
def _clean_tables(db_engine):
"""Truncate all domain tables before and after every test (isolation)."""
from sqlmodel import SQLModel
def _truncate() -> None:
with db_engine.begin() as conn:
for table in reversed(SQLModel.metadata.sorted_tables):
conn.execute(table.delete())
_truncate()
yield
_truncate()
@pytest.fixture()
async def client():
"""ASGI test client for the FastAPI app."""
from httpx import ASGITransport, AsyncClient
from app.main import app
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest.fixture()
def login(client, session):
"""Log the test client in as a fresh user: ``await login()`` (admin)
or ``await login(role="member", tenant_id="pfarramt-a")``."""
async def _login(
email: str = "admin@example.com",
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name="Test", email=email, role=role, tenant_id=tenant_id)
session.add(user)
else:
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
return user
return _login
@pytest.fixture(scope="session")
def lfx_server(tmp_path_factory):
"""Real `lfx serve` subprocess on a free port; points settings at it.
Serves an (initially seeded) flow dir; tests upload fixture flows through
the same deploy path production uses.
"""
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from app.settings import settings
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
flow_dir = tmp_path_factory.mktemp("lfx-flows")
seed = (Path(__file__).parent / "fixtures" / "flows" / "simple_rule.json").read_text()
(flow_dir / "seed.json").write_text(seed)
env = {
**os.environ,
# lfx serve refuses to start without a serve key; requests need it too.
# get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo
# .env may put a real Langflow key in the environment — clear it so the
# test key below is the one the client sends.
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": "test-lfx-key",
}
proc = subprocess.Popen(
[
lfx_bin,
"serve",
"--host",
"127.0.0.1",
"--port",
str(port),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
)
url = f"http://127.0.0.1:{port}"
deadline = time.time() + 240
healthy = False
while time.time() < deadline:
if proc.poll() is not None:
out = proc.stdout.read() if proc.stdout else ""
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}")
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
if response.status == 200:
healthy = True
break
except OSError:
time.sleep(1)
if not healthy:
proc.terminate()
raise RuntimeError("lfx serve did not become healthy in time")
old_url = settings.lfx_url
old_key = settings.lfx_api_key
settings.lfx_url = url
settings.lfx_api_key = "test-lfx-key"
yield url
settings.lfx_url = old_url
settings.lfx_api_key = old_key
proc.terminate()
try:
proc.wait(timeout=20)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture()
def override_langflow():
"""Replace the Langflow dependency with a fake (real LFX stays untouched).
Usage: override_langflow(export_doc) -- the fake export_flow() returns the
doc, or raises LangflowNotFound/Unavailable for status=404/0.
Pass flows=[...] + docs={id: doc} for multi-flow pull tests, or
list_status=0/500 to fail the listing. Default list is empty so
file-only sync tests stay hermetic.
"""
from app.flows.api import get_langflow
from app.flows.langflow_client import LangflowError, LangflowNotFound, LangflowUnavailable
from app.main import app
def _override(export_doc=None, status=200, flows=None, docs=None, list_status=200):
if flows is None:
flows = [{"id": "fake-1", "name": "fake"}] if export_doc is not None else []
class FakeLangflow:
async def export_flow(self, flow_id):
if status == 404:
raise LangflowNotFound(f"flow {flow_id} not found")
if status == 0:
raise LangflowUnavailable("Langflow unreachable")
if docs is not None:
if flow_id in docs:
return docs[flow_id]
raise LangflowNotFound(f"flow {flow_id} not found")
return export_doc
async def list_flows(self):
if list_status == 0:
raise LangflowUnavailable("Langflow unreachable")
if list_status == 500:
raise LangflowError("Langflow error 500")
return flows
app.dependency_overrides[get_langflow] = lambda: FakeLangflow()
yield _override
app.dependency_overrides.pop(get_langflow, None)
@pytest.fixture(scope="session")
def live_server():
"""Real uvicorn server (ephemeral port) so LFX flows can call back
into our API over HTTP, exactly like production."""
import threading
import time
import uvicorn
from app.main import app
config = uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
deadline = time.time() + 30
while not server.started and time.time() < deadline:
time.sleep(0.05)
assert server.started, "uvicorn test server did not start"
port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=15)