"""Tests for projects API endpoints.
Note: These tests call the underlying endpoint functions directly, bypassing
the require_permission decorator. This allows testing endpoint logic
independently of RBAC. The RBAC permission checking is tested separately
in plugin-specific tests.
If the EE RBAC plugin is linked (via link_ee_plugins.sh), the decorator
wraps functions in an async wrapper. To handle this, tests access the
original unwrapped function via __wrapped__ when available.
"""
import asyncio
import inspect
import uuid
from datetime import datetime, UTC
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from preloop.api.endpoints import projects
from preloop.schemas.project import (
ProjectCreate,
ProjectUpdate,
TestConnectionRequest,
)
def call_endpoint(func, **kwargs):
"""Call an endpoint function, handling both sync and async wrappers.
When the EE RBAC plugin is linked, endpoints are wrapped in an async
decorator. This helper unwraps to get the original sync function,
or runs the async version if needed.
"""
# Try to get the original unwrapped function
original_func = func
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# If original is sync, call it directly
if not inspect.iscoroutinefunction(original_func):
return original_func(**kwargs)
# If we get here, both wrapper and original are async - run it
return asyncio.get_event_loop().run_until_complete(func(**kwargs))
@pytest.fixture
def mock_user():
"""Create a mock user with account_id."""
user = MagicMock()
user.id = uuid.uuid4()
user.account_id = str(uuid.uuid4())
user.username = "testuser"
user.is_active = True
return user
@pytest.fixture
def mock_organization():
"""Create a mock organization."""
org = MagicMock()
org.id = str(uuid.uuid4())
org.name = "Test Organization"
org.identifier = "test-org"
org.description = "A test organization"
org.tracker_id = str(uuid.uuid4())
org.settings = {}
org.meta_data = {}
org.is_active = True
org.tracker = MagicMock()
org.tracker.id = org.tracker_id
org.tracker.tracker_type = "github"
org.tracker.url = "https://github.com"
org.tracker.api_key = "test_key"
org.tracker.connection_details = {}
return org
@pytest.fixture
def mock_project(mock_organization):
"""Create a mock project."""
project = MagicMock()
project.id = str(uuid.uuid4())
project.name = "Test Project"
project.identifier = "test-project"
project.description = "A test project"
project.organization_id = mock_organization.id
project.settings = {}
project.tracker_settings = {"repo": "test/repo"}
project.meta_data = {}
project.is_active = True
project.created_at = datetime.now(UTC)
project.updated_at = datetime.now(UTC)
return project
@pytest.fixture
def mock_db_session():
"""Create a mock database session."""
return MagicMock()
class TestCreateProject:
"""Tests for the create_project endpoint."""
def test_create_project_success(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test successful project creation."""
project_create = ProjectCreate(
name="Test Project",
identifier="test-project",
description="A test project",
organization_id=mock_organization.id,
settings={},
tracker_configurations={"repo": "test/repo"},
)
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(
projects.crud_project, "get_by_identifier", return_value=None
):
with patch.object(
projects.crud_project, "create", return_value=mock_project
):
result = call_endpoint(
projects.create_project,
project=project_create,
db=mock_db_session,
current_user=mock_user,
)
assert result["name"] == "Test Project"
assert result["identifier"] == "test-project"
def test_create_project_organization_not_found(self, mock_user, mock_db_session):
"""Test 404 when organization is not found."""
project_create = ProjectCreate(
name="Test Project",
identifier="test-project",
organization_id=str(uuid.uuid4()),
)
with patch.object(projects.crud_organization, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.create_project,
project=project_create,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Organization not found"
def test_create_project_duplicate_identifier(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test 400 when project identifier already exists."""
project_create = ProjectCreate(
name="Test Project",
identifier="test-project",
organization_id=mock_organization.id,
)
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(
projects.crud_project, "get_by_identifier", return_value=mock_project
):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.create_project,
project=project_create,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 400
assert "already exists" in exc_info.value.detail
class TestListProjects:
"""Tests for the list_projects endpoint."""
def test_list_projects_success(self, mock_user, mock_project, mock_db_session):
"""Test successful project listing."""
with patch.object(
projects, "get_accessible_projects", return_value=[mock_project]
):
result = call_endpoint(
projects.list_projects,
organization_id=None,
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert len(result) == 1
assert result[0]["name"] == "Test Project"
def test_list_projects_with_organization_filter(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test listing with organization filter."""
with patch.object(
projects, "get_accessible_projects", return_value=[mock_project]
):
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
result = call_endpoint(
projects.list_projects,
organization_id=mock_organization.id,
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert len(result) == 1
def test_list_projects_organization_not_found(
self, mock_user, mock_project, mock_db_session
):
"""Test 404 when organization filter is invalid."""
with patch.object(
projects, "get_accessible_projects", return_value=[mock_project]
):
with patch.object(projects.crud_organization, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.list_projects,
organization_id=str(uuid.uuid4()),
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
def test_list_projects_empty(self, mock_user, mock_db_session):
"""Test listing when no projects exist."""
with patch.object(projects, "get_accessible_projects", return_value=[]):
result = call_endpoint(
projects.list_projects,
organization_id=None,
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert len(result) == 0
class TestListOrganizationProjects:
"""Tests for the list_organization_projects endpoint."""
def test_list_organization_projects_success(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test successful listing of organization projects."""
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(
projects, "get_accessible_projects", return_value=[mock_project]
):
result = call_endpoint(
projects.list_organization_projects,
organization_id=mock_organization.id,
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert "items" in result
assert result["total"] == 1
def test_list_organization_projects_not_found(self, mock_user, mock_db_session):
"""Test 404 when organization is not found."""
with patch.object(projects.crud_organization, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.list_organization_projects,
organization_id=str(uuid.uuid4()),
limit=100,
offset=0,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
class TestGetProject:
"""Tests for the get_project endpoint."""
def test_get_project_success(self, mock_user, mock_project, mock_db_session):
"""Test successful project retrieval."""
with patch.object(projects.crud_project, "get", return_value=mock_project):
result = call_endpoint(
projects.get_project,
project_id=mock_project.id,
db=mock_db_session,
current_user=mock_user,
)
assert result["id"] == mock_project.id
assert result["name"] == "Test Project"
def test_get_project_not_found(self, mock_user, mock_db_session):
"""Test 404 when project is not found."""
with patch.object(projects.crud_project, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.get_project,
project_id=str(uuid.uuid4()),
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Project not found"
class TestGetProjectByIdentifier:
"""Tests for the get_project_by_identifier endpoint."""
def test_get_project_by_identifier_success(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test successful project retrieval by identifier."""
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(
projects.crud_project,
"get_by_slug_or_identifier",
return_value=mock_project,
):
result = call_endpoint(
projects.get_project_by_identifier,
organization_id=mock_organization.id,
identifier="test-project",
db=mock_db_session,
current_user=mock_user,
)
assert result["identifier"] == "test-project"
def test_get_project_by_identifier_org_not_found(self, mock_user, mock_db_session):
"""Test 404 when organization is not found."""
with patch.object(projects.crud_organization, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.get_project_by_identifier,
organization_id=str(uuid.uuid4()),
identifier="test-project",
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Organization not found"
def test_get_project_by_identifier_not_found(
self, mock_user, mock_organization, mock_db_session
):
"""Test 404 when project is not found."""
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(
projects.crud_project, "get_by_slug_or_identifier", return_value=None
):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.get_project_by_identifier,
organization_id=mock_organization.id,
identifier="non-existent",
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Project not found"
class TestUpdateProject:
"""Tests for the update_project endpoint."""
def test_update_project_success(self, mock_user, mock_project, mock_db_session):
"""Test successful project update."""
project_update = ProjectUpdate(
name="Updated Project",
description="Updated description",
)
updated_project = MagicMock()
updated_project.id = mock_project.id
updated_project.name = "Updated Project"
updated_project.identifier = mock_project.identifier
updated_project.description = "Updated description"
updated_project.organization_id = mock_project.organization_id
updated_project.settings = {}
updated_project.tracker_settings = {}
updated_project.created_at = datetime.now(UTC)
updated_project.updated_at = datetime.now(UTC)
with patch.object(projects.crud_project, "get", return_value=mock_project):
with patch.object(
projects.crud_project, "update", return_value=updated_project
):
result = call_endpoint(
projects.update_project,
project_id=mock_project.id,
project_update=project_update,
db=mock_db_session,
current_user=mock_user,
)
assert result["name"] == "Updated Project"
def test_update_project_not_found(self, mock_user, mock_db_session):
"""Test 404 when project is not found."""
project_update = ProjectUpdate(name="Updated Project")
with patch.object(projects.crud_project, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.update_project,
project_id=str(uuid.uuid4()),
project_update=project_update,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
def test_update_project_tracker_configurations(
self, mock_user, mock_project, mock_db_session
):
"""Test updating project with tracker_configurations field."""
project_update = ProjectUpdate(
tracker_configurations={"new_repo": "test/new-repo"}
)
updated_project = MagicMock()
updated_project.id = mock_project.id
updated_project.name = mock_project.name
updated_project.identifier = mock_project.identifier
updated_project.description = mock_project.description
updated_project.organization_id = mock_project.organization_id
updated_project.settings = {}
updated_project.tracker_settings = {"new_repo": "test/new-repo"}
updated_project.created_at = datetime.now(UTC)
updated_project.updated_at = datetime.now(UTC)
with patch.object(projects.crud_project, "get", return_value=mock_project):
with patch.object(
projects.crud_project, "update", return_value=updated_project
) as mock_update:
call_endpoint(
projects.update_project,
project_id=mock_project.id,
project_update=project_update,
db=mock_db_session,
current_user=mock_user,
)
# Verify tracker_configurations was mapped to tracker_settings
call_args = mock_update.call_args
update_data = call_args[1]["obj_in"]
assert "tracker_settings" in update_data
class TestDeleteProject:
"""Tests for the delete_project endpoint."""
def test_delete_project_success(self, mock_user, mock_project, mock_db_session):
"""Test successful project deletion."""
with patch.object(projects.crud_project, "get", return_value=mock_project):
with patch.object(projects.crud_project, "delete") as mock_delete:
result = call_endpoint(
projects.delete_project,
project_id=mock_project.id,
db=mock_db_session,
current_user=mock_user,
)
assert result is None
mock_delete.assert_called_once_with(mock_db_session, id=mock_project.id)
def test_delete_project_not_found(self, mock_user, mock_db_session):
"""Test 404 when project is not found."""
with patch.object(projects.crud_project, "get", return_value=None):
with pytest.raises(HTTPException) as exc_info:
call_endpoint(
projects.delete_project,
project_id=str(uuid.uuid4()),
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
class TestTestProjectConnection:
"""Tests for the test_project_connection endpoint."""
@pytest.mark.asyncio
async def test_connection_success(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test successful connection test."""
request = TestConnectionRequest(
organization=mock_organization.id,
project=mock_project.id,
)
mock_connection_result = MagicMock()
mock_connection_result.success = True
mock_connection_result.message = "Connection successful"
mock_connection_result.details = {}
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(projects.crud_project, "get", return_value=mock_project):
with patch.object(
projects, "create_tracker_client", new_callable=AsyncMock
) as mock_create_client:
mock_client = AsyncMock()
mock_client.test_connection.return_value = mock_connection_result
mock_create_client.return_value = mock_client
result = await projects.test_project_connection(
request=request,
db=mock_db_session,
current_user=mock_user,
)
assert result.success is True
assert result.message == "Connection successful"
@pytest.mark.asyncio
async def test_connection_organization_not_found(self, mock_user, mock_db_session):
"""Test 404 when organization is not found."""
request = TestConnectionRequest(
organization=str(uuid.uuid4()),
project=str(uuid.uuid4()),
)
with patch.object(projects.crud_organization, "get", return_value=None):
with patch.object(
projects.crud_organization, "get_by_identifier", return_value=None
):
with pytest.raises(HTTPException) as exc_info:
await projects.test_project_connection(
request=request,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Organization not found"
@pytest.mark.asyncio
async def test_connection_no_tracker(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test failure when organization has no tracker."""
mock_organization.tracker = None
request = TestConnectionRequest(
organization=mock_organization.id,
project=mock_project.id,
)
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(projects.crud_project, "get", return_value=mock_project):
result = await projects.test_project_connection(
request=request,
db=mock_db_session,
current_user=mock_user,
)
assert result.success is False
assert "no associated tracker" in result.message
@pytest.mark.asyncio
async def test_connection_project_not_found(
self, mock_user, mock_organization, mock_db_session
):
"""Test 404 when project is not found."""
request = TestConnectionRequest(
organization=mock_organization.id,
project=str(uuid.uuid4()),
)
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(projects.crud_project, "get", return_value=None):
with patch.object(
projects.crud_project, "get_by_identifier", return_value=None
):
with pytest.raises(HTTPException) as exc_info:
await projects.test_project_connection(
request=request,
db=mock_db_session,
current_user=mock_user,
)
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Project not found"
@pytest.mark.asyncio
async def test_connection_client_creation_error(
self, mock_user, mock_organization, mock_project, mock_db_session
):
"""Test failure when tracker client creation fails."""
request = TestConnectionRequest(
organization=mock_organization.id,
project=mock_project.id,
)
with patch.object(
projects.crud_organization, "get", return_value=mock_organization
):
with patch.object(projects.crud_project, "get", return_value=mock_project):
with patch.object(
projects,
"create_tracker_client",
side_effect=Exception("Client error"),
):
result = await projects.test_project_connection(
request=request,
db=mock_db_session,
current_user=mock_user,
)
assert result.success is False
assert "error" in result.details