feat: flow runtime on LFX with tests
- LfxClient (upload/run/info/delete, typed errors, serve-key auth) - Flows API: CRUD, versions, deploy, sync run, SSE stream proxy, runs - Approvals callback endpoint (flow terminal-state contract) - Runner: envelope in, status from approval side-effect (no interrupts) - Fixture generator (lfx serializers) + simple_rule/approval_handoff fixtures verified against real lfx serve - 14 tests green (real LFX subprocess + live uvicorn callback server)
This commit is contained in:
parent
e178ae406d
commit
96c0da33c7
15 changed files with 5070 additions and 5 deletions
|
|
@ -47,14 +47,26 @@ def db_engine():
|
|||
|
||||
@pytest.fixture()
|
||||
def session(db_engine):
|
||||
"""Function-scoped session; tables are truncated after each test."""
|
||||
from sqlmodel import Session, SQLModel
|
||||
"""Function-scoped session for direct DB access in tests."""
|
||||
from sqlmodel import Session
|
||||
|
||||
with Session(db_engine) as s:
|
||||
yield s
|
||||
with db_engine.begin() as conn:
|
||||
for table in reversed(SQLModel.metadata.sorted_tables):
|
||||
conn.execute(table.delete())
|
||||
|
||||
|
||||
@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()
|
||||
|
|
@ -67,3 +79,113 @@ async def client():
|
|||
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.
|
||||
"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(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)
|
||||
|
|
|
|||
1945
backend/tests/fixtures/flows/approval_handoff.json
vendored
Normal file
1945
backend/tests/fixtures/flows/approval_handoff.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1393
backend/tests/fixtures/flows/simple_rule.json
vendored
Normal file
1393
backend/tests/fixtures/flows/simple_rule.json
vendored
Normal file
File diff suppressed because one or more lines are too long
248
backend/tests/test_flows_api.py
Normal file
248
backend/tests/test_flows_api.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
||||
def load_fixture(name: str) -> dict:
|
||||
return json.loads((FIXTURES / name).read_text())
|
||||
|
||||
|
||||
async def _create_flow(client, name="regeltest"):
|
||||
response = await client.post(
|
||||
"/api/flows", json={"name": name, "description": "test flow"}
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _create_version(client, flow_id, fixture="simple_rule.json"):
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow_id}/versions",
|
||||
json={"langflow_json": load_fixture(fixture), "author": "test"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _deploy(client, flow_id, version_no=1):
|
||||
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 test_flow_crud(client):
|
||||
created = await _create_flow(client, "crud-flow")
|
||||
assert created["status"] == "active"
|
||||
|
||||
response = await client.get(f"/api/flows/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "crud-flow"
|
||||
|
||||
response = await client.get("/api/flows")
|
||||
assert response.status_code == 200
|
||||
assert any(f["id"] == created["id"] for f in response.json())
|
||||
|
||||
response = await client.post("/api/flows", json={"name": "crud-flow"})
|
||||
assert response.status_code == 409
|
||||
|
||||
response = await client.get(f"/api/flows/{uuid4()}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_version_validation_and_numbering(client):
|
||||
flow = await _create_flow(client, "versioned")
|
||||
|
||||
v1 = await _create_version(client, flow["id"])
|
||||
assert v1["version_no"] == 1
|
||||
v2 = await _create_version(client, flow["id"])
|
||||
assert v2["version_no"] == 2
|
||||
assert v1["deployed_at"] is None
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/versions", json={"langflow_json": {"nope": 1}}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{uuid4()}/versions",
|
||||
json={"langflow_json": load_fixture("simple_rule.json")},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
response = await client.get(f"/api/flows/{flow['id']}/versions")
|
||||
assert [v["version_no"] for v in response.json()] == [1, 2]
|
||||
|
||||
|
||||
async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
||||
flow = await _create_flow(client, "dachsanierung")
|
||||
await _create_version(client, flow["id"])
|
||||
deployed = await _deploy(client, flow["id"])
|
||||
assert deployed["lfx_flow_id"] == deployed["id"]
|
||||
assert deployed["deployed_at"] is not None
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={"input": {"betrag": 120000, "schwelle": 200000}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
run = response.json()
|
||||
assert run["status"] == "completed"
|
||||
assert "Standardfall" in run["output"]["result"]
|
||||
assert run["finished_at"] is not None
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={"input": {"betrag": 250000, "schwelle": 200000}},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
run = response.json()
|
||||
assert run["status"] == "completed"
|
||||
assert "Sonderfall" in run["output"]["result"]
|
||||
|
||||
response = await client.get(f"/api/flows/runs/{run['id']}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == run["id"]
|
||||
|
||||
|
||||
async def test_run_undeployed_version_fails(client):
|
||||
flow = await _create_flow(client, "undeployed")
|
||||
await _create_version(client, flow["id"])
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run", json={"input": {"betrag": 1}}
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_run_explicit_version_no(client, lfx_server):
|
||||
flow = await _create_flow(client, "explicit-version")
|
||||
await _create_version(client, flow["id"])
|
||||
await _create_version(client, flow["id"])
|
||||
await _deploy(client, flow["id"], version_no=2)
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={"input": {"betrag": 1}, "version_no": 2},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_run_stream_events(client, lfx_server):
|
||||
flow = await _create_flow(client, "streamed")
|
||||
await _create_version(client, flow["id"])
|
||||
await _deploy(client, flow["id"])
|
||||
|
||||
events: list[tuple[str, str]] = []
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"/api/flows/{flow['id']}/run?stream=true",
|
||||
json={"input": {"betrag": 1}},
|
||||
) as response:
|
||||
assert response.status_code == 200, await response.aread()
|
||||
current_event = "message"
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("event:"):
|
||||
current_event = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("data:"):
|
||||
events.append((current_event, line.split(":", 1)[1].strip()))
|
||||
current_event = "message"
|
||||
|
||||
assert events, "expected SSE events"
|
||||
assert events[0][0] == "run"
|
||||
first_payload = json.loads(events[0][1])
|
||||
assert "run_id" in first_payload
|
||||
assert events[-1][0] == "run_finished"
|
||||
last_payload = json.loads(events[-1][1])
|
||||
assert last_payload["status"] == "completed"
|
||||
|
||||
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_approval_handoff_end_to_end(client, lfx_server, live_server):
|
||||
flow = await _create_flow(client, "freigabe")
|
||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||
await _deploy(client, flow["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={
|
||||
"input": {"betrag": 250000, "schwelle": 200000},
|
||||
"callback_base_url": live_server,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
run = response.json()
|
||||
assert run["status"] == "pending_approval"
|
||||
assert "PENDING_HUMAN_APPROVAL" in run["output"]["result"]
|
||||
|
||||
response = await client.get("/api/approvals")
|
||||
assert response.status_code == 200
|
||||
tasks = response.json()
|
||||
assert len(tasks) == 1
|
||||
task = tasks[0]
|
||||
assert task["run_id"] == run["id"]
|
||||
assert task["status"] == "open"
|
||||
assert task["payload"]["facts"]["betrag"] == 250000
|
||||
|
||||
|
||||
async def test_approval_handoff_auto_creates_no_task(client, lfx_server, live_server):
|
||||
flow = await _create_flow(client, "freigabe-auto")
|
||||
await _create_version(client, flow["id"], fixture="approval_handoff.json")
|
||||
await _deploy(client, flow["id"])
|
||||
|
||||
response = await client.post(
|
||||
f"/api/flows/{flow['id']}/run",
|
||||
json={
|
||||
"input": {"betrag": 50000, "schwelle": 200000},
|
||||
"callback_base_url": live_server,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["status"] == "completed"
|
||||
|
||||
response = await client.get("/api/approvals")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
async def test_approval_callback_contract(client, session):
|
||||
from uuid import UUID
|
||||
|
||||
from app.flows import store as flow_store
|
||||
|
||||
flow = await _create_flow(client, "callback-target")
|
||||
version = await _create_version(client, flow["id"])
|
||||
version_obj = flow_store.get_version(session, UUID(version["id"]))
|
||||
|
||||
run = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||
|
||||
response = await client.post(
|
||||
"/api/approvals",
|
||||
json={"run_id": str(run.id), "title": "bitte pruefen", "payload": {"x": 1}},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
task = response.json()
|
||||
assert task["status"] == "open"
|
||||
assert task["tenant_id"] == "default"
|
||||
assert task["version_id"] == version["id"]
|
||||
|
||||
response = await client.get(f"/api/flows/runs/{run.id}")
|
||||
assert response.json()["status"] == "pending_approval"
|
||||
|
||||
response = await client.post("/api/approvals", json={"run_id": str(run.id)})
|
||||
assert response.status_code == 409
|
||||
|
||||
run2 = flow_store.create_run(session, version_obj, input={}, tenant_id="default")
|
||||
response = await client.post(
|
||||
"/api/approvals",
|
||||
json={"run_id": str(run2.id), "continuation_flow_id": str(uuid4())},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
response = await client.post("/api/approvals", json={"run_id": str(uuid4())})
|
||||
assert response.status_code == 404
|
||||
Loading…
Add table
Add a link
Reference in a new issue