"""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") 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(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. """ from app.flows.api import get_langflow from app.flows.langflow_client import LangflowNotFound, LangflowUnavailable from app.main import app def _override(export_doc=None, status=200): 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") return export_doc async def list_flows(self): return [{"id": "fake-1", "name": "fake"}] 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)