feat: canonical flow directory with sync engine (files are source of truth)
This commit is contained in:
parent
7d5ade6b6a
commit
c65e3ed332
16 changed files with 4167 additions and 42 deletions
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
LANGFLOW_ID = "11111111-2222-4333-8444-555555555555"
|
||||
|
|
@ -35,7 +37,9 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert body["definition"]["name"] == "importiert"
|
||||
assert body["version"]["version_no"] == 1
|
||||
assert body["version"]["author"] == "admin"
|
||||
assert body["version"]["lfx_flow_id"] == body["version"]["id"]
|
||||
assert body["version"]["lfx_flow_id"] == lfx_id_for(
|
||||
UUID(body["definition"]["id"]), 1
|
||||
)
|
||||
assert body["version"]["deployed_at"] is not None
|
||||
assert body["version"]["langflow_json"]["data"]["nodes"]
|
||||
|
||||
|
|
@ -48,7 +52,7 @@ async def test_import_creates_definition_version_and_deploys(
|
|||
assert response.json()["status"] == "completed"
|
||||
|
||||
|
||||
async def test_import_reuses_definition_and_bumps_version(
|
||||
async def test_import_same_content_is_idempotent(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
override_langflow(_export_doc())
|
||||
|
|
@ -61,9 +65,44 @@ async def test_import_reuses_definition_and_bumps_version(
|
|||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()["definition"]["id"] == second.json()["definition"]["id"]
|
||||
# Unchanged content creates no new version.
|
||||
assert second.json()["version"]["version_no"] == 1
|
||||
|
||||
|
||||
async def test_import_changed_content_bumps_version(
|
||||
client, lfx_server, override_langflow
|
||||
):
|
||||
override_langflow(_export_doc())
|
||||
first = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert first.status_code == 201
|
||||
changed = _export_doc()
|
||||
changed["description"] = "neue Beschreibung"
|
||||
override_langflow(changed)
|
||||
second = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert second.json()["version"]["version_no"] == 2
|
||||
|
||||
|
||||
async def test_import_writes_canonical_file(
|
||||
client, lfx_server, override_langflow, tmp_path, monkeypatch
|
||||
):
|
||||
from app.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
override_langflow(_export_doc())
|
||||
response = await client.post(
|
||||
"/api/flows/import", json={"langflow_flow_id": LANGFLOW_ID}
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
written = tmp_path / "importiert.json"
|
||||
assert written.is_file()
|
||||
assert json.loads(written.read_text())["name"] == "importiert"
|
||||
|
||||
|
||||
async def test_import_with_explicit_name(client, lfx_server, override_langflow):
|
||||
override_langflow(_export_doc(name="langflow-name"))
|
||||
response = await client.post(
|
||||
|
|
|
|||
39
backend/tests/test_export.py
Normal file
39
backend/tests/test_export.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Unit tests for canonical flow files (export.py). No DB or LFX needed."""
|
||||
|
||||
import json
|
||||
|
||||
from app.flows import export
|
||||
|
||||
|
||||
def test_slugify():
|
||||
assert export.slugify("Dachsanierung 200k") == "dachsanierung-200k"
|
||||
assert export.slugify(" A/B\\C ") == "a-b-c"
|
||||
assert export.slugify("simple_rule") == "simple-rule"
|
||||
assert export.slugify("") == "flow"
|
||||
|
||||
|
||||
def test_canonical_bytes_stable_under_key_reorder():
|
||||
doc = {"name": "x", "data": {"nodes": [], "edges": []}, "tags": ["b", "a"]}
|
||||
shuffled = {"tags": ["b", "a"], "data": {"edges": [], "nodes": []}, "name": "x"}
|
||||
assert export.canonical_bytes(shuffled) == export.canonical_bytes(doc)
|
||||
assert export.content_hash(doc) == export.content_hash(shuffled)
|
||||
|
||||
|
||||
def test_write_and_read_roundtrip_is_byte_stable(tmp_path):
|
||||
doc = {"name": "My Flow", "data": {"nodes": [], "edges": []}}
|
||||
path = export.write_flow_file(tmp_path, doc)
|
||||
assert path == tmp_path / "my-flow.json"
|
||||
assert json.loads(path.read_text()) == doc
|
||||
# Rewriting identical content is byte-identical (no phantom git diffs).
|
||||
before = path.read_bytes()
|
||||
export.write_flow_file(tmp_path, doc)
|
||||
assert path.read_bytes() == before
|
||||
|
||||
|
||||
def test_iter_flow_files_only_top_level_json(tmp_path):
|
||||
(tmp_path / "a.json").write_text("{}")
|
||||
(tmp_path / "b.txt").write_text("{}")
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "c.json").write_text("{}")
|
||||
assert export.iter_flow_files(tmp_path) == [tmp_path / "a.json"]
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.flows.sync import lfx_id_for
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "flows"
|
||||
|
||||
|
|
@ -81,7 +83,7 @@ async def test_deploy_and_run_standard_and_sonderfall(client, lfx_server):
|
|||
flow = await _create_flow(client, "dachsanierung")
|
||||
await _create_version(client, flow["id"])
|
||||
deployed = await _deploy(client, flow["id"])
|
||||
assert deployed["lfx_flow_id"] == deployed["id"]
|
||||
assert deployed["lfx_flow_id"] == lfx_id_for(UUID(flow["id"]), 1)
|
||||
assert deployed["deployed_at"] is not None
|
||||
|
||||
response = await client.post(
|
||||
|
|
|
|||
129
backend/tests/test_sync.py
Normal file
129
backend/tests/test_sync.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Sync engine tests: backend/flows/*.json -> DB + LFX (LFX real, DB sqlite)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flows import store, sync
|
||||
from app.flows.lfx_client import LfxClient, LfxNotFound
|
||||
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)
|
||||
|
||||
|
||||
def _seed_dir(tmp_path: Path, name: str = "simple_rule.json") -> Path:
|
||||
shutil.copy(FIXTURES / name, tmp_path / name)
|
||||
return tmp_path
|
||||
|
||||
|
||||
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)
|
||||
assert report.added == ["simple_rule"]
|
||||
assert report.errors == {}
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert definition is not None
|
||||
assert definition.name == "simple_rule"
|
||||
versions = store.list_versions(session, definition.id)
|
||||
assert [v.version_no for v in versions] == [1]
|
||||
assert versions[0].lfx_flow_id == lfx_id_for(definition.id, 1)
|
||||
assert versions[0].deployed_at is not None
|
||||
|
||||
|
||||
async def test_sync_skips_unchanged(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
first = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert first.added == ["simple_rule"]
|
||||
second = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert second.skipped == ["simple_rule"]
|
||||
assert second.added == second.updated == []
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert len(store.list_versions(session, definition.id)) == 1
|
||||
|
||||
|
||||
async def test_sync_updates_on_change_and_replaces_lfx(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
old_lfx_id = lfx_id_for(definition.id, 1)
|
||||
|
||||
doc = load_fixture("simple_rule.json")
|
||||
doc["description"] = "geaendert"
|
||||
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
|
||||
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.updated == ["simple_rule"]
|
||||
versions = store.list_versions(session, definition.id)
|
||||
assert [v.version_no for v in versions] == [1, 2]
|
||||
assert versions[1].lfx_flow_id == lfx_id_for(definition.id, 2)
|
||||
# Superseded LFX registration is gone.
|
||||
with pytest.raises(LfxNotFound):
|
||||
await _lfx().flow_info(old_lfx_id)
|
||||
|
||||
|
||||
async def test_sync_deletes_missing_file_with_cascade(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
version = store.list_versions(session, definition.id)[0]
|
||||
run = store.create_run(session, version, input={"a": 1}, tenant_id="default")
|
||||
lfx_id = lfx_id_for(definition.id, 1)
|
||||
|
||||
(tmp_path / "simple_rule.json").unlink()
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.deleted == ["simple_rule"]
|
||||
assert store.get_definition_by_source_path(session, "simple_rule.json") is None
|
||||
with pytest.raises(store.NotFoundError):
|
||||
store.get_run(session, run.id)
|
||||
with pytest.raises(LfxNotFound):
|
||||
await _lfx().flow_info(lfx_id)
|
||||
|
||||
|
||||
async def test_sync_reports_invalid_json(session, lfx_server, tmp_path):
|
||||
(tmp_path / "kaputt.json").write_text("{kein json")
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert "kaputt.json" in report.errors
|
||||
assert report.added == []
|
||||
assert store.list_definitions(session) == []
|
||||
|
||||
|
||||
async def test_sync_missing_dir_raises(session, lfx_server, tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path / "gibts-nicht")
|
||||
|
||||
|
||||
async def test_sync_rename_updates_definition_name(session, lfx_server, tmp_path):
|
||||
_seed_dir(tmp_path)
|
||||
await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
doc = load_fixture("simple_rule.json")
|
||||
doc["name"] = "umbenannt"
|
||||
(tmp_path / "simple_rule.json").write_text(json.dumps(doc))
|
||||
report = await sync.sync_from_directory(session, _lfx(), tmp_path)
|
||||
assert report.updated == ["simple_rule"]
|
||||
definition = store.get_definition_by_source_path(session, "simple_rule.json")
|
||||
assert definition.name == "umbenannt"
|
||||
|
||||
|
||||
async def test_sync_endpoint_returns_report(client, lfx_server, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "flows_dir", str(tmp_path))
|
||||
_seed_dir(tmp_path)
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["added"] == ["simple_rule"]
|
||||
assert body["errors"] == {}
|
||||
|
||||
response = await client.post("/api/flows/sync")
|
||||
assert response.json()["skipped"] == ["simple_rule"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue