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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue