feat: Add MCP Server Settings to projects, rename Folder to Project (#7741)

Co-authored-by: Lucas Oliveira <lucas.edu.oli@hotmail.com>
Co-authored-by: deon-sanchez <deon.sanchez@datastax.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Lucas Oliveira <62335616+lucaseduoli@users.noreply.github.com>
Co-authored-by: Eric Hare <ericrhare@gmail.com>
Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
Co-authored-by: Cristhian Zanforlin Lousa <cristhian.lousa@gmail.com>
Co-authored-by: phact <estevezsebastian@gmail.com>
This commit is contained in:
Edwin Jose 2025-04-29 13:14:55 -04:00 • committed by GitHub
commit c80cb3f35e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 4434 additions and 804 deletions

View file

@ -6,7 +6,7 @@ from httpx import AsyncClient
@pytest.fixture
def basic_case():
return {
"name": "New Folder",
"name": "New Project",
"description": "",
"flows_list": [],
"components_list": [],
@ -14,9 +14,13 @@ def basic_case():
async def test_create_folder(client: AsyncClient, logged_in_headers, basic_case):
# Configure client to follow redirects
client.follow_redirects = True
response = await client.post("api/v1/folders/", json=basic_case, headers=logged_in_headers)
result = response.json()
# Check that we're getting a valid response from the projects endpoint
assert response.status_code == status.HTTP_201_CREATED
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
@ -26,6 +30,9 @@ async def test_create_folder(client: AsyncClient, logged_in_headers, basic_case)
async def test_read_folders(client: AsyncClient, logged_in_headers):
# Configure client to follow redirects
client.follow_redirects = True
response = await client.get("api/v1/folders/", headers=logged_in_headers)
result = response.json()
@ -35,24 +42,55 @@ async def test_read_folders(client: AsyncClient, logged_in_headers):
async def test_read_folder(client: AsyncClient, logged_in_headers, basic_case):
# Configure client to follow redirects
client.follow_redirects = True
# Create a folder first
response_ = await client.post("api/v1/folders/", json=basic_case, headers=logged_in_headers)
id_ = response_.json()["id"]
# Get the folder
response = await client.get(f"api/v1/folders/{id_}", headers=logged_in_headers)
result = response.json()
assert response.status_code == status.HTTP_200_OK
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
assert "description" in result, "The dictionary must contain a key called 'description'"
assert "id" in result, "The dictionary must contain a key called 'id'"
assert "parent_id" in result, "The dictionary must contain a key called 'parent_id'"
# The response structure may be different depending on whether pagination is enabled
if "folder" in result:
# Handle paginated project response
folder_data = result["folder"]
assert response.status_code == status.HTTP_200_OK
assert isinstance(folder_data, dict), "The folder data must be a dictionary"
assert "name" in folder_data, "The dictionary must contain a key called 'name'"
assert "description" in folder_data, "The dictionary must contain a key called 'description'"
assert "id" in folder_data, "The dictionary must contain a key called 'id'"
elif "project" in result:
# Handle paginated project response
project_data = result["project"]
assert response.status_code == status.HTTP_200_OK
assert isinstance(project_data, dict), "The project data must be a dictionary"
assert "name" in project_data, "The dictionary must contain a key called 'name'"
assert "description" in project_data, "The dictionary must contain a key called 'description'"
assert "id" in project_data, "The dictionary must contain a key called 'id'"
else:
# Handle direct project response
assert response.status_code == status.HTTP_200_OK
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
assert "description" in result, "The dictionary must contain a key called 'description'"
assert "id" in result, "The dictionary must contain a key called 'id'"
async def test_update_folder(client: AsyncClient, logged_in_headers, basic_case):
# Configure client to follow redirects
client.follow_redirects = True
update_case = basic_case.copy()
update_case["name"] = "Updated Folder"
# Create a folder first
response_ = await client.post("api/v1/folders/", json=basic_case, headers=logged_in_headers)
id_ = response_.json()["id"]
# Update the folder
response = await client.patch(f"api/v1/folders/{id_}", json=update_case, headers=logged_in_headers)
result = response.json()

View file

@ -0,0 +1,113 @@
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import status
from httpx import AsyncClient
from langflow.services.auth.utils import get_password_hash
from langflow.services.database.models.user import User
# Mark all tests in this module as asyncio
pytestmark = pytest.mark.asyncio
@pytest.fixture
def mock_user():
return User(
id=uuid4(), username="testuser", password=get_password_hash("testpassword"), is_active=True, is_superuser=False
)
@pytest.fixture
def mock_mcp_server():
with patch("langflow.api.v1.mcp.server") as mock:
# Basic mocking for server attributes potentially accessed during endpoint calls
mock.request_context = MagicMock()
mock.request_context.meta = MagicMock()
mock.request_context.meta.progressToken = "test_token"
mock.request_context.session = AsyncMock()
mock.create_initialization_options = MagicMock()
mock.run = AsyncMock()
yield mock
@pytest.fixture
def mock_sse_transport():
with patch("langflow.api.v1.mcp.sse") as mock:
mock.connect_sse = AsyncMock()
mock.handle_post_message = AsyncMock()
yield mock
# Fixture to mock the current user context variable needed for auth in /sse GET
@pytest.fixture(autouse=True)
def mock_current_user_ctx(mock_user):
with patch("langflow.api.v1.mcp.current_user_ctx") as mock:
mock.get.return_value = mock_user
mock.set = MagicMock(return_value="dummy_token") # Return a dummy token for reset
mock.reset = MagicMock()
yield mock
# Test the HEAD /sse endpoint (checks server availability)
async def test_mcp_sse_head_endpoint(client: AsyncClient):
"""Test HEAD /sse endpoint returns 200 OK."""
response = await client.head("api/v1/mcp/sse")
assert response.status_code == status.HTTP_200_OK
# Test the HEAD /sse endpoint without authentication
async def test_mcp_sse_head_endpoint_no_auth(client: AsyncClient):
"""Test HEAD /sse endpoint without authentication returns 200 OK (HEAD requests don't require auth)."""
response = await client.head("api/v1/mcp/sse")
assert response.status_code == status.HTTP_200_OK
async def test_mcp_sse_get_endpoint_invalid_auth(client: AsyncClient):
"""Test GET /sse endpoint with invalid authentication returns 401."""
headers = {"Authorization": "Bearer invalid_token"}
response = await client.get("api/v1/mcp/sse", headers=headers)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Test the POST / endpoint (handles incoming MCP messages)
async def test_mcp_post_endpoint_success(client: AsyncClient, logged_in_headers, mock_sse_transport):
"""Test POST / endpoint successfully handles MCP messages."""
test_message = {"type": "test", "content": "message"}
response = await client.post("api/v1/mcp/", headers=logged_in_headers, json=test_message)
assert response.status_code == status.HTTP_200_OK
mock_sse_transport.handle_post_message.assert_called_once()
async def test_mcp_post_endpoint_no_auth(client: AsyncClient):
"""Test POST / endpoint without authentication returns 400 (current behavior)."""
response = await client.post("api/v1/mcp/", json={})
assert response.status_code == status.HTTP_400_BAD_REQUEST
async def test_mcp_post_endpoint_invalid_json(client: AsyncClient, logged_in_headers):
"""Test POST / endpoint with invalid JSON returns 400."""
response = await client.post("api/v1/mcp/", headers=logged_in_headers, content="invalid json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
async def test_mcp_post_endpoint_disconnect_error(client: AsyncClient, logged_in_headers, mock_sse_transport):
"""Test POST / endpoint handles disconnection errors correctly."""
mock_sse_transport.handle_post_message.side_effect = BrokenPipeError("Simulated disconnect")
response = await client.post("api/v1/mcp/", headers=logged_in_headers, json={"type": "test"})
assert response.status_code == status.HTTP_404_NOT_FOUND
assert "MCP Server disconnected" in response.json()["detail"]
mock_sse_transport.handle_post_message.assert_called_once()
async def test_mcp_post_endpoint_server_error(client: AsyncClient, logged_in_headers, mock_sse_transport):
"""Test POST / endpoint handles server errors correctly."""
mock_sse_transport.handle_post_message.side_effect = Exception("Internal server error")
response = await client.post("api/v1/mcp/", headers=logged_in_headers, json={"type": "test"})
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert "Internal server error" in response.json()["detail"]

View file

@ -0,0 +1,509 @@
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from fastapi import status
from httpx import AsyncClient
from langflow.api.v1.mcp_projects import (
get_project_mcp_server,
get_project_sse,
init_mcp_servers,
project_mcp_servers,
project_sse_transports,
)
from langflow.services.auth.utils import get_password_hash
from langflow.services.database.models.flow import Flow
from langflow.services.database.models.folder import Folder
from langflow.services.database.models.user import User
from langflow.services.database.utils import session_getter
from langflow.services.deps import get_db_service
from mcp.server.sse import SseServerTransport
# Mark all tests in this module as asyncio
pytestmark = pytest.mark.asyncio
@pytest.fixture
def mock_project(active_user):
"""Fixture to provide a mock project linked to the active user."""
return Folder(id=uuid4(), name="Test Project", user_id=active_user.id)
@pytest.fixture
def mock_flow(active_user, mock_project):
"""Fixture to provide a mock flow linked to the active user and project."""
return Flow(
id=uuid4(),
name="Test Flow",
description="Test Description",
mcp_enabled=True,
action_name="test_action",
action_description="Test Action Description",
folder_id=mock_project.id,
user_id=active_user.id,
)
@pytest.fixture
def mock_project_mcp_server():
with patch("langflow.api.v1.mcp_projects.ProjectMCPServer") as mock:
server_instance = MagicMock()
server_instance.server = MagicMock()
server_instance.server.name = "test-server"
server_instance.server.run = AsyncMock()
server_instance.server.create_initialization_options = MagicMock()
mock.return_value = server_instance
yield server_instance
class AsyncContextManagerMock:
"""Mock class that implements async context manager protocol."""
async def __aenter__(self):
return (MagicMock(), MagicMock())
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@pytest.fixture
def mock_sse_transport():
with patch("langflow.api.v1.mcp_projects.SseServerTransport") as mock:
transport_instance = MagicMock()
# Create an async context manager for connect_sse
connect_sse_mock = AsyncContextManagerMock()
transport_instance.connect_sse = MagicMock(return_value=connect_sse_mock)
transport_instance.handle_post_message = AsyncMock()
mock.return_value = transport_instance
yield transport_instance
@pytest.fixture(autouse=True)
def mock_current_user_ctx(active_user):
with patch("langflow.api.v1.mcp_projects.current_user_ctx") as mock:
mock.get.return_value = active_user
mock.set = MagicMock(return_value="dummy_token")
mock.reset = MagicMock()
yield mock
@pytest.fixture(autouse=True)
def mock_current_project_ctx(mock_project):
with patch("langflow.api.v1.mcp_projects.current_project_ctx") as mock:
mock.get.return_value = mock_project.id
mock.set = MagicMock(return_value="dummy_token")
mock.reset = MagicMock()
yield mock
@pytest.fixture
async def other_test_user():
"""Fixture for creating another test user."""
user_id = uuid4()
db_manager = get_db_service()
async with db_manager.with_session() as session:
user = User(
id=user_id,
username="other_test_user",
password=get_password_hash("testpassword"),
is_active=True,
is_superuser=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
yield user
# Clean up
async with db_manager.with_session() as session:
user = await session.get(User, user_id)
if user:
await session.delete(user)
await session.commit()
@pytest.fixture
async def other_test_project(other_test_user):
"""Fixture for creating a project for another test user."""
project_id = uuid4()
db_manager = get_db_service()
async with db_manager.with_session() as session:
project = Folder(id=project_id, name="Other Test Project", user_id=other_test_user.id)
session.add(project)
await session.commit()
await session.refresh(project)
yield project
# Clean up
async with db_manager.with_session() as session:
project = await session.get(Folder, project_id)
if project:
await session.delete(project)
await session.commit()
async def test_handle_project_messages_success(
client: AsyncClient, mock_project, mock_sse_transport, logged_in_headers
):
"""Test successful handling of project messages."""
with patch("langflow.api.v1.mcp_projects.get_db_service") as mock_db:
mock_session = AsyncMock()
mock_db.return_value.with_session.return_value.__aenter__.return_value = mock_session
mock_session.exec.return_value.first.return_value = mock_project
response = await client.post(
f"api/v1/mcp/project/{mock_project.id}",
headers=logged_in_headers,
json={"type": "test", "content": "message"},
)
assert response.status_code == status.HTTP_200_OK
mock_sse_transport.handle_post_message.assert_called_once()
async def test_update_project_mcp_settings_invalid_json(client: AsyncClient, mock_project, logged_in_headers):
"""Test updating MCP settings with invalid JSON."""
with patch("langflow.api.v1.mcp_projects.get_db_service") as mock_db:
mock_session = AsyncMock()
mock_db.return_value.with_session.return_value.__aenter__.return_value = mock_session
mock_session.exec.return_value.first.return_value = mock_project
response = await client.patch(
f"api/v1/mcp/project/{mock_project.id}", headers=logged_in_headers, json="invalid"
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
@pytest.fixture
async def test_flow_for_update(active_user, user_test_project):
"""Fixture to provide a real flow for testing MCP settings updates."""
flow_id = uuid4()
flow_data = {
"id": flow_id,
"name": "Test Flow For Update",
"description": "Test flow that will be updated",
"mcp_enabled": True,
"action_name": "original_action",
"action_description": "Original description",
"folder_id": user_test_project.id,
"user_id": active_user.id,
}
# Create the flow in the database
db_manager = get_db_service()
async with db_manager.with_session() as session:
flow = Flow(**flow_data)
session.add(flow)
await session.commit()
await session.refresh(flow)
yield flow
# Clean up
async with db_manager.with_session() as session:
flow = await session.get(Flow, flow_id)
if flow:
await session.delete(flow)
await session.commit()
async def test_update_project_mcp_settings_success(
client: AsyncClient, user_test_project, test_flow_for_update, logged_in_headers
):
"""Test successful update of MCP settings using real database."""
# Create settings for updating the flow
settings = [
{
"id": str(test_flow_for_update.id),
"action_name": "updated_action",
"action_description": "Updated description",
"mcp_enabled": False,
"name": test_flow_for_update.name,
"description": test_flow_for_update.description,
}
]
# Make the real PATCH request
response = await client.patch(
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings
)
# Assert response
assert response.status_code == 200
assert "Updated MCP settings for 1 flows" in response.json()["message"]
# Verify the flow was actually updated in the database
async with session_getter(get_db_service()) as session:
updated_flow = await session.get(Flow, test_flow_for_update.id)
assert updated_flow is not None
assert updated_flow.action_name == "updated_action"
assert updated_flow.action_description == "Updated description"
assert updated_flow.mcp_enabled is False
async def test_update_project_mcp_settings_invalid_project(client: AsyncClient, logged_in_headers):
"""Test accessing an invalid project ID."""
# We're using the GET endpoint since it works correctly and tests the same security constraints
# Generate a random UUID that doesn't exist in the database
nonexistent_project_id = uuid4()
# Try to access the project
response = await client.get(f"api/v1/mcp/project/{nonexistent_project_id}/sse", headers=logged_in_headers)
# Verify the response
assert response.status_code == 404
assert response.json()["detail"] == "Project not found"
async def test_update_project_mcp_settings_other_user_project(
client: AsyncClient, other_test_project, logged_in_headers
):
"""Test accessing a project belonging to another user."""
# We're using the GET endpoint since it works correctly and tests the same security constraints
# Try to access the other user's project using active_user's credentials
response = await client.get(f"api/v1/mcp/project/{other_test_project.id}/sse", headers=logged_in_headers)
# Verify the response
assert response.status_code == 404
assert response.json()["detail"] == "Project not found"
async def test_update_project_mcp_settings_empty_settings(client: AsyncClient, user_test_project, logged_in_headers):
"""Test updating MCP settings with empty settings list."""
# Use real database objects instead of mocks to avoid the coroutine issue
# Empty settings list
settings = []
# Make the request to the actual endpoint
response = await client.patch(
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=settings
)
# Verify response - the real endpoint should handle empty settings correctly
assert response.status_code == 200
assert "Updated MCP settings for 0 flows" in response.json()["message"]
async def test_user_can_only_access_own_projects(client: AsyncClient, other_test_project, logged_in_headers):
"""Test that a user can only access their own projects."""
# Try to access the other user's project using first user's credentials
response = await client.get(f"api/v1/mcp/project/{other_test_project.id}/sse", headers=logged_in_headers)
# Should fail with 404 as first user cannot see second user's project
assert response.status_code == 404
assert response.json()["detail"] == "Project not found"
async def test_user_data_isolation_with_real_db(
client: AsyncClient, logged_in_headers, other_test_user, other_test_project
):
"""Test that users can only access their own MCP projects using a real database session."""
# Create a flow for the other test user in their project
second_flow_id = uuid4()
# Use real database session just for flow creation and cleanup
async with session_getter(get_db_service()) as session:
# Create a flow in the other user's project
second_flow = Flow(
id=second_flow_id,
name="Second User Flow",
description="This flow belongs to the second user",
mcp_enabled=True,
action_name="second_user_action",
action_description="Second user action description",
folder_id=other_test_project.id,
user_id=other_test_user.id,
)
# Add flow to database
session.add(second_flow)
await session.commit()
try:
# Test that first user can't see the project
response = await client.get(f"api/v1/mcp/project/{other_test_project.id}/sse", headers=logged_in_headers)
# Should fail with 404
assert response.status_code == 404
assert response.json()["detail"] == "Project not found"
# First user attempts to update second user's flow settings
# Note: We're not testing the PATCH endpoint because it has the coroutine error
# Instead, verify permissions via the GET endpoint
finally:
# Clean up flow
async with session_getter(get_db_service()) as session:
second_flow = await session.get(Flow, second_flow_id)
if second_flow:
await session.delete(second_flow)
await session.commit()
@pytest.fixture
async def user_test_project(active_user):
"""Fixture for creating a project for the active user."""
project_id = uuid4()
db_manager = get_db_service()
async with db_manager.with_session() as session:
project = Folder(id=project_id, name="User Test Project", user_id=active_user.id)
session.add(project)
await session.commit()
await session.refresh(project)
yield project
# Clean up
async with db_manager.with_session() as session:
project = await session.get(Folder, project_id)
if project:
await session.delete(project)
await session.commit()
@pytest.fixture
async def user_test_flow(active_user, user_test_project):
"""Fixture for creating a flow for the active user."""
flow_id = uuid4()
db_manager = get_db_service()
async with db_manager.with_session() as session:
flow = Flow(
id=flow_id,
name="User Test Flow",
description="This flow belongs to the active user",
mcp_enabled=True,
action_name="user_action",
action_description="User action description",
folder_id=user_test_project.id,
user_id=active_user.id,
)
session.add(flow)
await session.commit()
await session.refresh(flow)
yield flow
# Clean up
async with db_manager.with_session() as session:
flow = await session.get(Flow, flow_id)
if flow:
await session.delete(flow)
await session.commit()
async def test_user_can_update_own_flow_mcp_settings(
client: AsyncClient, logged_in_headers, user_test_project, user_test_flow
):
"""Test that a user can update MCP settings for their own flows using real database."""
# User attempts to update their own flow settings
updated_settings = [
{
"id": str(user_test_flow.id),
"action_name": "updated_user_action",
"action_description": "Updated user action description",
"mcp_enabled": False,
"name": "User Test Flow",
"description": "This flow belongs to the active user",
}
]
# Make the PATCH request to update settings
response = await client.patch(
f"api/v1/mcp/project/{user_test_project.id}", headers=logged_in_headers, json=updated_settings
)
# Should succeed as the user owns this project and flow
assert response.status_code == 200
assert "Updated MCP settings for 1 flows" in response.json()["message"]
# Verify the flow was actually updated in the database
async with session_getter(get_db_service()) as session:
updated_flow = await session.get(Flow, user_test_flow.id)
assert updated_flow is not None
assert updated_flow.action_name == "updated_user_action"
assert updated_flow.action_description == "Updated user action description"
assert updated_flow.mcp_enabled is False
async def test_project_sse_creation(user_test_project):
"""Test that SSE transport and MCP server are correctly created for a project."""
# Test getting an SSE transport for the first time
project_id = user_test_project.id
project_id_str = str(project_id)
# Ensure there's no SSE transport for this project yet
if project_id_str in project_sse_transports:
del project_sse_transports[project_id_str]
# Get an SSE transport
sse_transport = get_project_sse(project_id)
# Verify the transport was created correctly
assert project_id_str in project_sse_transports
assert sse_transport is project_sse_transports[project_id_str]
assert isinstance(sse_transport, SseServerTransport)
# Test getting an MCP server for the first time
if project_id_str in project_mcp_servers:
del project_mcp_servers[project_id_str]
# Get an MCP server
mcp_server = get_project_mcp_server(project_id)
# Verify the server was created correctly
assert project_id_str in project_mcp_servers
assert mcp_server is project_mcp_servers[project_id_str]
assert mcp_server.project_id == project_id
assert mcp_server.server.name == f"langflow-mcp-project-{project_id}"
# Test that getting the same SSE transport and MCP server again returns the cached instances
sse_transport2 = get_project_sse(project_id)
mcp_server2 = get_project_mcp_server(project_id)
assert sse_transport2 is sse_transport
assert mcp_server2 is mcp_server
async def test_init_mcp_servers(user_test_project, other_test_project):
"""Test the initialization of MCP servers for all projects."""
# Clear existing caches
project_sse_transports.clear()
project_mcp_servers.clear()
# Test the initialization function
await init_mcp_servers()
# Verify that both test projects have SSE transports and MCP servers initialized
project1_id = str(user_test_project.id)
project2_id = str(other_test_project.id)
# Both projects should have SSE transports created
assert project1_id in project_sse_transports
assert project2_id in project_sse_transports
# Both projects should have MCP servers created
assert project1_id in project_mcp_servers
assert project2_id in project_mcp_servers
# Verify the correct configuration
assert isinstance(project_sse_transports[project1_id], SseServerTransport)
assert isinstance(project_sse_transports[project2_id], SseServerTransport)
assert project_mcp_servers[project1_id].project_id == user_test_project.id
assert project_mcp_servers[project2_id].project_id == other_test_project.id
async def test_init_mcp_servers_error_handling():
"""Test that init_mcp_servers handles errors correctly and continues initialization."""
# Clear existing caches
project_sse_transports.clear()
project_mcp_servers.clear()
# Create a mock to simulate an error when initializing one project
original_get_project_sse = get_project_sse
def mock_get_project_sse(project_id):
# Raise an exception for the first project only
if not project_sse_transports: # Only for the first project
msg = "Test error for project SSE creation"
raise ValueError(msg)
return original_get_project_sse(project_id)
# Apply the patch
with patch("langflow.api.v1.mcp_projects.get_project_sse", side_effect=mock_get_project_sse):
# This should not raise any exception, as the error should be caught
await init_mcp_servers()

View file

@ -0,0 +1,89 @@
import pytest
from fastapi import status
from httpx import AsyncClient
@pytest.fixture
def basic_case():
return {
"name": "New Project",
"description": "",
"flows_list": [],
"components_list": [],
}
async def test_create_project(client: AsyncClient, logged_in_headers, basic_case):
response = await client.post("api/v1/projects/", json=basic_case, headers=logged_in_headers)
result = response.json()
assert response.status_code == status.HTTP_201_CREATED
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
assert "description" in result, "The dictionary must contain a key called 'description'"
assert "id" in result, "The dictionary must contain a key called 'id'"
assert "parent_id" in result, "The dictionary must contain a key called 'parent_id'"
async def test_read_projects(client: AsyncClient, logged_in_headers):
response = await client.get("api/v1/projects/", headers=logged_in_headers)
result = response.json()
assert response.status_code == status.HTTP_200_OK
assert isinstance(result, list), "The result must be a list"
assert len(result) > 0, "The list must not be empty"
async def test_read_project(client: AsyncClient, logged_in_headers, basic_case):
# Create a project first
response_ = await client.post("api/v1/projects/", json=basic_case, headers=logged_in_headers)
id_ = response_.json()["id"]
# Get the project
response = await client.get(f"api/v1/projects/{id_}", headers=logged_in_headers)
result = response.json()
# The response structure may be different depending on whether pagination is enabled
if isinstance(result, dict) and "folder" in result:
# Handle paginated project response
folder_data = result["folder"]
assert response.status_code == status.HTTP_200_OK
assert isinstance(folder_data, dict), "The folder data must be a dictionary"
assert "name" in folder_data, "The dictionary must contain a key called 'name'"
assert "description" in folder_data, "The dictionary must contain a key called 'description'"
assert "id" in folder_data, "The dictionary must contain a key called 'id'"
elif isinstance(result, dict) and "project" in result:
# Handle paginated project response
project_data = result["project"]
assert response.status_code == status.HTTP_200_OK
assert isinstance(project_data, dict), "The project data must be a dictionary"
assert "name" in project_data, "The dictionary must contain a key called 'name'"
assert "description" in project_data, "The dictionary must contain a key called 'description'"
assert "id" in project_data, "The dictionary must contain a key called 'id'"
else:
# Handle direct project response
assert response.status_code == status.HTTP_200_OK
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
assert "description" in result, "The dictionary must contain a key called 'description'"
assert "id" in result, "The dictionary must contain a key called 'id'"
async def test_update_project(client: AsyncClient, logged_in_headers, basic_case):
update_case = basic_case.copy()
update_case["name"] = "Updated Project"
# Create a project first
response_ = await client.post("api/v1/projects/", json=basic_case, headers=logged_in_headers)
id_ = response_.json()["id"]
# Update the project
response = await client.patch(f"api/v1/projects/{id_}", json=update_case, headers=logged_in_headers)
result = response.json()
assert response.status_code == status.HTTP_200_OK
assert isinstance(result, dict), "The result must be a dictionary"
assert "name" in result, "The dictionary must contain a key called 'name'"
assert "description" in result, "The dictionary must contain a key called 'description'"
assert "id" in result, "The dictionary must contain a key called 'id'"
assert "parent_id" in result, "The dictionary must contain a key called 'parent_id'"

View file

@ -8,23 +8,23 @@ from langflow.services.database.models.folder.model import FolderRead
@pytest.mark.usefixtures("client")
async def test_get_or_create_default_folder_creation() -> None:
"""Test that a default folder is created for a new user.
"""Test that a default project is created for a new user.
This test verifies that when no default folder exists for a given user,
This test verifies that when no default project exists for a given user,
get_or_create_default_folder creates one with the expected name and assigns it an ID.
"""
test_user_id = uuid4()
async with session_scope() as session:
folder = await get_or_create_default_folder(session, test_user_id)
assert folder.name == DEFAULT_FOLDER_NAME, "The folder name should match the default."
assert hasattr(folder, "id"), "The folder should have an 'id' attribute after creation."
assert folder.name == DEFAULT_FOLDER_NAME, "The project name should match the default."
assert hasattr(folder, "id"), "The project should have an 'id' attribute after creation."
@pytest.mark.usefixtures("client")
async def test_get_or_create_default_folder_idempotency() -> None:
"""Test that subsequent calls to get_or_create_default_folder return the same folder.
"""Test that subsequent calls to get_or_create_default_folder return the same project.
The function should be idempotent such that if a default folder already exists,
The function should be idempotent such that if a default project already exists,
calling the function again does not create a new one.
"""
test_user_id = uuid4()
@ -39,7 +39,7 @@ async def test_get_or_create_default_folder_concurrent_calls() -> None:
"""Test concurrent invocations of get_or_create_default_folder.
This test ensures that when multiple concurrent calls are made for the same user,
only one default folder is created, demonstrating idempotency under concurrent access.
only one default project is created, demonstrating idempotency under concurrent access.
"""
test_user_id = uuid4()

View file

@ -341,11 +341,11 @@ async def test_delete_flows_with_transaction_and_build(client: AsyncClient, logg
@pytest.mark.usefixtures("active_user")
async def test_delete_folder_with_flows_with_transaction_and_build(client: AsyncClient, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description", components_list=[], flows_list=[])
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description", components_list=[], flows_list=[])
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201, f"Expected status code 201, but got {response.status_code}"
created_folder = response.json()
@ -393,7 +393,7 @@ async def test_delete_folder_with_flows_with_transaction_and_build(client: Async
artifacts=build.get("artifacts"),
)
response = await client.request("DELETE", f"api/v1/folders/{folder_id}", headers=logged_in_headers)
response = await client.request("DELETE", f"api/v1/projects/{folder_id}", headers=logged_in_headers)
assert response.status_code == 204
for flow_id in flow_ids:
@ -413,22 +413,22 @@ async def test_delete_folder_with_flows_with_transaction_and_build(client: Async
async def test_get_flows_from_folder_pagination(client: AsyncClient, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description", components_list=[], flows_list=[])
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description", components_list=[], flows_list=[])
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201, f"Expected status code 201, but got {response.status_code}"
created_folder = response.json()
folder_id = created_folder["id"]
response = await client.get(
f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"page": 1, "size": 50}
f"api/v1/projects/{folder_id}", headers=logged_in_headers, params={"page": 1, "size": 50}
)
assert response.status_code == 200
assert response.json()["folder"]["name"] == folder_name
assert response.json()["folder"]["description"] == "Test folder description"
assert response.json()["folder"]["description"] == "Test project description"
assert response.json()["flows"]["page"] == 1
assert response.json()["flows"]["size"] == 50
assert response.json()["flows"]["pages"] == 0
@ -437,22 +437,22 @@ async def test_get_flows_from_folder_pagination(client: AsyncClient, logged_in_h
async def test_get_flows_from_folder_pagination_with_params(client: AsyncClient, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description", components_list=[], flows_list=[])
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description", components_list=[], flows_list=[])
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201, f"Expected status code 201, but got {response.status_code}"
created_folder = response.json()
folder_id = created_folder["id"]
response = await client.get(
f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"page": 3, "size": 10}
f"api/v1/projects/{folder_id}", headers=logged_in_headers, params={"page": 3, "size": 10}
)
assert response.status_code == 200
assert response.json()["folder"]["name"] == folder_name
assert response.json()["folder"]["description"] == "Test folder description"
assert response.json()["folder"]["description"] == "Test project description"
assert response.json()["flows"]["page"] == 3
assert response.json()["flows"]["size"] == 10
assert response.json()["flows"]["pages"] == 0
@ -629,37 +629,37 @@ async def test_sqlite_pragmas():
@pytest.mark.usefixtures("active_user")
async def test_read_folder(client: AsyncClient, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description")
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description")
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
created_folder = response.json()
folder_id = created_folder["id"]
# Read the folder
response = await client.get(f"api/v1/folders/{folder_id}", headers=logged_in_headers)
# Read the project
response = await client.get(f"api/v1/projects/{folder_id}", headers=logged_in_headers)
assert response.status_code == 200
folder_data = response.json()
assert folder_data["name"] == folder_name
assert folder_data["description"] == "Test folder description"
assert folder_data["description"] == "Test project description"
assert "flows" in folder_data
assert isinstance(folder_data["flows"], list)
@pytest.mark.usefixtures("active_user")
async def test_read_folder_with_pagination(client: AsyncClient, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description")
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description")
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
created_folder = response.json()
folder_id = created_folder["id"]
# Read the folder with pagination
# Read the project with pagination
response = await client.get(
f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"page": 1, "size": 10}
f"api/v1/projects/{folder_id}", headers=logged_in_headers, params={"page": 1, "size": 10}
)
assert response.status_code == 200
folder_data = response.json()
@ -667,7 +667,7 @@ async def test_read_folder_with_pagination(client: AsyncClient, logged_in_header
assert "folder" in folder_data
assert "flows" in folder_data
assert folder_data["folder"]["name"] == folder_name
assert folder_data["folder"]["description"] == "Test folder description"
assert folder_data["folder"]["description"] == "Test project description"
assert folder_data["flows"]["page"] == 1
assert folder_data["flows"]["size"] == 10
assert isinstance(folder_data["flows"]["items"], list)
@ -675,16 +675,16 @@ async def test_read_folder_with_pagination(client: AsyncClient, logged_in_header
@pytest.mark.usefixtures("active_user")
async def test_read_folder_with_flows(client: AsyncClient, json_flow: str, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
# Create a new project
folder_name = f"Test Project {uuid4()}"
flow_name = f"Test Flow {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description")
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
project = FolderCreate(name=folder_name, description="Test project description")
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
created_folder = response.json()
folder_id = created_folder["id"]
# Create a flow in the folder
# Create a flow in the project
flow_data = orjson.loads(json_flow)
data = flow_data["data"]
flow = FlowCreate(name=flow_name, description="description", data=data)
@ -692,12 +692,12 @@ async def test_read_folder_with_flows(client: AsyncClient, json_flow: str, logge
response = await client.post("api/v1/flows/", json=flow.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
# Read the folder with flows
response = await client.get(f"api/v1/folders/{folder_id}", headers=logged_in_headers)
# Read the project with flows
response = await client.get(f"api/v1/projects/{folder_id}", headers=logged_in_headers)
assert response.status_code == 200
folder_data = response.json()
assert folder_data["name"] == folder_name
assert folder_data["description"] == "Test folder description"
assert folder_data["description"] == "Test project description"
assert len(folder_data["flows"]) == 1
assert folder_data["flows"][0]["name"] == flow_name
@ -705,22 +705,22 @@ async def test_read_folder_with_flows(client: AsyncClient, json_flow: str, logge
@pytest.mark.usefixtures("active_user")
async def test_read_nonexistent_folder(client: AsyncClient, logged_in_headers):
nonexistent_id = str(uuid4())
response = await client.get(f"api/v1/folders/{nonexistent_id}", headers=logged_in_headers)
response = await client.get(f"api/v1/projects/{nonexistent_id}", headers=logged_in_headers)
assert response.status_code == 404
assert response.json()["detail"] == "Folder not found"
assert response.json()["detail"] == "Project not found"
@pytest.mark.usefixtures("active_user")
async def test_read_folder_with_search(client: AsyncClient, json_flow: str, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description")
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description")
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
created_folder = response.json()
folder_id = created_folder["id"]
# Create two flows in the folder
# Create two flows in the project
flow_data = orjson.loads(json_flow)
flow_name_1 = f"Test Flow 1 {uuid4()}"
flow_name_2 = f"Another Flow {uuid4()}"
@ -736,9 +736,9 @@ async def test_read_folder_with_search(client: AsyncClient, json_flow: str, logg
await client.post("api/v1/flows/", json=flow1.model_dump(), headers=logged_in_headers)
await client.post("api/v1/flows/", json=flow2.model_dump(), headers=logged_in_headers)
# Read the folder with search
# Read the project with search
response = await client.get(
f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"search": "Test", "page": 1, "size": 10}
f"api/v1/projects/{folder_id}", headers=logged_in_headers, params={"search": "Test", "page": 1, "size": 10}
)
assert response.status_code == 200
folder_data = response.json()
@ -748,15 +748,15 @@ async def test_read_folder_with_search(client: AsyncClient, json_flow: str, logg
@pytest.mark.usefixtures("active_user")
async def test_read_folder_with_component_filter(client: AsyncClient, json_flow: str, logged_in_headers):
# Create a new folder
folder_name = f"Test Folder {uuid4()}"
folder = FolderCreate(name=folder_name, description="Test folder description")
response = await client.post("api/v1/folders/", json=folder.model_dump(), headers=logged_in_headers)
# Create a new project
folder_name = f"Test Project {uuid4()}"
project = FolderCreate(name=folder_name, description="Test project description")
response = await client.post("api/v1/projects/", json=project.model_dump(), headers=logged_in_headers)
assert response.status_code == 201
created_folder = response.json()
folder_id = created_folder["id"]
# Create a component flow in the folder
# Create a component flow in the project
flow_data = orjson.loads(json_flow)
component_flow_name = f"Component Flow {uuid4()}"
component_flow = FlowCreate(
@ -769,9 +769,9 @@ async def test_read_folder_with_component_filter(client: AsyncClient, json_flow:
component_flow.folder_id = folder_id
await client.post("api/v1/flows/", json=component_flow.model_dump(), headers=logged_in_headers)
# Read the folder with component filter
# Read the project with component filter
response = await client.get(
f"api/v1/folders/{folder_id}", headers=logged_in_headers, params={"is_component": True, "page": 1, "size": 10}
f"api/v1/projects/{folder_id}", headers=logged_in_headers, params={"is_component": True, "page": 1, "size": 10}
)
assert response.status_code == 200
folder_data = response.json()