refactor: tighten backend, flows, frontend, tests; push flows to Langflow

This commit is contained in:
Jonas Miederer 2026-09-28 15:58:00 +02:00
parent 6688fb08c6
commit 06ab25fb47
31 changed files with 783 additions and 358 deletions

68
backend/tests/_harness.py Normal file
View file

@ -0,0 +1,68 @@
"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions.
Single place for logic duplicated between tests/conftest.py and
scripts/smoke_e2e.py (~60 lines). Full real-LFX runs are kept.
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
def free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
return port
def wait_healthy(url: str, deadline_s: float = 240.0) -> None:
deadline = time.time() + deadline_s
while time.time() < deadline:
try:
with urllib.request.urlopen(url + "/health", timeout=3) as response:
if response.status == 200:
return
except OSError:
time.sleep(1)
raise RuntimeError(f"server did not become healthy: {url}")
def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"}
return 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,
)
def migrate_throwaway(backend_dir: Path) -> None:
from alembic import command as alembic_command
from alembic.config import Config
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head")

View file

@ -278,3 +278,89 @@ def live_server():
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:
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
return {
"id": flow_id,
"name": name,
"description": "",
"data": {"nodes": [], "edges": []},
}
@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
return {
"create_flow": _create_flow,
"create_version": _create_version,
"deploy": _deploy,
"deployed_flow": _deployed_flow,
}
@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)

View file

@ -2,18 +2,26 @@
import json
import pytest
from app.flows import export
def test_filename_stem_keeps_original_names():
assert export.filename_stem("simple_rule") == "simple_rule"
assert export.filename_stem("test_flow") == "test_flow"
assert export.filename_stem("Gehaltserhöhung") == "Gehaltserhöhung"
assert export.filename_stem("Dachsanierung 200k") == "Dachsanierung 200k"
assert export.filename_stem(" A/B\\C ") == "A-B-C"
assert export.filename_stem('a:b*c?d"e<f>g|h') == "a-b-c-d-e-f-g-h"
assert export.filename_stem("trailing...") == "trailing"
assert export.filename_stem("") == "flow"
@pytest.mark.parametrize(
("name", "expected"),
[
("simple_rule", "simple_rule"),
("test_flow", "test_flow"),
("Gehaltserhöhung", "Gehaltserhöhung"),
("Dachsanierung 200k", "Dachsanierung 200k"),
(" A/B\\C ", "A-B-C"),
('a:b*c?d"e<f>g|h', "a-b-c-d-e-f-g-h"),
("trailing...", "trailing"),
("", "flow"),
],
)
def test_filename_stem_keeps_original_names(name, expected):
assert export.filename_stem(name) == expected
def test_canonical_bytes_stable_under_key_reorder():