feat: form-input library with schema parsing and endpoint
This commit is contained in:
parent
80d81b8022
commit
46ecab00db
13 changed files with 2775 additions and 2 deletions
991
backend/tests/fixtures/flows/intake_form.json
vendored
Normal file
991
backend/tests/fixtures/flows/intake_form.json
vendored
Normal file
File diff suppressed because one or more lines are too long
104
backend/tests/test_form_components.py
Normal file
104
backend/tests/test_form_components.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Phase 2 tests: kirche CustomInput* library + intake fixture flow."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from lfx.custom import Component
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "backend" / "components"))
|
||||
|
||||
from kirche.form_inputs import CustomInputForm # noqa: E402
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
FORM_FIELDS = [
|
||||
"full_name",
|
||||
"notes",
|
||||
"password",
|
||||
"age",
|
||||
"amount",
|
||||
"newsletter",
|
||||
"department",
|
||||
"topics",
|
||||
"satisfaction",
|
||||
"attachment",
|
||||
]
|
||||
|
||||
|
||||
def test_component_contract():
|
||||
assert issubclass(CustomInputForm, Component)
|
||||
assert CustomInputForm.display_name.startswith("CustomInput")
|
||||
inputs = {i.name: i for i in CustomInputForm.inputs}
|
||||
# Ten form fields plus the hidden run-envelope plumbing input.
|
||||
assert sorted(n for n in inputs if n != "form_values") == sorted(FORM_FIELDS)
|
||||
assert inputs["form_values"].show is False
|
||||
outputs = {o.name: o for o in CustomInputForm.outputs}
|
||||
assert set(outputs) == {"form_data"}
|
||||
|
||||
|
||||
def test_collect_merges_submitted_values_over_defaults():
|
||||
from lfx.schema.message import Message
|
||||
|
||||
form = CustomInputForm()
|
||||
default = json.loads(form.collect().text)
|
||||
assert default["full_name"] == ""
|
||||
assert default["department"] == "Music"
|
||||
assert isinstance(form.collect(), Message)
|
||||
|
||||
form.form_values = json.dumps({"full_name": "Maria", "age": 41})
|
||||
merged = json.loads(form.collect().text)
|
||||
assert merged["full_name"] == "Maria"
|
||||
assert merged["age"] == 41
|
||||
assert merged["department"] == "Music" # default kept
|
||||
|
||||
# Run envelope shape {"payload": {...}} unwraps like the other components.
|
||||
form.form_values = json.dumps({"payload": {"notes": "hi"}})
|
||||
assert json.loads(form.collect().text)["notes"] == "hi"
|
||||
|
||||
form.form_values = "not json"
|
||||
assert json.loads(form.collect().text)["full_name"] == ""
|
||||
|
||||
|
||||
def test_intake_fixture_shape():
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
|
||||
form = nodes["CustomInputForm-intake"]
|
||||
assert form["data"]["type"] == "CustomInputForm"
|
||||
assert form["data"]["node"]["display_name"].startswith("CustomInput")
|
||||
template = form["data"]["node"]["template"]
|
||||
for field in FORM_FIELDS:
|
||||
assert field in template, field
|
||||
assert template["form_values"]["show"] is False
|
||||
assert len(template["code"]["value"]) > 200
|
||||
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
|
||||
assert ("ChatInput-entry", "CustomInputForm-intake") in edges
|
||||
assert ("CustomInputForm-intake", "ChatOutput-collected") in edges
|
||||
|
||||
|
||||
async def test_intake_deploy_and_run_with_values(client, lfx_server):
|
||||
doc = json.loads((FIXTURES / "intake_form.json").read_text())
|
||||
created = await client.post("/api/flows", json={"name": "intake-e2e"})
|
||||
assert created.status_code == 201, created.text
|
||||
flow_id = created.json()["id"]
|
||||
|
||||
version = await client.post(
|
||||
f"/api/flows/{flow_id}/versions",
|
||||
json={"langflow_json": doc, "author": "test"},
|
||||
)
|
||||
assert version.status_code == 201, version.text
|
||||
|
||||
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
|
||||
assert deployed.status_code == 200, deployed.text
|
||||
|
||||
run = await client.post(
|
||||
f"/api/flows/{flow_id}/run",
|
||||
json={"input": {"full_name": "Maria Muster", "age": 41}},
|
||||
)
|
||||
assert run.status_code == 200, run.text
|
||||
assert run.json()["status"] == "completed"
|
||||
result = json.loads(run.json()["output"]["result"])
|
||||
assert result["full_name"] == "Maria Muster"
|
||||
assert result["age"] == 41
|
||||
assert result["department"] == "Music" # default preserved
|
||||
201
backend/tests/test_input_schema.py
Normal file
201
backend/tests/test_input_schema.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""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 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):
|
||||
assert (await client.get(f"/api/flows/{uuid4()}/input-schema")).status_code == 404
|
||||
|
||||
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"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue