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
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