280 lines
8.7 KiB
Python
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)
|