refactor: finish declutter - test_flow removal, NOT NULL schema, parametrized tests, smoke harness, docs

This commit is contained in:
Jonas Miederer 2026-10-01 00:11:43 +02:00
parent 696db34c90
commit 92bb3bacba
23 changed files with 552 additions and 1261 deletions

View file

@ -254,7 +254,7 @@ def _jwt_secret() -> str:
def mint_session_token(user: User) -> str:
"""Issue a stateless session JWT for a user (30-day expiry)."""
"""Issue a stateless session JWT for a user (7-day expiry)."""
now = utcnow()
return jwt.encode(
{

View file

@ -27,7 +27,6 @@ from app.middleware import limiter, run_limit
from app.models import User
from app.settings import settings
from . import input_schema as form_schema
from . import runner, store
from .deploy import import_from_langflow
from .langflow_client import LangflowClient, LangflowError, LangflowNotFound, LangflowUnavailable
@ -363,7 +362,7 @@ def get_input_schema(
version = store.get_latest_deployed_version(session, flow_id)
except (store.NotFoundError, store.InvalidError) as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
cached = version.input_schema
return InputSchemaRead(
flow_id=flow_id,
version_no=version.version_no,

View file

@ -70,7 +70,7 @@ async def execute_run(
run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
)
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
cached = version.input_schema
envelope = build_envelope(
payload=payload,
run_id=run.id,
@ -119,7 +119,7 @@ async def stream_run(
run = store.create_run(
session, version, input=payload, tenant_id=tenant_id, session_id=session_id
)
cached = version.input_schema or form_schema.extract_form_schema(version.langflow_json)
cached = version.input_schema
envelope = build_envelope(
payload=payload,
run_id=run.id,

View file

@ -37,7 +37,7 @@ class FlowVersionRead(BaseModel):
deployed_at: datetime | None
author: str
created_at: datetime
input_schema: dict | None = None
input_schema: dict
class InputSchemaRead(BaseModel):

View file

@ -47,8 +47,8 @@ class FlowVersion(SQLModel, table=True):
author: str = Field(default="", max_length=200)
created_at: datetime = Field(default_factory=utcnow, sa_column=ts_column())
# Cached form schema for this exact JSON (see app/flows/input_schema.py).
# Computed at version creation; nullable for pre-Phase-3 rows.
input_schema: dict | None = Field(default=None, sa_column=_json_column())
# Computed at version creation; backfilled NOT NULL (e8f3a1c2b4d6).
input_schema: dict = Field(default_factory=dict, sa_column=Column(JSON, nullable=False))
class FlowRun(SQLModel, table=True):

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,65 @@
"""Backfill + require FlowVersion.input_schema.
Revision ID: e8f3a1c2b4d6
Revises: c4d8f2a1e6b9
Create Date: 2026-09-30 00:00:00.000000
Pre-Phase-3 rows have input_schema NULL. Backfill them from the stored
langflow_json (same parser the write choke point uses); unparseable rows
get the textarea fallback so reads never crash. Then enforce NOT NULL.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e8f3a1c2b4d6"
down_revision: str | Sequence[str] | None = "c4d8f2a1e6b9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FALLBACK = {
"json_schema": {"type": "object", "properties": {}},
"ui_schema": {},
"fallback": "textarea",
}
def _derive(langflow_json) -> dict:
import json as stdlib_json
try:
from app.flows import input_schema as parser
if isinstance(langflow_json, str):
# SQLite stores JSON columns as TEXT; Postgres returns dict.
langflow_json = stdlib_json.loads(langflow_json)
return parser.extract_form_schema(langflow_json)
except Exception: # noqa: BLE001 -- backfill must never fail the migration
return dict(_FALLBACK)
def upgrade() -> None:
"""Upgrade schema."""
import json as stdlib_json
connection = op.get_bind()
rows = connection.execute(
sa.text("SELECT id, langflow_json FROM flow_versions WHERE input_schema IS NULL")
).all()
for row_id, langflow_json in rows:
schema = _derive(langflow_json)
connection.execute(
sa.text("UPDATE flow_versions SET input_schema = :schema WHERE id = :id"),
{"schema": stdlib_json.dumps(schema), "id": str(row_id)},
)
with op.batch_alter_table("flow_versions") as batch:
batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=False)
def downgrade() -> None:
"""Downgrade schema (data kept, nullability restored)."""
with op.batch_alter_table("flow_versions") as batch:
batch.alter_column("input_schema", existing_type=sa.JSON(), nullable=True)

View file

@ -11,48 +11,36 @@ from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from tests._harness import (
free_port,
migrate_throwaway,
plant_session_jwt,
spawn_lfx,
wait_healthy,
)
BACKEND = Path(__file__).resolve().parent.parent
# Local venv (uv run) or image venv (/opt/venv in containers): prefer PATH.
VENV_BIN = Path(shutil.which("lfx") or "").parent or (BACKEND / ".venv" / "bin")
FIXTURES = BACKEND / "tests" / "fixtures" / "flows"
def _free_port() -> int:
# Ephemeral ports: the dev API/LFX may already occupy the defaults
# (e.g. when smoke runs inside the api container itself).
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
LFX_PORT = _free_port()
API_PORT = _free_port()
# Ephemeral ports: the dev API/LFX may already occupy the defaults
# (e.g. when smoke runs inside the api container itself).
LFX_PORT = free_port()
API_PORT = free_port()
LFX_KEY = "lfx-759GsfJKjAeOBZ_44-cll9WdHp_yfYn_tIg7eHG38Rs"
# Stateless admin session JWT, minted in main() after planting the user.
_SMOKE_JWT = ""
def wait_healthy(url: str, timeout: int = 180) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=3) as r:
if r.status == 200:
return
except OSError:
time.sleep(1)
raise RuntimeError(f"never healthy: {url}")
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
import httpx
@ -75,35 +63,19 @@ def check(name: str, cond: bool, detail: str = "") -> None:
def main() -> None:
tmp = Path(tempfile.mkdtemp(prefix="kirche-smoke-"))
flow_dir = Path("/tmp/smoke-e2e-flows")
flow_dir.mkdir(exist_ok=True)
flow_dir = Path(tempfile.mkdtemp(prefix="kirche-smoke-flows-"))
(flow_dir / "seed.json").write_text((FIXTURES / "simple_rule.json").read_text())
# Migrate a throwaway SQLite schema to head before starting the API.
from alembic import command as alembic_command
from alembic.config import Config
db_url = f"sqlite:///{tmp / 'smoke.db'}"
cfg = Config()
cfg.set_main_option("script_location", str(BACKEND / "migrations"))
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value):
# point this process at the throwaway DB before migrating.
from app.settings import settings as app_settings
app_settings.database_url = db_url
alembic_command.upgrade(cfg, "head")
migrate_throwaway(BACKEND, db_url)
# Plant an admin user + mint its session JWT: management + runs
# require login, and there is no SMTP in smoke to deliver a magic link.
# Sessions are stateless (see app/auth/service.py) — the cookie value
# below is the JWT sent as `kirche_session`.
global _SMOKE_JWT
import os
from sqlmodel import Session, create_engine
from app.auth import service as auth_service
from app.models import User
from app.settings import settings as app_settings
if not app_settings.jwt_secret:
@ -112,52 +84,34 @@ def main() -> None:
)
engine = create_engine(db_url)
with Session(engine) as s:
user = User(name="smoke", email="smoke@example.com", role="admin")
s.add(user)
s.commit()
s.refresh(user)
_SMOKE_JWT = auth_service.mint_session_token(user)
_, _SMOKE_JWT = plant_session_jwt(s, email="smoke@example.com", name="smoke")
# Smoke predates the shared harness key; keep its long serve key.
print("smoke admin JWT minted")
engine.dispose()
env = {
**os.environ,
# lfx prefers LANGFLOW_API_KEY over LFX_API_KEY; the repo .env may set
# it. Clear it so the serve key below is the one we send.
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": LFX_KEY,
env_extra = {
"DATABASE_URL": db_url,
"FLOWS_WATCH_ENABLED": "false",
"LFX_URL": f"http://127.0.0.1:{LFX_PORT}",
"CALLBACK_BASE_URL": f"http://127.0.0.1:{API_PORT}",
"CALLBACK_TOKEN": "smoke-callback-token",
}
lfx = subprocess.Popen(
[
str(VENV_BIN / "lfx"),
"serve",
"--host",
"127.0.0.1",
"--port",
str(LFX_PORT),
"--flow-dir",
str(flow_dir),
str(flow_dir),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
lfx = spawn_lfx(flow_dir, LFX_PORT, env_extra=env_extra, api_key=LFX_KEY)
uvicorn = subprocess.Popen(
[str(VENV_BIN / "uvicorn"), "app.main:app", "--host", "127.0.0.1", "--port", str(API_PORT)],
cwd=str(BACKEND),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
env={
**os.environ,
"LANGFLOW_API_KEY": "",
"LFX_API_KEY": LFX_KEY,
**env_extra,
},
)
try:
wait_healthy(f"http://127.0.0.1:{LFX_PORT}/health")
wait_healthy(f"http://127.0.0.1:{API_PORT}/health")
wait_healthy(f"http://127.0.0.1:{LFX_PORT}", deadline_s=180)
wait_healthy(f"http://127.0.0.1:{API_PORT}", deadline_s=180)
s, flows = api("GET", "/api/flows")
check("list flows", s == 200 and isinstance(flows, list))

View file

@ -1,7 +1,7 @@
"""Shared e2e harness: free ports, health waits, lfx spawn, migrate, sessions.
Single place for logic duplicated between tests/conftest.py and
scripts/smoke_e2e.py (~60 lines). Full real-LFX runs are kept.
Single place for logic shared between tests/conftest.py and
scripts/smoke_e2e.py. Full real-LFX runs are kept.
"""
from __future__ import annotations
@ -15,6 +15,10 @@ import time
import urllib.request
from pathlib import Path
# 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 free_port() -> int:
sock = socket.socket()
@ -36,10 +40,14 @@ def wait_healthy(url: str, deadline_s: float = 240.0) -> None:
raise RuntimeError(f"server did not become healthy: {url}")
def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
def spawn_lfx(
flow_dir: Path, port: int, env_extra: dict | None = None, api_key: str = TEST_LFX_KEY
) -> subprocess.Popen:
bindir = Path(sys.executable).parent
lfx_bin = shutil.which("lfx", path=str(bindir)) or "lfx"
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": "test-lfx-key"}
env = {**os.environ, "LANGFLOW_API_KEY": "", "LFX_API_KEY": api_key}
if env_extra:
env.update(env_extra)
return subprocess.Popen(
[
lfx_bin,
@ -59,20 +67,21 @@ def spawn_lfx(flow_dir: Path, port: int) -> subprocess.Popen:
)
def migrate_throwaway(backend_dir: Path) -> None:
def migrate_throwaway(backend_dir: Path, db_url: str | None = None) -> None:
from alembic import command as alembic_command
from alembic.config import Config
cfg = Config()
cfg.set_main_option("script_location", str(backend_dir / "migrations"))
if db_url is not None:
cfg.set_main_option("sqlalchemy.url", db_url)
# migrations/env.py takes the URL from settings (not the ini value).
from app.settings import settings as app_settings
app_settings.database_url = db_url
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
@ -91,7 +100,7 @@ def spawn_uvicorn(app, host: str = "127.0.0.1", port: int = 0): # type: ignore[
return server, thread
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> object:
def plant_session_jwt(session, email: str, role: str = "admin", name: str = "Test") -> tuple:
"""Idempotent test user + session JWT (shared with smoke pattern)."""
from sqlmodel import select

View file

@ -4,6 +4,7 @@ import json
import sys
from pathlib import Path
import pytest
from lfx.custom import Component
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
@ -208,28 +209,212 @@ def test_bauvorhaben_collect_only_own_fields():
assert "full_name" not in collected
def test_intake_fixture_shape():
doc = json.loads((FIXTURES / "intake_form.json").read_text())
def _parse_chat_json(text: str) -> dict:
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
@pytest.mark.parametrize(
(
"fixture_file",
"doc_name",
"node_id",
"expected_type",
"expected_display",
"display_mode",
"expected_fields",
"expected_edges",
),
[
(
"intake_form.json",
None,
"CustomInputForm-intake",
"CustomInputForm",
"CustomInput",
"startswith",
{
"full_name",
"notes",
"password",
"age",
"amount",
"newsletter",
"department",
"topics",
"satisfaction",
"attachment",
},
[
("ChatInput-entry", "CustomInputForm-intake"),
("CustomInputForm-intake", "ChatOutput-collected"),
],
),
(
"bauvorhaben.json",
"bauvorhaben",
"CustomInputBauvorhaben-bauvorhaben",
"CustomInputBauvorhaben",
"Bauvorhaben Form",
"exact",
{
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"code",
},
[
("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben"),
("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected"),
],
),
(
"mitarbeiter_einstellen.json",
"mitarbeiter_einstellen",
"CustomInputMitarbeiter-mitarbeiter",
"CustomInputMitarbeiter",
"Mitarbeiter Einstellen Form",
"exact",
{
"form_values",
"rolle",
"einstufung",
"mitarbeiter_name",
"geburtsdatum",
"code",
},
[
("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter"),
("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt"),
("Gehaltsrechner-gehalt", "ChatOutput-collected"),
],
),
],
ids=["intake", "bauvorhaben", "mitarbeiter"],
)
def test_fixture_shape(
fixture_file,
doc_name,
node_id,
expected_type,
expected_display,
display_mode,
expected_fields,
expected_edges,
):
doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8"))
if doc_name is not None:
assert doc["name"] == doc_name
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")
form = nodes[node_id]
assert form["data"]["type"] == expected_type
display = form["data"]["node"]["display_name"]
if display_mode == "startswith":
assert display.startswith(expected_display)
else:
assert display == expected_display
template = form["data"]["node"]["template"]
for field in FORM_FIELDS:
assert field in template, field
assert set(template) >= expected_fields
assert template["form_values"]["show"] is False
assert len(template["code"]["value"]) > 200
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"] # JSON object, not Message
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
for edge in expected_edges:
assert edge in edges
async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
async def _check_intake_result(client, flow_id, version, run):
# collect() returns Data: the terminal ChatOutput renders it as a
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved
async def _check_bauvorhaben_result(client, flow_id, version, run):
# Branch rules are cached per version and served via input-schema.
cached = version["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
result = _parse_chat_json(run["output"]["result"])
assert result["gebaeude"] == "Kirche"
assert result["bestandteil"] == ["Glocke", "Orgel"]
assert result["gussjahr"] == 1890
assert result["denkmalnummer"] == "D-123"
# Only asked fields: unasked code defaults (age, amount, ...) stay out.
assert set(result) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"denkmalschutz",
"denkmalnummer",
}
async def _check_mitarbeiter_result(client, flow_id, version, run):
result = run["output"]["result"]
assert "65000" in result
assert "Maria Muster" in result
@pytest.mark.parametrize(
("fixture_file", "flow_name", "run_input", "check_result"),
[
(
"intake_form.json",
"intake-e2e",
{"full_name": "Maria Muster", "age": 41},
_check_intake_result,
),
(
"bauvorhaben.json",
"bauvorhaben-e2e",
{
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
},
_check_bauvorhaben_result,
),
(
"mitarbeiter_einstellen.json",
"mitarbeiter-e2e",
{
"rolle": "Pfarrer:in",
"einstufung": "Stufe 2",
"mitarbeiter_name": "Maria Muster",
"geburtsdatum": "1980-01-01",
},
_check_mitarbeiter_result,
),
],
ids=["intake", "bauvorhaben", "mitarbeiter"],
)
async def test_deploy_and_run(
client, lfx_server, login, fixture_file, flow_name, run_input, check_result
):
await login()
doc = json.loads((FIXTURES / "intake_form.json").read_text())
created = await client.post("/api/flows", json={"name": "intake-e2e"})
doc = json.loads((FIXTURES / fixture_file).read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": flow_name})
assert created.status_code == 201, created.text
flow_id = created.json()["id"]
@ -242,29 +427,10 @@ async def test_intake_deploy_and_run_with_values(client, lfx_server, login):
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}},
)
run = await client.post(f"/api/flows/{flow_id}/run", json={"input": run_input})
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
# collect() returns Data: the terminal ChatOutput renders it as a
# fenced ```json block, so unwrap before parsing.
result = _parse_chat_json(run.json()["output"]["result"])
assert result["full_name"] == "Maria Muster"
assert result["age"] == 41
assert result["department"] == "Music" # default preserved
def _parse_chat_json(text: str) -> dict:
"""Parse terminal ChatOutput text: raw JSON or a ```json fenced block."""
text = text.strip()
if text.startswith("```"):
lines = text.splitlines()
assert lines[0].startswith("```"), text[:60]
assert lines[-1].startswith("```"), text[-60:]
text = "\n".join(lines[1:-1])
return json.loads(text)
await check_result(client, flow_id, version.json(), run.json())
def test_embedded_code_matches_template_fields():
@ -341,32 +507,6 @@ def test_kirche_node_inlines_core():
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"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputBauvorhaben-bauvorhaben"]
assert form["data"]["type"] == "CustomInputBauvorhaben"
assert form["data"]["node"]["display_name"] == "Bauvorhaben Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"gebaeudeart",
"denkmalschutz",
"denkmalnummer",
"code",
}
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"]
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputBauvorhaben-bauvorhaben") in edges
assert ("CustomInputBauvorhaben-bauvorhaben", "ChatOutput-collected") in edges
def test_bauvorhaben_schema_branches():
from app.flows import input_schema as parser
@ -411,59 +551,6 @@ def test_bauvorhaben_schema_branches():
}
async def test_bauvorhaben_deploy_and_run_nested_values(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "bauvorhaben.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "bauvorhaben-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
# Branch rules are cached per version and served via input-schema.
cached = version.json()["input_schema"]
assert len(cached["json_schema"]["allOf"]) == 5
deployed = await client.post(f"/api/flows/{flow_id}/versions/1/deploy")
assert deployed.status_code == 200, deployed.text
schema = await client.get(f"/api/flows/{flow_id}/input-schema")
assert schema.status_code == 200, schema.text
assert set(schema.json()["json_schema"]["properties"]) == {"gebaeude", "denkmalschutz"}
run = await client.post(
f"/api/flows/{flow_id}/run",
json={
"input": {
"gebaeude": "Kirche",
"bestandteil": ["Glocke", "Orgel"],
"glockenmassnahme": "Neuguss",
"gussjahr": 1890,
"denkmalschutz": True,
"denkmalnummer": "D-123",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = _parse_chat_json(run.json()["output"]["result"])
assert result["gebaeude"] == "Kirche"
assert result["bestandteil"] == ["Glocke", "Orgel"]
assert result["gussjahr"] == 1890
assert result["denkmalnummer"] == "D-123"
# Only asked fields: unasked code defaults (age, amount, ...) stay out.
assert set(result) == {
"gebaeude",
"bestandteil",
"glockenmassnahme",
"gussjahr",
"denkmalschutz",
"denkmalnummer",
}
def test_mitarbeiter_component_contract():
from kirche.mitarbeiter_form import CustomInputMitarbeiter
@ -485,60 +572,3 @@ def test_mitarbeiter_collect_branch_default():
assert "einstufung" not in form.collect().data # rolle defaults to Assistenz
form.form_values = json.dumps({"payload": {"rolle": "Pfarrer:in"}})
assert form.collect().data["einstufung"] == "Stufe 1"
def test_mitarbeiter_fixture_shape():
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
assert doc["name"] == "mitarbeiter_einstellen"
nodes = {n["id"]: n for n in doc["data"]["nodes"]}
form = nodes["CustomInputMitarbeiter-mitarbeiter"]
assert form["data"]["type"] == "CustomInputMitarbeiter"
assert form["data"]["node"]["display_name"] == "Mitarbeiter Einstellen Form"
template = form["data"]["node"]["template"]
assert set(template) >= {
"form_values",
"rolle",
"einstufung",
"mitarbeiter_name",
"geburtsdatum",
"code",
}
outputs = {o["name"]: o for o in form["data"]["node"]["outputs"]}
assert outputs["form_data"]["types"] == ["JSON"]
edges = {(e["source"], e["target"]) for e in doc["data"]["edges"]}
assert ("ChatInput-entry", "CustomInputMitarbeiter-mitarbeiter") in edges
assert ("CustomInputMitarbeiter-mitarbeiter", "Gehaltsrechner-gehalt") in edges
assert ("Gehaltsrechner-gehalt", "ChatOutput-collected") in edges
async def test_mitarbeiter_deploy_and_run_salary(client, lfx_server, login):
await login()
doc = json.loads((FIXTURES / "mitarbeiter_einstellen.json").read_text(encoding="utf-8"))
created = await client.post("/api/flows", json={"name": "mitarbeiter-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": {
"rolle": "Pfarrer:in",
"einstufung": "Stufe 2",
"mitarbeiter_name": "Maria Muster",
"geburtsdatum": "1980-01-01",
}
},
)
assert run.status_code == 200, run.text
assert run.json()["status"] == "completed"
result = run.json()["output"]["result"]
assert "65000" in result
assert "Maria Muster" in result

View file

@ -1,5 +1,7 @@
"""Model-level tests: defaults, constraints, JSON roundtrip, linkage."""
import uuid
import pytest
from sqlalchemy.exc import IntegrityError
from sqlmodel import select
@ -86,3 +88,48 @@ def test_run_and_task_linkage_with_tenant_default(session):
tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all()
assert len(tasks) == 1
assert tasks[0].payload == {}
def test_create_version_populates_input_schema(session):
from app.flows import store
from app.flows.schemas import FlowVersionCreate
d = FlowDefinition(name="cached")
session.add(d)
session.commit()
doc = {
"name": "cached",
"data": {
"nodes": [
{
"id": "n1",
"data": {
"type": "ChatInput",
"node": {"display_name": "Chat", "template": {}},
},
}
],
"edges": [],
},
}
version = store.create_version(session, d.id, FlowVersionCreate(langflow_json=doc))
assert version.input_schema["fallback"] == "textarea"
def test_input_schema_null_rejected(session):
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
d = FlowDefinition(name="nonnull")
session.add(d)
session.commit()
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO flow_versions (id, flow_id, version_no, langflow_json,"
" author, input_schema) VALUES (:id, :flow_id, 1, :doc, :author, NULL)"
),
{"id": str(uuid.uuid4()), "flow_id": str(d.id), "doc": "{}", "author": "t"},
)
session.commit()
session.rollback()

View file

@ -136,73 +136,78 @@ async def test_sync_endpoint_returns_report(
assert response.json()["skipped"] == ["simple_rule"]
async def test_sync_endpoint_pulls_from_langflow_then_syncs(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
@pytest.mark.parametrize(
("seed", "flow_name", "flow_id", "extra_flows", "extra_docs", "expected_file"),
[
(
None,
"Gehaltserhöhung",
"22222222-3333-4444-8555-666666666666",
[],
{},
"Gehaltserhöhung.json",
),
(
# Seeded from simple_rule.json: updates in place instead of
# duplicating as simple-rule.json (slug drift).
"simple_rule.json",
"simple_rule",
"44444444-5555-4666-8777-888888888888",
[],
{},
"simple_rule.json",
),
(
None,
"Gut",
"33333333-4444-4555-8666-777777777777",
[{"id": "kaputt-id", "name": "Kaputt"}],
{},
"Gut.json",
),
],
ids=["pull-new", "pull-reuse", "pull-error-continues"],
)
async def test_sync_endpoint_pull_matrix(
client,
lfx_server,
tmp_path,
monkeypatch,
login,
override_langflow,
seed,
flow_name,
flow_id,
extra_flows,
extra_docs,
expected_file,
):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
langflow_id = "22222222-3333-4444-8555-666666666666"
if seed is not None:
_seed_dir(tmp_path, seed)
override_langflow(
flows=[{"id": langflow_id, "name": "Gehaltserhöhung"}],
docs={langflow_id: langflow_export_doc("Gehaltserhöhung", langflow_id)},
flows=[{"id": flow_id, "name": flow_name}, *extra_flows],
docs={flow_id: langflow_export_doc(flow_name, flow_id), **extra_docs},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["Gehaltserhöhung"]
assert body["added"] == ["Gehaltserhöhung"]
assert body["errors"] == {}
assert (tmp_path / "Gehaltserhöhung.json").is_file()
assert body["pulled"] == [flow_name]
assert body["added"] == [flow_name]
assert (tmp_path / expected_file).is_file()
if flow_name == "Gut":
assert "kaputt-id" in body["errors"]
else:
assert body["errors"] == {}
assert not (tmp_path / "simple-rule.json").exists()
# Second pass: same content pulls again but syncs nothing new.
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["Gehaltserhöhung"]
assert body["skipped"] == ["Gehaltserhöhung"]
async def test_sync_endpoint_pull_reuses_same_named_file(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
"""A pulled flow seeded from simple_rule.json updates it in place
instead of duplicating it as simple-rule.json (slug drift)."""
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
_seed_dir(tmp_path) # fixture shape, name "simple_rule"
langflow_id = "44444444-5555-4666-8777-888888888888"
override_langflow(
flows=[{"id": langflow_id, "name": "simple_rule"}],
docs={langflow_id: langflow_export_doc("simple_rule", langflow_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["simple_rule"]
assert body["added"] == ["simple_rule"]
assert body["errors"] == {}
assert not (tmp_path / "simple-rule.json").exists()
response = await client.post("/api/flows/sync")
assert response.json()["skipped"] == ["simple_rule"]
async def test_sync_endpoint_pull_error_continues(
client, lfx_server, tmp_path, monkeypatch, login, override_langflow
):
await login()
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
good_id = "33333333-4444-4555-8666-777777777777"
override_langflow(
flows=[{"id": good_id, "name": "Gut"}, {"id": "kaputt-id", "name": "Kaputt"}],
docs={good_id: langflow_export_doc("Gut", good_id)},
)
response = await client.post("/api/flows/sync")
assert response.status_code == 200, response.text
body = response.json()
assert body["pulled"] == ["Gut"]
assert body["added"] == ["Gut"]
assert "kaputt-id" in body["errors"]
assert body["pulled"] == [flow_name]
assert body["skipped"] == [flow_name]
@pytest.mark.parametrize(