320 lines
10 KiB
Python
320 lines
10 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"
|
|
# Stateless session JWTs are signed with this (HS256).
|
|
os.environ["JWT_SECRET"] = "test-jwt-secret-32-chars-minimum!!"
|
|
# 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 sqlmodel import select
|
|
|
|
from app.auth import service
|
|
from app.auth.deps import SESSION_COOKIE
|
|
from app.models import User
|
|
|
|
# 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.commit()
|
|
session.refresh(user)
|
|
client.cookies.set(SESSION_COOKIE, service.mint_session_token(user))
|
|
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 subprocess
|
|
from pathlib import Path
|
|
|
|
from app.settings import settings
|
|
|
|
from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
|
|
|
|
port = free_port()
|
|
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)
|
|
|
|
proc = spawn_lfx(flow_dir, port)
|
|
url = f"http://127.0.0.1:{port}"
|
|
try:
|
|
wait_healthy(url)
|
|
except Exception:
|
|
if proc.poll() is not None:
|
|
out = proc.stdout.read() if proc.stdout else ""
|
|
raise RuntimeError(f"lfx serve exited early:\n{out[-4000:]}")
|
|
proc.terminate()
|
|
raise
|
|
|
|
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."""
|
|
from app.main import app
|
|
|
|
from ._harness import spawn_uvicorn
|
|
|
|
server, thread = spawn_uvicorn(app)
|
|
port = server.servers[0].sockets[0].getsockname()[1]
|
|
yield f"http://127.0.0.1:{port}"
|
|
server.should_exit = True
|
|
thread.join(timeout=15)
|
|
|
|
|
|
# --- shared declutter helpers (single place, was copy-pasted across files) ---
|
|
|
|
FIXTURES_DIR = Path(__file__).parent / "fixtures" / "flows"
|
|
|
|
|
|
def load_flow_fixture(name: str) -> dict:
|
|
"""Load a fixture flow JSON (shared; was load_fixture() x3)."""
|
|
import json
|
|
|
|
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
|
|
|
|
|
|
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
|
|
"""Full Langflow export envelope (merges _export_doc/_langflow_doc).
|
|
|
|
Carries real fixture flow data so import/deploy paths exercise LFX.
|
|
"""
|
|
fixture = load_flow_fixture("simple_rule.json")
|
|
return {
|
|
"id": flow_id,
|
|
"name": name,
|
|
"description": "aus Langflow",
|
|
"endpoint_name": None,
|
|
"data": fixture["data"],
|
|
"tags": [],
|
|
"webhook": False,
|
|
"is_component": False,
|
|
}
|
|
|
|
|
|
@pytest.fixture()
|
|
def anon_client():
|
|
"""Unauthenticated ASGI client (was 3x copy-pasted ASGITransport blocks)."""
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.main import app
|
|
|
|
async def _client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
return _client
|
|
|
|
|
|
@pytest.fixture()
|
|
def flow_helpers(client, login):
|
|
"""API flow factory: create flow/version/deploy/deployed_flow in one place.
|
|
|
|
Collapses _create_flow/_create_version/_deploy x4 files (~60 lines).
|
|
"""
|
|
|
|
async def _create_flow(name: str = "regeltest") -> dict:
|
|
response = await client.post("/api/flows", json={"name": name, "description": "t"})
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
async def _create_version(flow_id: str, fixture: str = "simple_rule.json") -> dict:
|
|
response = await client.post(
|
|
f"/api/flows/{flow_id}/versions",
|
|
json={"langflow_json": load_flow_fixture(fixture), "author": "test"},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
async def _deploy(flow_id: str, version_no: int = 1) -> dict:
|
|
response = await client.post(f"/api/flows/{flow_id}/versions/{version_no}/deploy")
|
|
assert response.status_code == 200, response.text
|
|
return response.json()
|
|
|
|
async def _deployed_flow(name: str = "regeltest", fixture: str = "simple_rule.json") -> tuple:
|
|
await login()
|
|
flow = await _create_flow(name)
|
|
version = await _create_version(flow["id"], fixture)
|
|
deployed = await _deploy(flow["id"], version["version_no"])
|
|
return flow, deployed
|
|
|
|
async def _version_and_deploy(flow_id: str, fixture: str = "simple_rule.json") -> dict:
|
|
await _create_version(flow_id, fixture)
|
|
return await _deploy(flow_id)
|
|
|
|
return {
|
|
"create_flow": _create_flow,
|
|
"create_version": _create_version,
|
|
"deploy": _deploy,
|
|
"deployed_flow": _deployed_flow,
|
|
"version_and_deploy": _version_and_deploy,
|
|
}
|
|
|
|
|
|
@pytest.fixture()
|
|
def lfx_client():
|
|
"""LFX client pointed at the real test server (was _lfx() x2)."""
|
|
from app.flows.lfx_client import LfxClient
|
|
from app.settings import settings
|
|
|
|
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|