refactor: form-core wiring, JWT sessions, slowapi, packaging, frontend rewrite, test declutter

This commit is contained in:
Jonas Miederer 2026-09-28 18:03:07 +02:00
parent 06ab25fb47
commit 03af41e837
74 changed files with 6089 additions and 5183 deletions

View file

@ -66,3 +66,42 @@ def migrate_throwaway(backend_dir: Path) -> None:
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
alembic_command.upgrade(cfg, "head")
# Single constant: lfx prefers LANGFLOW_API_KEY over LFX_API_KEY, so the
# repo .env value must be cleared wherever a test key is used.
TEST_LFX_KEY = "test-lfx-key"
def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[no-untyped-def]
"""Real uvicorn server in a daemon thread. Returns (server, thread)."""
import threading
import time
import uvicorn
config = uvicorn.Config(app, host=host, port=port, 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"
return server, thread
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object:
"""Idempotent test user + session JWT (shared with smoke pattern)."""
from sqlmodel import select
from app.auth import service
from app.models import User
user = session.exec(select(User).where(User.email == email)).first()
if user is None:
user = User(name=name, email=email, role=role)
session.add(user)
session.commit()
session.refresh(user)
return user, service.mint_session_token(user)

View file

@ -19,6 +19,8 @@ os.environ["DATABASE_URL"] = f"sqlite:///{TEST_AREA / 'test.db'}"
os.environ["FLOWS_DIR"] = str(TEST_AREA / "flows")
# Flow callbacks (POST /api/approvals) authenticate with this token.
os.environ["CALLBACK_TOKEN"] = "test-callback-token"
# Stateless session JWTs are signed with this (HS256).
os.environ["JWT_SECRET"] = "test-jwt-secret-32-chars-minimum!!"
# The embedded watcher + startup sync stay off in tests; sync is exercised
# directly (sync_from_directory) with per-test tmp dirs instead.
os.environ["FLOWS_WATCH_ENABLED"] = "false"
@ -93,13 +95,11 @@ def login(client, session):
role: str = "admin",
tenant_id: str = "default",
):
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlmodel import select
from app.auth import service
from app.auth.deps import SESSION_COOKIE
from app.models import User, UserSession
from app.models import User
# Idempotent: re-login updates the existing row (tests switch
# identity mid-test by calling login() again).
@ -111,15 +111,9 @@ def login(client, session):
user.role = role
user.tenant_id = tenant_id
session.add(user)
session.flush()
user_session = UserSession(
id=f"test-session-{uuid4().hex}",
user_id=user.id,
expires_at=datetime.now(UTC) + timedelta(days=1),
)
session.add(user_session)
session.commit()
client.cookies.set(SESSION_COOKIE, user_session.id)
session.refresh(user)
client.cookies.set(SESSION_COOKIE, service.mint_session_token(user))
return user
return _login
@ -132,76 +126,33 @@ def lfx_server(tmp_path_factory):
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()
from ._harness import TEST_LFX_KEY, free_port, spawn_lfx, wait_healthy
port = free_port()
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.
# get_api_key() prefers LANGFLOW_API_KEY over LFX_API_KEY, and the repo
# .env may put a real Langflow key in the environment — clear it so the
# test key below is the one the client sends.
"LANGFLOW_API_KEY": "",
"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,
)
proc = spawn_lfx(flow_dir, port)
url = f"http://127.0.0.1:{port}"
deadline = time.time() + 240
healthy = False
while time.time() < deadline:
try:
wait_healthy(url)
except Exception:
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")
raise
old_url = settings.lfx_url
old_key = settings.lfx_api_key
settings.lfx_url = url
settings.lfx_api_key = "test-lfx-key"
settings.lfx_api_key = TEST_LFX_KEY
yield url
settings.lfx_url = old_url
settings.lfx_api_key = old_key
@ -259,21 +210,11 @@ def override_langflow():
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"
from ._harness import spawn_uvicorn
server, thread = spawn_uvicorn(app)
port = server.servers[0].sockets[0].getsockname()[1]
yield f"http://127.0.0.1:{port}"
server.should_exit = True
@ -293,12 +234,20 @@ def load_flow_fixture(name: str) -> dict:
def langflow_export_doc(name: str = "fake", flow_id: str = "fake-1") -> dict:
"""Minimal Langflow export envelope (merges _export_doc/_langflow_doc)."""
"""Full Langflow export envelope (merges _export_doc/_langflow_doc).
Carries real fixture flow data so import/deploy paths exercise LFX.
"""
fixture = load_flow_fixture("simple_rule.json")
return {
"id": flow_id,
"name": name,
"description": "",
"data": {"nodes": [], "edges": []},
"description": "aus Langflow",
"endpoint_name": None,
"data": fixture["data"],
"tags": [],
"webhook": False,
"is_component": False,
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,16 +1,15 @@
"""Step 5 tests: decisions + continuations, tenant isolation, error paths."""
import json
from pathlib import Path
from uuid import UUID, uuid4
from app.flows.schemas import ApprovalCreate
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import load_flow_fixture
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name):

View file

@ -93,6 +93,9 @@ async def test_request_approve_consume_me_logout(client, session):
assert me.json()["tenant_id"] == "default"
assert (await member.post("/api/auth/logout")).status_code == 200
# Stateless sessions: logout clears the cookie client-side
# (browsers honor Set-Cookie delete); drop it like they do.
member.cookies.clear()
assert (await member.get("/api/auth/me")).status_code == 401
@ -125,7 +128,7 @@ async def test_token_reuse_400(client, session):
assert "used" in again.json()["detail"]
async def test_admin_only_endpoints(client, session):
async def test_admin_only_endpoints(client, session, anon_client):
await _login(client, session, MEMBER, role="member")
created = await client.post(
"/api/access/requests",
@ -137,12 +140,7 @@ async def test_admin_only_endpoints(client, session):
approve = await client.post(f"/api/access/requests/{created.json()['id']}/approve")
assert approve.status_code == 403
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:
async for anon in anon_client():
assert (await anon.get("/api/access/requests")).status_code == 401
assert (
await anon.post(f"/api/access/requests/{created.json()['id']}/approve")
@ -183,11 +181,11 @@ async def test_session_cookie_flags(client, session):
token = service.issue_magic_token(session, user)
response = await client.post("/api/auth/magic/consume", json={"token": token})
assert response.status_code == 200
raw = " ".join(response.headers.get_list("set-cookie")).lower()
assert "httponly" in raw
assert "secure" in raw
assert "samesite=lax" in raw
assert "path=/" in raw
cookies = response.headers.get_list("set-cookie")
assert any("httponly" in c.lower() for c in cookies), cookies
assert any("secure" in c.lower() for c in cookies), cookies
assert any("samesite=lax" in c.lower() for c in cookies), cookies
assert any("path=/" in c.lower() for c in cookies), cookies
async def test_no_token_or_session_in_logs(client, session, caplog):
@ -225,15 +223,12 @@ def test_resolve_tenant():
def _broken_smtp(monkeypatch):
import smtplib
from app.auth import mail
class ExplodingSMTP:
def __init__(self, *args, **kwargs):
raise smtplib.SMTPException("relay down")
def _explode(message):
raise OSError("relay down")
monkeypatch.setattr(mail.smtplib, "SMTP", ExplodingSMTP)
monkeypatch.setattr(mail, "_send", _explode)
def test_magic_link_delivery_failure_returns_false(monkeypatch):
@ -315,9 +310,8 @@ def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
html_part = next(p for p in message.walk() if p.get_content_type() == "text/html")
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
assert ">Jetzt anmelden</a>" in html
assert mail.BLUE in html # kirchenblau brand bar
assert "http://localhost:3000/login?token=GEHEIM" in html
assert "Jetzt anmelden" in html
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]

View file

@ -3,6 +3,8 @@
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO_ROOT / "backend" / "scripts"))
@ -12,6 +14,7 @@ from check_prod import check
def _good() -> dict:
return {
"CALLBACK_TOKEN": "t" * 40,
"JWT_SECRET": "j" * 40,
"LANGFLOW_API_KEY": "k" * 24,
"LFX_API_KEY": "x" * 24,
"LANGFLOW_SUPERUSER_PASSWORD": "p" * 20,
@ -31,33 +34,28 @@ def test_good_env_passes_without_warnings():
assert warnings == []
def test_empty_and_short_secrets_fail():
@pytest.mark.parametrize(
("key", "bad_value"),
[
("CALLBACK_TOKEN", ""),
("JWT_SECRET", ""),
("CALLBACK_TOKEN", "t" * 10),
("JWT_SECRET", "j" * 10),
("LANGFLOW_SUPERUSER_PASSWORD", "short"),
("LANGFLOW_SECRET_KEY", "s" * 10),
("LANGFLOW_API_KEY", "sk-"),
("LFX_API_KEY", "lfx-abc"),
("FRONTEND_MODE", "dev"),
("APP_ENV", "dev"),
("LANGFLOW_AUTO_LOGIN", "true"),
],
)
def test_bad_values_fail(key, bad_value):
values = _good()
values["CALLBACK_TOKEN"] = ""
values["LANGFLOW_SUPERUSER_PASSWORD"] = "short"
values[key] = bad_value
failures, _ = check(values)
assert any("CALLBACK_TOKEN" in f for f in failures)
assert any("LANGFLOW_SUPERUSER_PASSWORD" in f for f in failures)
def test_placeholders_fail():
values = _good()
values["LANGFLOW_API_KEY"] = "sk-"
values["LFX_API_KEY"] = "lfx-abc"
failures, _ = check(values)
assert any("LANGFLOW_API_KEY" in f for f in failures)
assert any("LFX_API_KEY" in f for f in failures)
def test_insecure_modes_fail():
values = _good()
values["FRONTEND_MODE"] = "dev"
values["APP_ENV"] = "dev"
values["LANGFLOW_AUTO_LOGIN"] = "true"
failures, _ = check(values)
assert len([f for f in failures if "FRONTEND_MODE" in f]) == 1
assert len([f for f in failures if "APP_ENV" in f]) == 1
assert len([f for f in failures if "AUTO_LOGIN" in f]) == 1
wanted = "AUTO_LOGIN" if key == "LANGFLOW_AUTO_LOGIN" else key
assert any(wanted in f for f in failures), failures
def test_soft_misconfigurations_warn_only():

View file

@ -1,27 +1,20 @@
"""Step 4 tests: Langflow import pipeline (Langflow faked, LFX real)."""
import json
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import langflow_export_doc
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,
}
"""Alias of the shared conftest helper (kept for call-site stability)."""
return langflow_export_doc(name, LANGFLOW_ID)
async def test_import_creates_definition_version_and_deploys(
@ -100,6 +93,14 @@ async def test_import_with_explicit_name(client, login, lfx_server, override_lan
assert response.json()["definition"]["name"] == "wunschname"
async def test_import_with_implicit_name_uses_langflow_name(client, login, override_langflow):
override_langflow(_export_doc(name="langflow-name"))
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID})
assert response.status_code == 201, response.text
assert response.json()["definition"]["name"] == "langflow-name"
async def test_import_without_deploy(client, login, override_langflow):
override_langflow(_export_doc())
await login()
@ -113,18 +114,19 @@ async def test_import_without_deploy(client, login, override_langflow):
assert body["version"]["deployed_at"] is None
async def test_import_langflow_not_found(client, login, override_langflow):
override_langflow(status=404)
@pytest.mark.parametrize(
("fake_status", "expected_status"),
[(404, 404), (0, 503)],
)
async def test_import_langflow_failures(
client, login, override_langflow, fake_status, expected_status
):
"""Langflow 404/unreachable maps to 404/503 (parametrized)."""
override_langflow(status=fake_status)
await login()
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, login, override_langflow):
override_langflow(status=0)
await login()
response = await client.post("/api/flows/import", json={"langflow_flow_id": str(uuid4())})
assert response.status_code == 503
target = LANGFLOW_ID if fake_status == 404 else str(uuid4())
response = await client.post("/api/flows/import", json={"langflow_flow_id": target})
assert response.status_code == expected_status
async def test_list_langflow_flows(client, login, override_langflow):

View file

@ -1,17 +1,20 @@
"""Step 3 tests: flows CRUD, versions, deploy, sync/stream runs, handoff E2E."""
import json
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from app.flows.sync import lfx_id_for
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import load_flow_fixture
CALLBACK_HEADERS = {"X-Callback-Token": "test-callback-token"}
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
async def _create_flow(client, name="regeltest"):
@ -55,59 +58,57 @@ async def test_flow_crud(client, login):
assert response.status_code == 404
async def test_management_endpoints_require_admin(client, login):
"""Anonymous -> 401, member -> 403, admin -> through (A)."""
MANAGEMENT_PROBES = [
("POST", "/api/flows", {"name": "x"}),
("POST", "/api/flows/sync", None),
("POST", "/api/flows/{flow_id}/versions", {"langflow_json": {}}),
("GET", "/api/flows/{flow_id}/versions", None),
("POST", "/api/flows/{flow_id}/versions/1/deploy", None),
("POST", "/api/flows/import", {"langflow_flow_id": "x"}),
("GET", "/api/flows/langflow/available", None),
]
@pytest.mark.parametrize(("method", "path", "body"), MANAGEMENT_PROBES)
async def test_management_endpoints_require_admin(client, login, method, path, body):
"""Anonymous -> 401, member -> 403 (parametrized per endpoint)."""
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)
target = path.format(flow_id=flow_id)
if method == "POST":
response = await client.post(target, json=body or {})
else:
response = await client.get(target)
assert response.status_code == 401, (method, target, 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)
if method == "POST":
response = await client.post(target, json=body or {})
else:
response = await client.get(target)
assert response.status_code == 403, (method, target, response.text)
async def test_runs_and_reads_require_login(client, login):
"""Run, run reads and approval reads are login-gated (A)."""
LOGIN_GATED_PATHS = [
("POST", "/api/flows/{flow_id}/run", {"input": {}}),
("GET", "/api/flows/{flow_id}/runs", None),
("GET", "/api/flows/runs/{run_id}", None),
("GET", "/api/approvals", None),
("POST", "/api/approvals/{task_id}/decision", {"decision": "approve"}),
]
@pytest.mark.parametrize(("method", "path", "body"), LOGIN_GATED_PATHS)
async def test_runs_and_reads_require_login(client, login, anon_client, method, path, body):
"""Run, run reads and approval reads are login-gated (parametrized)."""
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)
target = path.format(flow_id=flow["id"], run_id=uuid4(), task_id=uuid4())
async for anon in anon_client():
if method == "POST":
response = await anon.post(target, json=body or {})
else:
response = await anon.get(target)
assert response.status_code == 401, (method, target, response.text)
async def test_version_validation_and_numbering(client, login):
@ -211,12 +212,13 @@ async def test_run_stream_events(client, lfx_server, login):
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"
by_type = {}
for event, data in events:
by_type.setdefault(event, []).append(json.loads(data))
assert "run_id" in by_type["run"][0]
finished = [p for p in by_type.get("run_finished", []) if "status" in p]
assert finished and finished[0]["status"] == "completed"
first_payload = by_type["run"][0]
response = await client.get(f"/api/flows/runs/{first_payload['run_id']}")
assert response.status_code == 200

View file

@ -293,11 +293,54 @@ def test_embedded_code_matches_template_fields():
field["name"] for field in template.values() if isinstance(field, dict)
} - {"code"}
code = template["code"]["value"]
# Codegen-inlined core: self-contained for LFX, single truth in repo.
assert "from kirche" not in code, filename
assert "from ._" not in code, filename
assert "collect_form_data" in code, filename
assert "parse_envelope" in code, filename
cls = validate_mod.create_class(code, class_name)
code_names = {inp.name for inp in cls.inputs}
assert code_names == template_names, filename
def test_form_core_envelope_and_conditions():
"""Unit tests for the shared core (single truth for all 3 forms)."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "components"))
from kirche._form_core import (
_field_conditions,
collect_form_data,
parse_envelope,
)
payload, asked = parse_envelope('{"payload": {"a": 1}, "fields": ["a", ""]}')
assert payload == {"a": 1} and asked == ["a"]
payload, asked = parse_envelope('{"a": 2}')
assert payload == {"a": 2} and asked is None
payload, asked = parse_envelope("not json")
assert payload == {} and asked is None
assert _field_conditions("x [visible_if a=B]", None) == [("a", "B")]
assert _field_conditions("x", [{"field": "a", "value": 1}]) == [("a", 1)]
assert _field_conditions("x [visible_if a=B]", {"a": 2}) == [("a", 2)]
effective, scope = collect_form_data(["a", "b"], {"a": 1, "b": 2}, {"b": 3}, ["a"])
assert scope == ["a"] and effective["b"] == 3
def test_kirche_node_inlines_core():
"""Generator composes thin source + core into self-contained code."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from make_flow_fixtures import _compose_component_code, _inline_core
core = _inline_core()
assert "def parse_envelope" in core and "def collect_form_data" in core
thin = "from kirche._form_core import parse_envelope\n# <-- _form_core inlined -->\nX = 1\n"
composed = _compose_component_code(thin)
assert "from kirche" not in composed and "def parse_envelope" in composed
def test_bauvorhaben_fixture_shape():
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
assert doc["name"] == "bauvorhaben"
@ -332,8 +375,8 @@ def test_bauvorhaben_schema_branches():
schema = out["json_schema"]
assert set(schema["properties"]) == {"gebaeude", "denkmalschutz"}
assert schema["required"] == ["gebaeude"]
assert len(schema["allOf"]) == 5
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
assert len(schema["allOf"]) == len(by_field) == 5
assert set(by_field) == {
"bestandteil",
"glockenmassnahme",
@ -355,7 +398,9 @@ def test_bauvorhaben_schema_branches():
assert "visible_if" not in out["json_schema"]["allOf"][0]["then"]["properties"][
"bestandteil"
].get("description", "")
assert out["ui_schema"]["ui:order"] == [
order = out["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
@ -363,8 +408,7 @@ def test_bauvorhaben_schema_branches():
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"*",
]
}
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):

