Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/backend/base/langflow/api/v1/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ async def update_project(
if not existing_project:
raise HTTPException(status_code=404, detail="Project not found")

result = await session.exec(
select(Flow.id, Flow.is_component).where(Flow.folder_id == existing_project.id, Flow.user_id == current_user.id)
)
flows_and_components = result.all()

project.flows = [flow_id for flow_id, is_component in flows_and_components if not is_component]
project.components = [flow_id for flow_id, is_component in flows_and_components if is_component]

try:
# Track if MCP Composer needs to be started or stopped
should_start_mcp_composer = False
Expand Down Expand Up @@ -261,7 +269,7 @@ async def update_project(

flows_ids = (await session.exec(select(Flow.id).where(Flow.folder_id == existing_project.id))).all()

excluded_flows = list(set(flows_ids) - set(concat_project_components))
excluded_flows = list(set(flows_ids) - set(project.flows))

my_collection_project = (await session.exec(select(Folder).where(Folder.name == DEFAULT_FOLDER_NAME))).first()
if my_collection_project:
Expand Down
237 changes: 237 additions & 0 deletions src/backend/tests/unit/api/v1/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,240 @@ async def test_create_and_read_project_cyrillic(client: AsyncClient, logged_in_h

assert fetched["name"] == CYRILLIC_NAME
assert fetched["description"] == CYRILLIC_DESC


async def test_update_project_preserves_flows(client: AsyncClient, logged_in_headers):
"""Test that renaming a project preserves all associated flows (regression test for flow loss bug)."""
# Create a project
project_payload = {
"name": "Project with Flows",
"description": "Testing flow preservation",
"flows_list": [],
"components_list": [],
}
create_resp = await client.post("api/v1/projects/", json=project_payload, headers=logged_in_headers)
assert create_resp.status_code == status.HTTP_201_CREATED
project = create_resp.json()
project_id = project["id"]

# Create flows in the project
flow1_payload = {
"name": "Test Flow 1",
"description": "First test flow",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": False,
}
flow2_payload = {
"name": "Test Flow 2",
"description": "Second test flow",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": False,
}

flow1_resp = await client.post("api/v1/flows/", json=flow1_payload, headers=logged_in_headers)
flow2_resp = await client.post("api/v1/flows/", json=flow2_payload, headers=logged_in_headers)
assert flow1_resp.status_code == status.HTTP_201_CREATED
assert flow2_resp.status_code == status.HTTP_201_CREATED

flow1_id = flow1_resp.json()["id"]
flow2_id = flow2_resp.json()["id"]

# Get project to verify flows are associated
get_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
assert get_resp.status_code == status.HTTP_200_OK
project_data = get_resp.json()

# Current behavior: all flows (including components) are in the flows field
flows_before = project_data.get("flows", [])
# Filter only actual flows (not components)
actual_flows_before = [f for f in flows_before if not f.get("is_component", False)]

assert len(actual_flows_before) == 2
flow_ids_before = [f["id"] for f in actual_flows_before]
assert str(flow1_id) in flow_ids_before
assert str(flow2_id) in flow_ids_before

# Update project name (the bug scenario)
update_payload = {"name": "Renamed Project with Flows", "description": "Testing flow preservation after rename"}
update_resp = await client.patch(f"api/v1/projects/{project_id}", json=update_payload, headers=logged_in_headers)
assert update_resp.status_code == status.HTTP_200_OK

# Verify project was renamed
updated_project = update_resp.json()
assert updated_project["name"] == "Renamed Project with Flows"

# Critical test: Verify flows are still associated after rename
get_after_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
assert get_after_resp.status_code == status.HTTP_200_OK
project_after = get_after_resp.json()

flows_after = project_after.get("flows", [])
actual_flows_after = [f for f in flows_after if not f.get("is_component", False)]

# This was the bug: flows were being lost after project rename
assert len(actual_flows_after) == 2, f"Expected 2 flows after rename, got {len(actual_flows_after)}. Flows lost!"

flow_ids_after = [f["id"] for f in actual_flows_after]
assert str(flow1_id) in flow_ids_after, "Flow 1 was lost after project rename!"
assert str(flow2_id) in flow_ids_after, "Flow 2 was lost after project rename!"

# Verify individual flows still exist and are accessible
flow1_get_resp = await client.get(f"api/v1/flows/{flow1_id}", headers=logged_in_headers)
flow2_get_resp = await client.get(f"api/v1/flows/{flow2_id}", headers=logged_in_headers)
assert flow1_get_resp.status_code == status.HTTP_200_OK
assert flow2_get_resp.status_code == status.HTTP_200_OK

# Verify flows still reference the correct project
flow1_data = flow1_get_resp.json()
flow2_data = flow2_get_resp.json()
assert str(flow1_data["folder_id"]) == str(project_id)
assert str(flow2_data["folder_id"]) == str(project_id)


async def test_update_project_preserves_components(client: AsyncClient, logged_in_headers):
"""Test that renaming a project preserves all associated components."""
# Create a project
project_payload = {
"name": "Project with Components",
"description": "Testing component preservation",
"flows_list": [],
"components_list": [],
}
create_resp = await client.post("api/v1/projects/", json=project_payload, headers=logged_in_headers)
assert create_resp.status_code == status.HTTP_201_CREATED
project = create_resp.json()
project_id = project["id"]

# Create components in the project
comp1_payload = {
"name": "Test Component 1",
"description": "First test component",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": True, # This makes it a component
}
comp2_payload = {
"name": "Test Component 2",
"description": "Second test component",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": True, # This makes it a component
}

comp1_resp = await client.post("api/v1/flows/", json=comp1_payload, headers=logged_in_headers)
comp2_resp = await client.post("api/v1/flows/", json=comp2_payload, headers=logged_in_headers)
assert comp1_resp.status_code == status.HTTP_201_CREATED
assert comp2_resp.status_code == status.HTTP_201_CREATED

comp1_id = comp1_resp.json()["id"]
comp2_id = comp2_resp.json()["id"]

# Get project to verify components are associated
get_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
assert get_resp.status_code == status.HTTP_200_OK
project_data = get_resp.json()

# Current behavior: all flows (including components) are in the flows field
flows_before = project_data.get("flows", [])
# Filter only components
components_before = [f for f in flows_before if f.get("is_component", False)]

assert len(components_before) == 2
component_ids_before = [c["id"] for c in components_before]
assert str(comp1_id) in component_ids_before
assert str(comp2_id) in component_ids_before

# Update project name
update_payload = {"name": "Renamed Project with Components"}
update_resp = await client.patch(f"api/v1/projects/{project_id}", json=update_payload, headers=logged_in_headers)
assert update_resp.status_code == status.HTTP_200_OK

# Verify components are still associated after rename
get_after_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
assert get_after_resp.status_code == status.HTTP_200_OK
project_after = get_after_resp.json()

flows_after = project_after.get("flows", [])
components_after = [f for f in flows_after if f.get("is_component", False)]

assert len(components_after) == 2, (
f"Expected 2 components after rename, got {len(components_after)}. Components lost!"
)

component_ids_after = [c["id"] for c in components_after]
assert str(comp1_id) in component_ids_after, "Component 1 was lost after project rename!"
assert str(comp2_id) in component_ids_after, "Component 2 was lost after project rename!"


async def test_update_project_preserves_mixed_flows_and_components(client: AsyncClient, logged_in_headers):
"""Test that renaming a project preserves both flows and components correctly."""
# Create a project
project_payload = {
"name": "Mixed Project",
"description": "Testing mixed flows and components preservation",
"flows_list": [],
"components_list": [],
}
create_resp = await client.post("api/v1/projects/", json=project_payload, headers=logged_in_headers)
assert create_resp.status_code == status.HTTP_201_CREATED
project = create_resp.json()
project_id = project["id"]

# Create flows and components
flow_payload = {
"name": "Regular Flow",
"description": "A regular flow",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": False,
}
component_payload = {
"name": "Custom Component",
"description": "A custom component",
"folder_id": project_id,
"data": {"nodes": [], "edges": []},
"is_component": True,
}

flow_resp = await client.post("api/v1/flows/", json=flow_payload, headers=logged_in_headers)
comp_resp = await client.post("api/v1/flows/", json=component_payload, headers=logged_in_headers)
assert flow_resp.status_code == status.HTTP_201_CREATED
assert comp_resp.status_code == status.HTTP_201_CREATED

flow_id = flow_resp.json()["id"]
comp_id = comp_resp.json()["id"]

# Verify initial state
get_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
project_data = get_resp.json()

flows_before = project_data.get("flows", [])
actual_flows_before = [f for f in flows_before if not f.get("is_component", False)]
components_before = [f for f in flows_before if f.get("is_component", False)]

assert len(actual_flows_before) == 1
assert len(components_before) == 1

# Update project
update_payload = {"name": "Renamed Mixed Project"}
update_resp = await client.patch(f"api/v1/projects/{project_id}", json=update_payload, headers=logged_in_headers)
assert update_resp.status_code == status.HTTP_200_OK

# Verify both flows and components preserved
get_after_resp = await client.get(f"api/v1/projects/{project_id}", headers=logged_in_headers)
project_after = get_after_resp.json()

flows_after = project_after.get("flows", [])
actual_flows_after = [f for f in flows_after if not f.get("is_component", False)]
components_after = [f for f in flows_after if f.get("is_component", False)]

assert len(actual_flows_after) == 1, "Flow was lost after project rename!"
assert len(components_after) == 1, "Component was lost after project rename!"

flow_id_after = actual_flows_after[0]["id"]
comp_id_after = components_after[0]["id"]

assert str(flow_id) == flow_id_after
assert str(comp_id) == comp_id_after