410 lines
14 KiB
Python
410 lines
14 KiB
Python
"""Phase 3 tests: form-schema extraction, sync cache, input-schema endpoint."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from uuid import uuid4
|
|
|
|
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"
|
|
|
|
|
|
def _field(name, itype, **kw):
|
|
base = {
|
|
"name": name,
|
|
"display_name": name.replace("_", " ").title(),
|
|
"info": "",
|
|
"value": None,
|
|
"required": False,
|
|
"advanced": False,
|
|
"show": True,
|
|
"_input_type": itype,
|
|
}
|
|
base.update(kw)
|
|
return base
|
|
|
|
|
|
def _doc(*fields, node_type="CustomInputForm", display="CustomInput Form"):
|
|
template = {f["name"]: f for f in fields}
|
|
node = {
|
|
"data": {
|
|
"type": node_type,
|
|
"node": {"display_name": display, "template": template},
|
|
}
|
|
}
|
|
return {"data": {"nodes": [node], "edges": []}}
|
|
|
|
|
|
def _dropdown(name, options, **kw):
|
|
return _field(name, "DropdownInput", options=options, **kw)
|
|
|
|
|
|
def _branch_schema(doc):
|
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
|
assert "allOf" in schema
|
|
return schema
|
|
|
|
|
|
def test_type_mapping_table():
|
|
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",
|
|
"DropdownInput",
|
|
options=["Music", "Youth"],
|
|
value="Youth",
|
|
),
|
|
_field(
|
|
"i",
|
|
"MultiselectInput",
|
|
options=["Worship", "Courses"],
|
|
value=["Worship"],
|
|
),
|
|
_field(
|
|
"j",
|
|
"SliderInput",
|
|
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",
|
|
"type": "string",
|
|
"enum": ["Music", "Youth"],
|
|
"default": "Youth",
|
|
}
|
|
assert props["i"]["type"] == "array"
|
|
assert props["i"]["items"] == {"type": "string", "enum": ["Worship", "Courses"]}
|
|
assert props["i"]["uniqueItems"] is True
|
|
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():
|
|
doc = _doc(_field("h", "DropdownInput", options=["Music"], value=""))
|
|
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
|
|
assert props["h"] == {"title": "H", "type": "string", "enum": ["Music"]}
|
|
|
|
|
|
def test_required_advanced_hidden_code():
|
|
doc = _doc(
|
|
_field("a", "StrInput", required=True),
|
|
_field("b", "StrInput", advanced=True),
|
|
_field("c", "StrInput", show=False),
|
|
)
|
|
doc["data"]["nodes"][0]["data"]["node"]["template"]["code"] = {"value": "x"}
|
|
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", "*"]
|
|
|
|
|
|
def test_display_name_match_and_fallback():
|
|
doc = _doc(_field("a", "StrInput"), node_type="Custom", display="CustomInput X")
|
|
assert set(parser.extract_form_schema(doc)["json_schema"]["properties"]) == {"a"}
|
|
plain = {
|
|
"data": {
|
|
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
|
"edges": [],
|
|
}
|
|
}
|
|
schema = parser.extract_form_schema(plain)
|
|
assert schema["fallback"] == "textarea"
|
|
assert schema["json_schema"]["properties"] == {}
|
|
|
|
|
|
def test_real_intake_fixture_parses():
|
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
schema = parser.extract_form_schema(doc)
|
|
props = schema["json_schema"]["properties"]
|
|
assert "fallback" not in schema
|
|
assert set(props) == {
|
|
"full_name",
|
|
"notes",
|
|
"password",
|
|
"age",
|
|
"amount",
|
|
"newsletter",
|
|
"department",
|
|
"topics",
|
|
"satisfaction",
|
|
"attachment",
|
|
}
|
|
assert "full_name" in schema["json_schema"]["required"]
|
|
assert props["department"]["enum"] == ["Music", "Youth", "Seniors", "Admin"]
|
|
assert schema["ui_schema"]["notes"] == {"ui:widget": "textarea"}
|
|
|
|
|
|
def _lfx() -> LfxClient:
|
|
return LfxClient(settings.lfx_url, api_key=settings.lfx_api_key or None)
|
|
|
|
|
|
async def test_sync_caches_schema_per_version(session, lfx_server, tmp_path):
|
|
(tmp_path / "intake_form.json").write_text((FIXTURES / "intake_form.json").read_text())
|
|
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
|
assert not report.errors, report.errors
|
|
from app.flows import store
|
|
|
|
defs = store.list_definitions(session)
|
|
definition = next(d for d in defs if d.name == "intake_form")
|
|
versions = store.list_versions(session, definition.id)
|
|
assert len(versions) == 1
|
|
cached = versions[0].input_schema
|
|
assert cached["json_schema"]["properties"]["full_name"]["type"] == "string"
|
|
assert "form_values" not in cached["json_schema"]["properties"]
|
|
|
|
|
|
async def test_input_schema_endpoint(client, lfx_server, login):
|
|
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
|
|
|
await login()
|
|
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
|
created = await client.post("/api/flows", json={"name": "schema-e2e"})
|
|
flow_id = created.json()["id"]
|
|
version = await client.post(f"/api/flows/{flow_id}/versions", json={"langflow_json": doc})
|
|
assert version.status_code == 201, version.text
|
|
assert version.json()["input_schema"]["json_schema"]["properties"]["age"]["type"] == "integer"
|
|
# Created but never deployed → 404.
|
|
assert (await client.get(f"/api/flows/{flow_id}/input-schema")).status_code == 404
|
|
|
|
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
|
assert deployed.status_code == 200, deployed.text
|
|
response = await client.get(f"/api/flows/{flow_id}/input-schema")
|
|
assert response.status_code == 200, response.text
|
|
body = response.json()
|
|
assert body["version_no"] == 1
|
|
assert body["fallback"] is None
|
|
assert set(body["json_schema"]["properties"]) >= {"full_name", "topics"}
|
|
assert body["ui_schema"]["password"] == {"ui:widget": "password"}
|
|
|
|
|
|
def test_free_rename_keys_kept_verbatim():
|
|
doc = _doc(
|
|
_field("name", "StrInput", display_name="Name", required=True),
|
|
_field("favorite room", "StrInput"),
|
|
)
|
|
schema = parser.extract_form_schema(doc)
|
|
assert set(schema["json_schema"]["properties"]) == {"name", "favorite room"}
|
|
assert schema["json_schema"]["required"] == ["name"]
|
|
|
|
|
|
def test_empty_name_skipped_and_collision_suffixed():
|
|
a = _field("a", "StrInput")
|
|
clash = _field("other_key", "StrInput")
|
|
clash["name"] = "a" # Langflow rename collision: second field gets a suffix
|
|
empty = _field("zzz", "StrInput")
|
|
empty["name"] = " "
|
|
node = {
|
|
"data": {
|
|
"type": "CustomInputForm",
|
|
"node": {
|
|
"display_name": "CustomInput Form",
|
|
"template": {"a": a, "other_key": clash, "zzz": empty},
|
|
},
|
|
}
|
|
}
|
|
doc = {"data": {"nodes": [node], "edges": []}}
|
|
props = parser.extract_form_schema(doc)["json_schema"]["properties"]
|
|
assert set(props) == {"a", "a_2"}
|
|
|
|
|
|
def test_visible_if_single_branch():
|
|
doc = _doc(
|
|
_dropdown("gebaeude", ["Kirche", "Halle"], required=True),
|
|
_field(
|
|
"bestandteil",
|
|
"MultiselectInput",
|
|
options=["Glocke", "Orgel"],
|
|
info="Bitte wählen [visible_if gebaeude=Kirche]",
|
|
required=True,
|
|
),
|
|
)
|
|
out = parser.extract_form_schema(doc)
|
|
schema = out["json_schema"]
|
|
# Conditional field lives only in its branch, not at root.
|
|
assert set(schema["properties"]) == {"gebaeude"}
|
|
assert schema["required"] == ["gebaeude"]
|
|
assert len(schema["allOf"]) == 1
|
|
rule = schema["allOf"][0]
|
|
assert rule["if"] == {
|
|
"properties": {"gebaeude": {"const": "Kirche"}},
|
|
"required": ["gebaeude"],
|
|
}
|
|
assert rule["then"]["properties"]["bestandteil"]["type"] == "array"
|
|
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", "*"]
|
|
|
|
|
|
def test_visible_if_nested_three_levels():
|
|
doc = _doc(
|
|
_dropdown("a", ["x", "y"]),
|
|
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
|
_field("c", "IntInput", info="[visible_if b=p]"),
|
|
_field("d", "StrInput", info="[visible_if c=3]"),
|
|
)
|
|
schema = _branch_schema(doc)
|
|
assert set(schema["properties"]) == {"a"}
|
|
assert len(schema["allOf"]) == 3
|
|
by_field = {next(iter(rule["then"]["properties"])): rule for rule in schema["allOf"]}
|
|
assert by_field["b"]["if"] == {
|
|
"properties": {"a": {"const": "x"}},
|
|
"required": ["a"],
|
|
}
|
|
assert by_field["c"]["if"]["properties"] == {"b": {"const": "p"}}
|
|
assert by_field["d"]["if"]["properties"] == {"c": {"const": 3}}
|
|
|
|
|
|
def test_visible_if_and_quoted_bool_multiselect():
|
|
doc = _doc(
|
|
_dropdown("a", ["x", "y"]),
|
|
_field("flag", "BoolInput"),
|
|
_field(
|
|
"m",
|
|
"MultiselectInput",
|
|
options=["Glocke", "Orgel"],
|
|
info='[visible_if a="x" & flag=true]',
|
|
),
|
|
)
|
|
schema = _branch_schema(doc)
|
|
rule = schema["allOf"][0]
|
|
assert rule["if"] == {
|
|
"properties": {"a": {"const": "x"}, "flag": {"const": True}},
|
|
"required": ["a", "flag"],
|
|
}
|
|
assert rule["then"]["properties"]["m"]["type"] == "array"
|
|
|
|
|
|
def test_visible_if_multiselect_trigger_uses_contains():
|
|
doc = _doc(
|
|
_field("tags", "MultiselectInput", options=["Glocke", "Orgel"]),
|
|
_field("detail", "StrInput", info="[visible_if tags=Glocke]"),
|
|
)
|
|
schema = _branch_schema(doc)
|
|
assert schema["allOf"][0]["if"]["properties"] == {"tags": {"contains": {"const": "Glocke"}}}
|
|
|
|
|
|
def test_visible_if_dangling_unsupported_cycle_fail_open():
|
|
doc = _doc(
|
|
_dropdown("a", ["x", "y"]),
|
|
_field("ok", "StrInput", info="[visible_if a=x]"),
|
|
_field("dangling", "StrInput", info="[visible_if nope=x]"),
|
|
_field("slider_dep", "StrInput", info="[visible_if slid=x]"),
|
|
_field(
|
|
"slid",
|
|
"SliderInput",
|
|
value=3,
|
|
range_spec={"min": 1, "max": 5, "step": 1},
|
|
),
|
|
_field("c1", "StrInput", info="[visible_if c2=x]"),
|
|
_field("c2", "StrInput", info="[visible_if c1=x]"),
|
|
_field("selfish", "StrInput", info="[visible_if selfish=x]"),
|
|
)
|
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
|
# Only the valid marker produces a branch; the rest stay at root.
|
|
assert len(schema["allOf"]) == 1
|
|
assert set(schema["properties"]) >= {
|
|
"a",
|
|
"dangling",
|
|
"slider_dep",
|
|
"slid",
|
|
"c1",
|
|
"c2",
|
|
"selfish",
|
|
}
|
|
assert "ok" not in schema["properties"]
|
|
|
|
|
|
def test_no_markers_no_allof():
|
|
doc = _doc(_dropdown("a", ["x", "y"]))
|
|
schema = parser.extract_form_schema(doc)["json_schema"]
|
|
assert "allOf" not in schema
|
|
assert set(schema["properties"]) == {"a"}
|
|
|
|
|
|
def test_custom_component_form_display_match():
|
|
"""User-built * Form components parse without a kirche base class."""
|
|
doc = _doc(
|
|
_field("mitarbeiter_name", "StrInput", info="Vor- und Nachname"),
|
|
_dropdown("rolle", ["Assistenz", "Pfarrer:in"]),
|
|
node_type="CustomComponent",
|
|
display="Mitarbeiter Einstellen Form",
|
|
)
|
|
schema = parser.extract_form_schema(doc)
|
|
assert "fallback" not in schema
|
|
props = schema["json_schema"]["properties"]
|
|
assert set(props) == {"mitarbeiter_name", "rolle"}
|
|
assert props["rolle"]["enum"] == ["Assistenz", "Pfarrer:in"]
|
|
assert props["mitarbeiter_name"]["description"] == "Vor- und Nachname"
|
|
|
|
|
|
def test_custom_component_non_form_falls_back():
|
|
doc = _doc(
|
|
_field("payload", "StrInput"),
|
|
node_type="CustomComponent",
|
|
display="Rule Check",
|
|
)
|
|
assert parser.extract_form_schema(doc)["fallback"] == "textarea"
|
|
|
|
|
|
def test_real_mitarbeiter_file_parses():
|
|
path = Path(__file__).parent.parent / "flows" / "mitarbeiter_einstellen.json"
|
|
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
schema = parser.extract_form_schema(doc)
|
|
assert "fallback" not in schema
|
|
props = schema["json_schema"]["properties"]
|
|
assert set(props) == {"rolle", "mitarbeiter_name", "geburtsdatum"}
|
|
assert schema["json_schema"]["required"] == ["rolle", "mitarbeiter_name"]
|
|
# Einstufung is branched (visible only for Pfarrer:in).
|
|
assert len(schema["json_schema"]["allOf"]) == 1
|
|
rule = schema["json_schema"]["allOf"][0]
|
|
assert rule["if"]["properties"] == {"rolle": {"const": "Pfarrer:in"}}
|
|
assert set(rule["then"]["properties"]) == {"einstufung"}
|
|
|
|
|
|
def test_schema_field_names_covers_branches():
|
|
doc = _doc(
|
|
_dropdown("a", ["x", "y"]),
|
|
_dropdown("b", ["p", "q"], info="[visible_if a=x]"),
|
|
_field("c", "IntInput", info="[visible_if b=p]"),
|
|
)
|
|
cached = parser.extract_form_schema(doc)
|
|
assert parser.schema_field_names(cached) == ["a", "b", "c"]
|
|
plain = parser.extract_form_schema(
|
|
{
|
|
"data": {
|
|
"nodes": [{"data": {"type": "ChatInput", "node": {"template": {}}}}],
|
|
"edges": [],
|
|
}
|
|
}
|
|
)
|
|
assert parser.schema_field_names(plain) == []
|
|
assert parser.schema_field_names({}) == []
|
|
assert parser.schema_field_names({"json_schema": None}) == []
|