diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index 17ab72c0..cf783746 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -13,6 +13,7 @@ import logging import os import shutil +import sqlite3 from codeframe.core.models import ( ProjectStatus, @@ -326,21 +327,34 @@ async def create_project(request: ProjectCreateRequest): ) # Check for duplicate project name - existing_projects = app.state.db.list_projects() + try: + existing_projects = app.state.db.list_projects() + except sqlite3.Error as e: + logger.error(f"Database error listing projects: {str(e)}") + raise HTTPException( + status_code=500, detail="Database error occurred. Please try again later." + ) + if any(p["name"] == request.name for p in existing_projects): raise HTTPException( status_code=409, detail=f"Project with name '{request.name}' already exists" ) # Create project record first (to get ID) - project_id = app.state.db.create_project( - name=request.name, - description=request.description, - source_type=request.source_type.value, - source_location=request.source_location, - source_branch=request.source_branch, - workspace_path="", # Will be updated after workspace creation - ) + try: + project_id = app.state.db.create_project( + name=request.name, + description=request.description, + source_type=request.source_type.value, + source_location=request.source_location, + source_branch=request.source_branch, + workspace_path="", # Will be updated after workspace creation + ) + except sqlite3.Error as e: + logger.error(f"Database error creating project: {str(e)}") + raise HTTPException( + status_code=500, detail="Database error occurred. Please try again later." + ) # Create workspace try: @@ -352,13 +366,45 @@ async def create_project(request: ProjectCreateRequest): ) # Update project with workspace path and git status - app.state.db.update_project( - project_id, {"workspace_path": str(workspace_path), "git_initialized": True} - ) + try: + app.state.db.update_project( + project_id, {"workspace_path": str(workspace_path), "git_initialized": True} + ) + except sqlite3.Error as db_error: + # Database error during update - cleanup and fail + logger.error(f"Database error updating project {project_id}: {db_error}") + + # Best-effort cleanup: delete project record + try: + app.state.db.delete_project(project_id) + except sqlite3.Error as cleanup_db_error: + logger.error(f"Failed to delete project {project_id} during cleanup: {cleanup_db_error}") + + # Best-effort cleanup: remove workspace directory (use actual workspace_path) + if workspace_path.exists(): + try: + shutil.rmtree(workspace_path) + logger.info(f"Cleaned up workspace directory: {workspace_path}") + except (OSError, PermissionError) as cleanup_fs_error: + logger.error(f"Failed to clean up workspace {workspace_path}: {cleanup_fs_error}") + + raise HTTPException( + status_code=500, detail="Database error occurred. Please try again later." + ) + + except HTTPException: + # Re-raise HTTPException from database error handling above + raise except Exception as e: # Cleanup: delete project and workspace if creation fails - app.state.db.delete_project(project_id) + logger.error(f"Workspace creation failed for project {project_id}: {e}") + + # Best-effort cleanup: delete project record + try: + app.state.db.delete_project(project_id) + except sqlite3.Error as cleanup_db_error: + logger.error(f"Failed to delete project {project_id} during cleanup: {cleanup_db_error}") # Explicitly clean up workspace directory if it exists # (Defense in depth: WorkspaceManager has cleanup, but this ensures @@ -368,13 +414,21 @@ async def create_project(request: ProjectCreateRequest): try: shutil.rmtree(workspace_path) logger.info(f"Cleaned up orphaned workspace: {workspace_path}") - except Exception as cleanup_error: + except (OSError, PermissionError) as cleanup_error: logger.error(f"Failed to clean up workspace {workspace_path}: {cleanup_error}") - raise HTTPException(status_code=500, detail=f"Workspace creation failed: {str(e)}") + raise HTTPException( + status_code=500, detail="Workspace creation failed. Please try again later." + ) # Return project details - project = app.state.db.get_project(project_id) + try: + project = app.state.db.get_project(project_id) + except sqlite3.Error as e: + logger.error(f"Database error retrieving project {project_id}: {str(e)}") + raise HTTPException( + status_code=500, detail="Database error occurred. Please try again later." + ) return ProjectResponse( id=project["id"], diff --git a/tests/api/test_project_creation_api.py b/tests/api/test_project_creation_api.py index f384fca1..3c9242c3 100644 --- a/tests/api/test_project_creation_api.py +++ b/tests/api/test_project_creation_api.py @@ -9,6 +9,9 @@ 3. REFACTOR: Clean up while keeping tests green """ +import sqlite3 +from unittest.mock import patch + import pytest @@ -212,13 +215,119 @@ def test_create_project_via_api_then_get_status(self, api_client): class TestProjectCreationErrorHandling: """Test error handling for project creation API.""" - @pytest.mark.skip( - reason="Database close() creates ungraceful crashes, not 500 errors. This test design is flawed." - ) - def test_create_project_handles_database_errors(self, api_client): - """Test that database errors are handled gracefully (500 Internal Server Error).""" - # This test is skipped - see reason above - pass + def test_create_project_database_locked_error(self, api_client): + """Test that database locked error returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "create_project", + side_effect=sqlite3.OperationalError("database is locked"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-db-locked", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() + + def test_create_project_disk_full_error(self, api_client): + """Test that disk I/O error returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "create_project", + side_effect=sqlite3.OperationalError("disk I/O error"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-disk-full", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() or "i/o" in data["detail"].lower() + + def test_create_project_integrity_error(self, api_client): + """Test that constraint violation error returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "create_project", + side_effect=sqlite3.IntegrityError("UNIQUE constraint failed"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-integrity", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() or "constraint" in data["detail"].lower() + + def test_create_project_list_projects_database_error(self, api_client): + """Test that database error during list_projects returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "list_projects", + side_effect=sqlite3.OperationalError("database is locked"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-list-error", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() + + def test_create_project_update_project_database_error(self, api_client): + """Test that database error during update_project returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "update_project", + side_effect=sqlite3.OperationalError("database is locked"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-update-error", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() + + def test_create_project_get_project_database_error(self, api_client): + """Test that database error during get_project returns 500 Internal Server Error.""" + from codeframe.ui import server + + with patch.object( + server.app.state.db, + "get_project", + side_effect=sqlite3.OperationalError("database is locked"), + ): + response = api_client.post( + "/api/projects", + json={"name": "test-get-error", "description": "Test project"}, + ) + + assert response.status_code == 500 + data = response.json() + assert "detail" in data + assert "database" in data["detail"].lower() def test_create_project_with_extra_fields(self, api_client): """Test that extra fields in request are ignored."""