View file

@ -4,12 +4,14 @@ import json
from pathlib import Path
from uuid import uuid4
import pytest
from app.flows import input_schema as parser
from app.flows import sync
from app.flows.lfx_client import LfxClient
from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
from .conftest import FIXTURES_DIR as FIXTURES
def _field(name, itype, **kw):
@ -48,14 +50,55 @@ def _branch_schema(doc):
return schema
def test_type_mapping_table():
@pytest.mark.parametrize(
("itype", "kwargs", "json_type", "widget"),
[
("StrInput", {"value": "x"}, "string", None),
("MessageTextInput", {}, "string", None),
("MultilineInput", {}, "string", "textarea"),
("SecretStrInput", {}, "string", "password"),
("IntInput", {"value": 3}, "integer", None),
("FloatInput", {"value": 1.5}, "number", None),
("BoolInput", {"value": True}, "boolean", None),
(
"DropdownInput",
{"options": ["Music", "Youth"], "value": "Youth"},
"string",
None,
),
(
"MultiselectInput",
{"options": ["Worship", "Courses"], "value": ["Worship"]},
"array",
None,
),
(
"SliderInput",
{"value": 3, "range_spec": {"min": 1, "max": 5, "step": 1}},
"number",
"range",
),
("FileInput", {}, "string", None),
("FutureFancyInput", {}, "string", None),
],
)
def test_type_mapping_table(itype, kwargs, json_type, widget):
doc = _doc(_field("a", itype, **kwargs))
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
prop = schema["json_schema"]["properties"]["a"]
assert prop["type"] == json_type
ui = schema["ui_schema"].get("a", {})
if widget is None:
assert "ui:widget" not in ui
else:
assert ui["ui:widget"] == widget
def test_type_mapping_details():
doc = _doc(
_field("a", "StrInput", value="x"),
_field("b", "MessageTextInput"),
_field("c", "MultilineInput"),
_field("d", "SecretStrInput"),
_field("e", "IntInput", value=3),
_field("f", "FloatInput", value=1.5),
_field("g", "BoolInput", value=True),
_field(
"h",
@ -75,16 +118,11 @@ def test_type_mapping_table():
value=3,
range_spec={"min": 1, "max": 5, "step": 1},
),
_field("k", "FileInput"),
_field("l", "FutureFancyInput"),
)
schema = parser.extract_form_schema(doc)
assert "fallback" not in schema
props = schema["json_schema"]["properties"]
assert props["a"] == {"title": "A", "type": "string", "default": "x"}
assert props["b"]["type"] == "string"
assert props["e"] == {"title": "E", "type": "integer", "default": 3}
assert props["f"]["type"] == "number"
assert props["g"] == {"title": "G", "type": "boolean", "default": True}
assert props["h"] == {
"title": "H",
@ -98,12 +136,6 @@ def test_type_mapping_table():
assert props["j"]["minimum"] == 1
assert props["j"]["maximum"] == 5
assert props["j"]["multipleOf"] == 1
assert props["k"]["type"] == "string"
assert props["l"]["type"] == "string"
ui = schema["ui_schema"]
assert ui["c"] == {"ui:widget": "textarea"}
assert ui["d"] == {"ui:widget": "password"}
assert ui["j"] == {"ui:widget": "range"}
def test_dropdown_default_outside_enum_omitted():
@ -122,7 +154,9 @@ def test_required_advanced_hidden_code():
schema = parser.extract_form_schema(doc)
assert schema["json_schema"]["required"] == ["a"]
assert set(schema["json_schema"]["properties"]) == {"a", "b"}
assert schema["ui_schema"]["ui:order"] == ["a", "b", "*"]
order = schema["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {"a", "b"}
def test_display_name_match_and_fallback():
@ -260,7 +294,9 @@ def test_visible_if_single_branch():
assert rule["then"]["required"] == ["bestandteil"]
# Marker stripped from the shown description.
assert schema["properties"]["gebaeude"]["title"] == "Gebaeude"
assert out["ui_schema"]["ui:order"] == ["gebaeude", "bestandteil", "*"]
order = out["ui_schema"]["ui:order"]
assert order[-1] == "*"
assert set(order[:-1]) == {"gebaeude", "bestandteil"}
def test_visible_if_nested_three_levels():

View file

@ -38,7 +38,8 @@ async def test_oversize_body_rejected(client):
async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
from app.settings import settings
monkeypatch.setattr(settings, "run_throttle_per_minute", 2)
limit = 2
monkeypatch.setattr(settings, "run_throttle_per_minute", limit)
await login()
flow = (await client.post("/api/flows", json={"name": "throttled"})).json()
@ -65,7 +66,7 @@ async def test_run_throttle_returns_429(client, lfx_server, login, monkeypatch):
},
)
# Undeployed: 422s still pass the throttle (only 429 matters here).
for _ in range(2):
for _ in range(limit):
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})
assert response.status_code == 422
response = await client.post(f"/api/flows/{flow['id']}/run", json={"input": {}})

View file

@ -8,6 +8,9 @@ from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion
def test_flow_definition_defaults(session):
from datetime import UTC, datetime
before = datetime.now(UTC).replace(tzinfo=None)
d = FlowDefinition(name="bauvorhaben")
session.add(d)
session.commit()
@ -18,7 +21,8 @@ def test_flow_definition_defaults(session):
assert d.created_at is not None
# Timestamps are written as UTC. SQLite does not round-trip tzinfo, so
# after a refresh the value is calendar-time UTC without tzinfo.
assert d.created_at.year >= 2026
assert d.created_at.tzinfo is None
assert abs((d.created_at - before).total_seconds()) < 60
def test_flow_definition_name_unique(session):

View file

@ -14,22 +14,24 @@ from app.flows.schemas import SyncReport
from app.flows.sync import lfx_id_for
from app.settings import settings
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
def load_fixture(name: str) -> dict:
return json.loads((FIXTURES / name).read_text())
def _lfx() -> LfxClient:
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
from .conftest import FIXTURES_DIR, langflow_export_doc, load_flow_fixture
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
shutil.copy(FIXTURES / name, tmp_path / name)
shutil.copy(FIXTURES_DIR / name, tmp_path / name)
return tmp_path
def load_fixture(name: str) -> dict:
"""Alias of the shared conftest helper (kept for call-site stability)."""
return load_flow_fixture(name)
def _lfx() -> LfxClient:
"""Alias of the shared lfx_client fixture logic (call-site stability)."""
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
async def test_sync_adds_new_flow(session, lfx_server, tmp_path):
_seed_dir(tmp_path)
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
@ -145,17 +147,8 @@ async def test_sync_endpoint_returns_report(
def _langflow_doc(name: str, langflow_id: str) -> dict:
doc = load_fixture("simple_rule.json")
return {
"id": langflow_id,
"name": name,
"description": "aus Langflow",
"endpoint_name": None,
"data": doc["data"],
"tags": [],
"webhook": False,
"is_component": False,
}
"""Alias of the shared conftest helper (kept for call-site stability)."""
return langflow_export_doc(name, langflow_id)
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
@ -227,24 +220,26 @@ async def test_sync_endpoint_pull_error_continues(
assert "kaputt-id" in body["errors"]
async def test_sync_endpoint_langflow_unreachable(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
@pytest.mark.parametrize(
("list_status", "expected_status"),
[(0, 503), (500, 502)],
)
async def test_sync_endpoint_langflow_failures(
client,
lfx_server,
tmp_path,
monkeypatch,
login,
override_langflow,
list_status,
expected_status,
):
"""Listing failures map to 503/502 (parametrized)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(flows=[], list_status=0)
override_langflow(flows=[], list_status=list_status)
response = await client.post("/api/flows/sync")
assert response.status_code == 503
async def test_sync_endpoint_langflow_error(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
override_langflow(flows=[], list_status=500)
response = await client.post("/api/flows/sync")
assert response.status_code == 502
assert response.status_code == expected_status
async def test_watch_forever_triggers_on_file_change(tmp_path):