feat: Langflow authoring pipeline with tests

- LangflowClient (list/export, x-api-key auth, error mapping)
- import_from_langflow: export -> versioned definition -> LFX deploy
- POST /api/flows/import, GET /api/flows/langflow/available
- scripts/deploy_flow.py CLI
- 25 tests green (Langflow faked via dependency override + respx
  client tests; respx passthrough proven unreliable for LFX traffic)
This commit is contained in:
Jonas Miederer 2026-09-06 23:01:47 +00:00
parent 96c0da33c7
commit 1034206870
8 changed files with 451 additions and 0 deletions

View file

@ -166,6 +166,35 @@ def lfx_server(tmp_path_factory):
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.
"""
from app.flows.api import get_langflow
from app.flows.langflow_client import LangflowNotFound, LangflowUnavailable
from app.main import app
def _override(export_doc=None, status=200):
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")
return export_doc
async def list_flows(self):
return [{"id": "fake-1", "name": "fake"}]
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

View file

@ -0,0 +1,109 @@
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
import json
from pathlib import Path
from uuid import uuid4
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
def _export_doc(name="importiert"):
fixture = json.loads((FIXTURES / "simple_rule.json").read_text())
return {
"id": LANGFLOW_ID,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": fixture["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
async def test_import_creates_definition_version_and_deploys(
client, lfx_server, override_langflow
):
override_langflow(_export_doc())
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "author": "admin"},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["definition"]["name"] == "importiert"
assert body["version"]["version_no"] == 1
assert body["version"]["author"] == "admin"
assert body["version"]["lfx_flow_id"] == body["version"]["id"]
assert body["version"]["deployed_at"] is not None
assert body["version"]["langflow_json"]["data"]["nodes"]
# Imported flow actually runs on LFX.
definition_id = body["definition"]["id"]
response = await client.post(
f"/api/flows/{definition_id}/run", json={"input": {"betrag": 10}}
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "completed"
async def test_import_reuses_definition_and_bumps_version(
client, lfx_server, override_langflow
):
override_langflow(_export_doc())
first = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
second = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert first.status_code == 201
assert second.status_code == 201
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
assert second.json()["version"]["version_no"] == 2
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "name": "wunschname"},
)
assert response.status_code == 201, response.text
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_without_deploy(client, override_langflow):
override_langflow(_export_doc())
response = await client.post(
"/api/flows/import",
json={"langflow_flow_id": LANGFLOW_ID, "deploy": False},
)
assert response.status_code == 201, response.text
body = response.json()
assert body["version"]["lfx_flow_id"] is None
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, override_langflow):
override_langflow(status=404)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
)
assert response.status_code == 404
async def test_import_langflow_unreachable(client, override_langflow):
override_langflow(status=0)
response = await client.post(
"/api/flows/import", json={"langflow_flow_id": str(uuid4())}
)
assert response.status_code == 503
async def test_list_langflow_flows(client, override_langflow):
override_langflow(_export_doc())
response = await client.get("/api/flows/langflow/available")
assert response.status_code == 200
assert response.json() == [{"id": "fake-1", "name": "fake"}]

View file

@ -0,0 +1,52 @@
"""LangflowClient HTTP mapping tests (Langflow mocked via respx, no LFX)."""
import httpx
import pytest
import respx
from app.flows.langflow_client import (
LangflowClient,
LangflowNotFound,
LangflowUnavailable,
)
BASE = "https://langflow.test"
def _client():
return LangflowClient(BASE, api_key="test-key")
@respx.mock
async def test_export_flow_sends_api_key_and_returns_doc():
doc = {"id": "abc", "name": "n", "data": {"nodes": [], "edges": []}}
route = respx.get(f"{BASE}/api/v1/flows/abc").mock(
return_value=httpx.Response(200, json=doc)
)
result = await _client().export_flow("abc")
assert result == doc
assert route.called
assert route.calls[0].request.headers["x-api-key"] == "test-key"
@respx.mock
async def test_export_flow_not_found():
respx.get(f"{BASE}/api/v1/flows/missing").mock(
return_value=httpx.Response(404, json={"detail": "nope"})
)
with pytest.raises(LangflowNotFound):
await _client().export_flow("missing")
@respx.mock
async def test_list_flows_unwraps_items():
respx.get(f"{BASE}/api/v1/flows/").mock(
return_value=httpx.Response(200, json={"items": [{"id": "1"}]})
)
assert await _client().list_flows() == [{"id": "1"}]
async def test_unreachable_raises_unavailable():
client = LangflowClient("https://langflow.dead", api_key="k")
with pytest.raises(LangflowUnavailable):
await client.export_flow("whatever")