"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E.""" import json from pathlib import Path from uuid import UUID, uuid4 from app.flows.sync import lfx_id_for FIXTURES = Path(__file__).parent / "fixtures" / "flows" CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"} 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, login): await login() 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_management_endpoints_require_admin(client, login): """Anonymous -> 401, member -> 403, admin -> through (A).""" flow_id = uuid4() probes = [ ("POST", "/api/flows", {"name": "x"}), ("POST", "/api/flows/sync", None), ("POST", f"/api/flows/{flow_id}/versions", {"langflow_json": {}}), ("GET", f"/api/flows/{flow_id}/versions", None), ("POST", f"/api/flows/{flow_id}/versions/1/deploy", None), ("POST", "/api/flows/import", {"langflow_flow_id": "x"}), ("GET", "/api/flows/langflow/available", None), ] for method, path, body in probes: if method == "POST": response = await client.post(path, json=body or {}) else: response = await client.get(path) assert response.status_code == 401, (method, path, response.text) await login(email="member@example.com", role="member") for method, path, body in probes: if method == "POST": response = await client.post(path, json=body or {}) else: response = await client.get(path) assert response.status_code == 403, (method, path, response.text) async def test_runs_and_reads_require_login(client, login): """Run, run reads and approval reads are login-gated (A).""" await login() flow = await _create_flow(client, "gated") run_id = uuid4() authed_paths = [ ("POST", f"/api/flows/{flow['id']}/run", {"input": {}}), ("GET", f"/api/flows/{flow['id']}/runs", None), ("GET", f"/api/flows/runs/{run_id}", None), ("GET", "/api/approvals", None), ("POST", f"/api/approvals/{uuid4()}/decision", {"decision": "approve"}), ] # Fresh client without a session cookie. from httpx import ASGITransport, AsyncClient from app.main import app transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as anon: for method, path, body in authed_paths: if method == "POST": response = await anon.post(path, json=body or {}) else: response = await anon.get(path) assert response.status_code == 401, (method, path, response.text) async def test_version_validation_and_numbering(client, login): await login() 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, login): await login() flow = await _create_flow(client, "dachsanierung") await _create_version(client, flow["id"]) deployed = await _deploy(client, flow["id"]) assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1) 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, login): await login() 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, login): await login() 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, login): await login() 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, login, monkeypatch): from app.settings import settings await login() monkeypatch.setattr(settings, "callback_base_url", 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}}, ) 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, login, monkeypatch ): from app.settings import settings await login() monkeypatch.setattr(settings, "callback_base_url", 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}}, ) 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, login): from uuid import UUID from app.flows import store as flow_store await login() 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}}, headers=CALLBACK_HEADERS, ) 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}", headers=CALLBACK_HEADERS) assert response.json()["status"] == "pending_approval" response = await client.post( "/api/approvals", json={"run_id": str(run.id)}, headers=CALLBACK_HEADERS ) 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())}, headers=CALLBACK_HEADERS, ) assert response.status_code == 422 response = await client.post( "/api/approvals", json={"run_id": str(uuid4())}, headers=CALLBACK_HEADERS ) assert response.status_code == 404 async def test_approval_callback_token_required(client, session, login): """Forged callbacks without the shared token are rejected (C).""" from uuid import UUID from app.flows import store as flow_store await login() flow = await _create_flow(client, "callback-gated") 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)}) assert response.status_code == 401 response = await client.post( "/api/approvals", json={"run_id": str(run.id)}, headers={"X-Callback-Token": "wrong"}, ) assert response.status_code == 401