diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a3094612..c392db35 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -12,6 +12,7 @@ {"id":"codeframe-17","title":"codeframe-14.2: Frontend Chat Component","description":"Build React chat component with message input, display area, auto-scroll, typing indicators, and error handling. Include WebSocket connection for real-time updates. Style with Tailwind CSS matching dashboard design.","acceptance_criteria":"Component renders messages, input sends to API, WebSocket receives updates, UI matches design, 7 tests passing","notes":"codeframe-14.2 complete: Frontend ChatInterface.tsx component (227 lines) with message history, real-time WebSocket updates, loading states, optimistic UI. TypeScript 0 errors. 8 test specs documented.","status":"closed","priority":0,"issue_type":"feature","created_at":"2025-10-16T16:19:17.061435037-07:00","updated_at":"2025-10-16T22:52:10.170827311-07:00","closed_at":"2025-10-16T22:52:10.170827311-07:00","labels":["frontend","p0","sprint-2"],"dependencies":[{"issue_id":"codeframe-17","depends_on_id":"codeframe-16","type":"blocks","created_at":"2025-10-16T16:19:17.062318143-07:00","created_by":"frankbria"}]} {"id":"codeframe-18","title":"codeframe-14.3: Message Persistence","description":"Implement database schema and operations for chat message persistence. Create messages table with fields: id, project_id, role (user/assistant), content, timestamp. Add CRUD operations and database integration tests.","acceptance_criteria":"Messages table created, messages persist across sessions, queries work efficiently, 5 tests passing","notes":"codeframe-14.3 complete: Message persistence using memory table with role (user/assistant) and timestamps. Pagination support, chronological ordering (ORDER BY id). Covered in test_chat_api.py tests.","status":"closed","priority":0,"issue_type":"feature","created_at":"2025-10-16T16:19:22.522108744-07:00","updated_at":"2025-10-16T22:52:10.248013626-07:00","closed_at":"2025-10-16T22:52:10.248013626-07:00","labels":["backend","database","p0","sprint-2"],"dependencies":[{"issue_id":"codeframe-18","depends_on_id":"codeframe-16","type":"blocks","created_at":"2025-10-16T16:19:22.52316183-07:00","created_by":"frankbria"}]} {"id":"codeframe-19","title":"codeframe-15: Socratic Discovery Flow","description":"Implement Socratic discovery methodology: question framework generation, answer capture with structured metadata, Lead Agent integration for intelligent follow-ups. Enable conversational requirements gathering through progressive questioning.","acceptance_criteria":"Discovery questions generated, answers captured with structure, Lead Agent adapts questions, conversation flows naturally, 30 tests passing","notes":"codeframe-15 (Socratic Discovery Flow) complete. Discovery question framework (codeframe-15.1), answer capture \u0026 structuring (codeframe-15.2), and Lead Agent integration (codeframe-15.3) all implemented. 72 tests passing (100% pass rate), \u003e95% coverage. Multi-agent parallel execution with TDD.","status":"closed","priority":0,"issue_type":"feature","created_at":"2025-10-16T16:19:26.802269208-07:00","updated_at":"2025-10-16T22:51:50.590222809-07:00","closed_at":"2025-10-16T22:51:50.590222809-07:00","labels":["ai","full-stack","p0","sprint-2"]} +{"id":"codeframe-1j32","title":"Make sprint number configurable in planning automation","description":"Planning automation hardcodes sprint_number=1 in discovery.py:193. Should derive from project state or be configurable for multi-sprint support. See GitHub #210.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T21:09:24.624768065-07:00","updated_at":"2026-01-06T21:09:24.624768065-07:00","labels":["enhancement","planning"]} {"id":"codeframe-1vn","title":"T003: Add Blocker Pydantic models","description":"Add Blocker, BlockerCreate, BlockerResolve Pydantic models to codeframe/core/models.py","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-08T19:20:00.048395471-07:00","updated_at":"2025-11-14T10:48:48.464327998-07:00","closed_at":"2025-11-14T10:48:48.464327998-07:00","close_reason":"Sprint 6 (049-human-in-loop) complete - merged in PR #18"} {"id":"codeframe-1ww","title":"Context Engineering Improvements","description":"Collection of context window optimization and management improvements. See GitHub issues #62-73.","status":"open","priority":2,"issue_type":"epic","created_at":"2026-01-06T17:26:16.851552468-07:00","updated_at":"2026-01-06T17:26:16.851552468-07:00","labels":["architecture","context-engineering"]} {"id":"codeframe-1ww.1","title":"Evaluate Just-in-Time Context Loading Strategy","description":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T17:26:30.548499298-07:00","updated_at":"2026-01-06T17:26:30.548499298-07:00","external_ref":"gh-62","labels":["context-engineering"],"dependencies":[{"issue_id":"codeframe-1ww.1","depends_on_id":"codeframe-1ww","type":"parent-child","created_at":"2026-01-06T17:26:30.54903234-07:00","created_by":"daemon"}]} diff --git a/codeframe/ui/routers/discovery.py b/codeframe/ui/routers/discovery.py index 597e1260..528c3c25 100644 --- a/codeframe/ui/routers/discovery.py +++ b/codeframe/ui/routers/discovery.py @@ -4,6 +4,7 @@ allowing submission of discovery answers and retrieval of discovery progress. """ +import asyncio import os import logging from typing import Dict, Any @@ -17,6 +18,13 @@ from codeframe.auth.dependencies import get_current_user from codeframe.auth.models import User from codeframe.ui.shared import manager +from codeframe.ui.websocket_broadcasts import ( + broadcast_planning_started, + broadcast_issues_generated, + broadcast_tasks_decomposed, + broadcast_tasks_ready, + broadcast_planning_failed, +) # Module logger logger = logging.getLogger(__name__) @@ -33,14 +41,13 @@ async def generate_prd_background(project_id: int, db: Database, api_key: str): 3. prd_generation_progress (calling_llm) - Sending to Claude API 4. prd_generation_progress (saving) - Saving PRD to database/file 5. prd_generation_completed - Final notification + 6. Spawns planning automation as a separate async task Args: project_id: Project ID db: Database instance api_key: API key for Claude """ - import asyncio - async def broadcast_progress(stage: str, message: str, progress_pct: int = 0): """Helper to broadcast progress updates.""" await manager.broadcast( @@ -127,8 +134,21 @@ async def broadcast_progress(stage: str, message: str, progress_pct: int = 0): project_id=project_id, ) + # Trigger planning automation as a non-blocking background task + # This allows PRD completion to be reported immediately while planning runs + def _handle_planning_exception(task: asyncio.Task) -> None: + """Log any unhandled exceptions from background planning task.""" + if not task.cancelled() and task.exception(): + logger.error( + f"Unhandled exception in planning background task: {task.exception()}", + exc_info=task.exception() + ) + + planning_task = asyncio.create_task(generate_planning_background(project_id, db, api_key)) + planning_task.add_done_callback(_handle_planning_exception) + except Exception as e: - logger.error(f"Failed to generate PRD for project {project_id}: {e}") + logger.error(f"Failed to generate PRD for project {project_id}: {e}", exc_info=True) # Broadcast error await manager.broadcast( { @@ -141,6 +161,78 @@ async def broadcast_progress(stage: str, message: str, progress_pct: int = 0): ) +async def generate_planning_background(project_id: int, db: Database, api_key: str): + """Background task to generate issues and tasks after PRD completion. + + This function implements planning automation: + 1. planning_started - Notify automation begins + 2. generate_issues - Create issues from PRD + 3. issues_generated - Report issues created + 4. decompose_prd - Decompose issues into tasks + 5. tasks_decomposed - Report tasks created + 6. tasks_ready - Signal ready for user review + + Note: LeadAgent methods are synchronous and use the sync Anthropic client, + so asyncio.to_thread() is appropriate for running them without blocking. + + Args: + project_id: Project ID + db: Database instance + api_key: API key for Claude + """ + # Configurable timeout for AI operations (default 2 minutes per operation) + planning_timeout = float(os.environ.get("PLANNING_OPERATION_TIMEOUT", "120")) + + try: + logger.info(f"Starting planning automation for project {project_id}") + + # Stage 1: Broadcast planning started + await broadcast_planning_started(manager, project_id) + + # Initialize LeadAgent for issue/task generation + agent = LeadAgent(project_id=project_id, db=db, api_key=api_key) + + # Stage 2: Generate issues from PRD (with timeout) + logger.info(f"Generating issues for project {project_id}") + issues = await asyncio.wait_for( + asyncio.to_thread(agent.generate_issues, sprint_number=1), + timeout=planning_timeout + ) + issue_count = len(issues) if issues else 0 + + # Stage 3: Broadcast issues generated + await broadcast_issues_generated(manager, project_id, issue_count) + logger.info(f"Generated {issue_count} issues for project {project_id}") + + # Stage 4: Decompose PRD into tasks (with timeout) + logger.info(f"Decomposing PRD into tasks for project {project_id}") + decomposition_result = await asyncio.wait_for( + asyncio.to_thread(agent.decompose_prd), + timeout=planning_timeout + ) + task_count = decomposition_result.get("tasks", 0) if decomposition_result else 0 + + # Stage 5: Broadcast tasks decomposed + await broadcast_tasks_decomposed(manager, project_id, task_count) + logger.info(f"Decomposed into {task_count} tasks for project {project_id}") + + # Stage 6: Broadcast tasks ready for review + await broadcast_tasks_ready(manager, project_id, task_count) + logger.info(f"Planning automation completed for project {project_id}") + + except asyncio.TimeoutError: + logger.error( + f"Planning automation timed out for project {project_id} " + f"(timeout={planning_timeout}s)", + exc_info=True + ) + await broadcast_planning_failed(manager, project_id, "Planning operation timed out") + + except Exception as e: + logger.error(f"Planning automation failed for project {project_id}: {e}", exc_info=True) + await broadcast_planning_failed(manager, project_id, str(e)) + + @router.post("/answer") async def submit_discovery_answer( project_id: int, diff --git a/codeframe/ui/routers/tasks.py b/codeframe/ui/routers/tasks.py index 5c0fe6e0..2ef952be 100644 --- a/codeframe/ui/routers/tasks.py +++ b/codeframe/ui/routers/tasks.py @@ -4,17 +4,21 @@ - Task creation - Task updates - Task status management +- Task approval (for planning phase automation) """ import logging -from typing import Optional +from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from codeframe.core.models import Task, TaskStatus +from codeframe.core.phase_manager import PhaseManager from codeframe.persistence.database import Database from codeframe.ui.dependencies import get_db +from codeframe.ui.shared import manager +from codeframe.ui.websocket_broadcasts import broadcast_development_started from codeframe.auth.dependencies import get_current_user from codeframe.auth.models import User @@ -22,6 +26,9 @@ router = APIRouter(prefix="/api/tasks", tags=["tasks"]) +# Also register under project-scoped prefix for task approval +project_router = APIRouter(prefix="/api/projects", tags=["tasks"]) + class TaskCreateRequest(BaseModel): """Request model for creating a task.""" @@ -103,3 +110,135 @@ async def create_task( except Exception as e: logger.error(f"Error creating task: {e}", exc_info=True) raise HTTPException(status_code=500, detail="Error creating task") + + +# ============================================================================ +# Task Approval Models and Endpoint (Feature: 016-planning-phase-automation) +# ============================================================================ + + +class TaskApprovalRequest(BaseModel): + """Request model for task approval.""" + approved: bool + excluded_task_ids: List[int] = Field(default_factory=list) + + +class TaskApprovalResponse(BaseModel): + """Response model for task approval.""" + success: bool + phase: str + approved_count: int + excluded_count: int + message: str + + +@project_router.post("/{project_id}/tasks/approve") +async def approve_tasks( + project_id: int, + request: TaskApprovalRequest, + db: Database = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> TaskApprovalResponse: + """Approve tasks and transition project to development phase. + + This endpoint allows users to approve generated tasks after reviewing them. + Approved tasks are updated to 'pending' status and the project phase + transitions to 'active' (development). + + Args: + project_id: Project ID + request: Approval request with approved flag and optional exclusions + db: Database connection + current_user: Authenticated user + + Returns: + TaskApprovalResponse with summary of approval + + Raises: + HTTPException: + - 400: Project not in planning phase + - 403: Access denied + - 404: Project or tasks not found + """ + # Verify project exists + project = db.get_project(project_id) + if not project: + raise HTTPException( + status_code=404, + detail=f"Project {project_id} not found" + ) + + # Authorization check + if not db.user_has_project_access(current_user.id, project_id): + raise HTTPException(status_code=403, detail="Access denied") + + # Check if user is rejecting + if not request.approved: + return TaskApprovalResponse( + success=False, + phase=project.get("phase", "planning"), + approved_count=0, + excluded_count=0, + message="Tasks were not approved. Please review and modify tasks before approving." + ) + + # Validate project is in planning phase + current_phase = project.get("phase", "discovery") + if current_phase != "planning": + raise HTTPException( + status_code=400, + detail=f"Project must be in planning phase to approve tasks. Current phase: {current_phase}" + ) + + # Get all tasks for the project + tasks = db.get_project_tasks(project_id) + if not tasks: + raise HTTPException( + status_code=404, + detail="No tasks found for this project. Generate tasks before approving." + ) + + # Separate approved and excluded tasks + # Note: Excluded tasks remain unchanged in the database for audit trail. + # They are not deleted or modified - users can re-include them later if needed. + excluded_ids = set(request.excluded_task_ids) + approved_tasks = [t for t in tasks if t.id not in excluded_ids] + excluded_tasks = [t for t in tasks if t.id in excluded_ids] + + # Transition project phase to active FIRST (fails early before modifying tasks) + # This ensures we don't leave tasks in pending status if phase transition fails + try: + PhaseManager.transition(project_id, "active", db) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to transition phase for project {project_id}: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail="Failed to transition project to development phase" + ) + + # Update approved tasks to pending status (after phase transition succeeds) + for task in approved_tasks: + db.update_task(task.id, {"status": "pending"}) + + # Broadcast development started event + await broadcast_development_started( + manager=manager, + project_id=project_id, + approved_count=len(approved_tasks), + excluded_count=len(excluded_tasks), + ) + + logger.info( + f"Tasks approved for project {project_id}: " + f"{len(approved_tasks)} approved, {len(excluded_tasks)} excluded" + ) + + return TaskApprovalResponse( + success=True, + phase="active", + approved_count=len(approved_tasks), + excluded_count=len(excluded_tasks), + message=f"Successfully approved {len(approved_tasks)} tasks. Development phase started." + ) diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index 8652eef9..6f5e6334 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -337,6 +337,7 @@ async def test_broadcast(message: dict, project_id: int = None): app.include_router(review.router) app.include_router(session.router) app.include_router(tasks.router) +app.include_router(tasks.project_router) app.include_router(websocket.router) app.include_router(auth_router.router) diff --git a/codeframe/ui/websocket_broadcasts.py b/codeframe/ui/websocket_broadcasts.py index 73b8803c..e0cf448a 100644 --- a/codeframe/ui/websocket_broadcasts.py +++ b/codeframe/ui/websocket_broadcasts.py @@ -784,3 +784,163 @@ async def broadcast_discovery_completed( logger.debug(f"Broadcast discovery_completed: {total_answers} answers") except Exception as e: logger.error(f"Failed to broadcast discovery completion: {e}") + + +# ============================================================================ +# Planning Phase Automation Broadcasts (Feature: 016-planning-phase-automation) +# ============================================================================ + + +async def broadcast_planning_started(manager, project_id: int) -> None: + """ + Broadcast when planning automation begins after PRD completion. + + Args: + manager: ConnectionManager instance + project_id: Project ID + """ + message = { + "type": "planning_started", + "project_id": project_id, + "status": "in_progress", + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug(f"Broadcast planning_started for project {project_id}") + except Exception as e: + logger.error(f"Failed to broadcast planning started: {e}") + + +async def broadcast_issues_generated( + manager, project_id: int, issue_count: int +) -> None: + """ + Broadcast when issues have been generated from PRD. + + Args: + manager: ConnectionManager instance + project_id: Project ID + issue_count: Number of issues generated + """ + message = { + "type": "issues_generated", + "project_id": project_id, + "issue_count": issue_count, + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug(f"Broadcast issues_generated: {issue_count} issues for project {project_id}") + except Exception as e: + logger.error(f"Failed to broadcast issues generated: {e}") + + +async def broadcast_tasks_decomposed( + manager, project_id: int, task_count: int +) -> None: + """ + Broadcast when tasks have been decomposed from issues. + + Args: + manager: ConnectionManager instance + project_id: Project ID + task_count: Number of tasks created + """ + message = { + "type": "tasks_decomposed", + "project_id": project_id, + "task_count": task_count, + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug(f"Broadcast tasks_decomposed: {task_count} tasks for project {project_id}") + except Exception as e: + logger.error(f"Failed to broadcast tasks decomposed: {e}") + + +async def broadcast_tasks_ready( + manager, project_id: int, total_tasks: int +) -> None: + """ + Broadcast when all tasks are ready for user review. + + Args: + manager: ConnectionManager instance + project_id: Project ID + total_tasks: Total number of tasks ready for review + """ + message = { + "type": "tasks_ready", + "project_id": project_id, + "total_tasks": total_tasks, + "status": "ready_for_review", + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug(f"Broadcast tasks_ready: {total_tasks} tasks for project {project_id}") + except Exception as e: + logger.error(f"Failed to broadcast tasks ready: {e}") + + +async def broadcast_planning_failed( + manager, project_id: int, error: str +) -> None: + """ + Broadcast when planning automation fails. + + Args: + manager: ConnectionManager instance + project_id: Project ID + error: Error message describing the failure + """ + message = { + "type": "planning_failed", + "project_id": project_id, + "status": "failed", + "error": error, + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug(f"Broadcast planning_failed for project {project_id}: {error}") + except Exception as e: + logger.error(f"Failed to broadcast planning failed: {e}") + + +async def broadcast_development_started( + manager, project_id: int, approved_count: int, excluded_count: int +) -> None: + """ + Broadcast when development phase starts after task approval. + + Args: + manager: ConnectionManager instance + project_id: Project ID + approved_count: Number of tasks approved + excluded_count: Number of tasks excluded from approval + """ + message = { + "type": "development_started", + "project_id": project_id, + "approved_count": approved_count, + "excluded_count": excluded_count, + "phase": "active", + "timestamp": datetime.now(UTC).isoformat().replace("+00:00", "Z"), + } + + try: + await manager.broadcast(message, project_id=project_id) + logger.debug( + f"Broadcast development_started for project {project_id}: " + f"{approved_count} approved, {excluded_count} excluded" + ) + except Exception as e: + logger.error(f"Failed to broadcast development started: {e}") diff --git a/tests/ui/test_discovery_automation.py b/tests/ui/test_discovery_automation.py new file mode 100644 index 00000000..84a3f772 --- /dev/null +++ b/tests/ui/test_discovery_automation.py @@ -0,0 +1,362 @@ +""" +Tests for planning automation triggered after PRD generation (Feature: 016-planning-phase-automation). + +These tests verify: +- Planning automation is triggered after PRD completion +- WebSocket events are broadcast at each stage +- Error handling and retry capability +- Proper sequencing of generate_issues and decompose_prd +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from codeframe.ui.routers.discovery import generate_planning_background + + +@pytest.fixture +def mock_manager(): + """Create mock ConnectionManager.""" + manager = MagicMock() + manager.broadcast = AsyncMock() + return manager + + +@pytest.fixture +def mock_db(): + """Create mock Database.""" + db = MagicMock() + db.get_project.return_value = {"id": 1, "name": "Test Project", "phase": "planning"} + return db + + +@pytest.fixture +def mock_lead_agent(): + """Create mock LeadAgent that returns successful results.""" + agent = MagicMock() + # Mock generate_issues to return a list of issues + agent.generate_issues.return_value = [ + MagicMock(id=1, title="Issue 1"), + MagicMock(id=2, title="Issue 2"), + MagicMock(id=3, title="Issue 3"), + ] + # Mock decompose_prd to return task count info + agent.decompose_prd.return_value = { + "issues": 3, + "tasks": 10, + "success": True, + } + return agent + + +class TestPlanningAutomationBackgroundTask: + """Tests for generate_planning_background function.""" + + @pytest.mark.asyncio + async def test_planning_automation_broadcasts_started_event( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that planning_started event is broadcast when automation begins.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + # Find the planning_started broadcast call + calls = mock_manager.broadcast.call_args_list + planning_started_calls = [ + call for call in calls + if call[0][0].get("type") == "planning_started" + ] + + assert len(planning_started_calls) == 1 + message = planning_started_calls[0][0][0] + assert message["type"] == "planning_started" + assert message["project_id"] == 1 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_planning_automation_calls_generate_issues( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that generate_issues is called with sprint_number=1.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + mock_lead_agent.generate_issues.assert_called_once_with(sprint_number=1) + + @pytest.mark.asyncio + async def test_planning_automation_broadcasts_issues_generated_event( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that issues_generated event is broadcast with issue count.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + issues_generated_calls = [ + call for call in calls + if call[0][0].get("type") == "issues_generated" + ] + + assert len(issues_generated_calls) == 1 + message = issues_generated_calls[0][0][0] + assert message["type"] == "issues_generated" + assert message["project_id"] == 1 + assert message["issue_count"] == 3 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_planning_automation_calls_decompose_prd( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that decompose_prd is called after generate_issues.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + mock_lead_agent.decompose_prd.assert_called_once() + + @pytest.mark.asyncio + async def test_planning_automation_broadcasts_tasks_decomposed_event( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that tasks_decomposed event is broadcast with task count.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + tasks_decomposed_calls = [ + call for call in calls + if call[0][0].get("type") == "tasks_decomposed" + ] + + assert len(tasks_decomposed_calls) == 1 + message = tasks_decomposed_calls[0][0][0] + assert message["type"] == "tasks_decomposed" + assert message["project_id"] == 1 + assert message["task_count"] == 10 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_planning_automation_broadcasts_tasks_ready_event( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that tasks_ready event is broadcast when automation completes.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + tasks_ready_calls = [ + call for call in calls + if call[0][0].get("type") == "tasks_ready" + ] + + assert len(tasks_ready_calls) == 1 + message = tasks_ready_calls[0][0][0] + assert message["type"] == "tasks_ready" + assert message["project_id"] == 1 + assert message["total_tasks"] == 10 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_planning_automation_all_events_in_order( + self, mock_db, mock_lead_agent, mock_manager + ): + """Test that all WebSocket events are broadcast in correct order.""" + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_lead_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + event_types = [call[0][0].get("type") for call in calls] + + # Verify order: planning_started → issues_generated → tasks_decomposed → tasks_ready + expected_order = ["planning_started", "issues_generated", "tasks_decomposed", "tasks_ready"] + assert event_types == expected_order + + +class TestPlanningAutomationErrorHandling: + """Tests for error handling in planning automation.""" + + @pytest.mark.asyncio + async def test_issues_generation_error_broadcasts_failure( + self, mock_db, mock_manager + ): + """Test that planning_failed event is broadcast when generate_issues fails.""" + mock_agent = MagicMock() + mock_agent.generate_issues.side_effect = Exception("API Error") + + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + failure_calls = [ + call for call in calls + if call[0][0].get("type") == "planning_failed" + ] + + assert len(failure_calls) == 1 + message = failure_calls[0][0][0] + assert message["type"] == "planning_failed" + assert message["project_id"] == 1 + assert "error" in message + assert "API Error" in message["error"] + + @pytest.mark.asyncio + async def test_decompose_prd_error_broadcasts_failure( + self, mock_db, mock_manager + ): + """Test that planning_failed event is broadcast when decompose_prd fails.""" + mock_agent = MagicMock() + mock_agent.generate_issues.return_value = [MagicMock(id=1)] + mock_agent.decompose_prd.side_effect = Exception("Task decomposition failed") + + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + failure_calls = [ + call for call in calls + if call[0][0].get("type") == "planning_failed" + ] + + assert len(failure_calls) == 1 + message = failure_calls[0][0][0] + assert message["type"] == "planning_failed" + assert "Task decomposition failed" in message["error"] + + @pytest.mark.asyncio + async def test_error_does_not_update_project_phase( + self, mock_db, mock_manager + ): + """Test that project phase is not updated when planning fails.""" + mock_agent = MagicMock() + mock_agent.generate_issues.side_effect = Exception("API Error") + + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + # Phase should not be updated on error + mock_db.update_project.assert_not_called() + + @pytest.mark.asyncio + async def test_generate_issues_timeout_broadcasts_failure( + self, mock_db, mock_manager + ): + """Test that timeout during generate_issues broadcasts planning_failed.""" + import asyncio + + mock_agent = MagicMock() + # Simulate a slow operation that will timeout + async def slow_generate_issues(*args, **kwargs): + await asyncio.sleep(10) # Longer than timeout + return [] + + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent), \ + patch("codeframe.ui.routers.discovery.asyncio.to_thread", side_effect=slow_generate_issues), \ + patch.dict("os.environ", {"PLANNING_OPERATION_TIMEOUT": "0.1"}): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + failure_calls = [ + call for call in calls + if call[0][0].get("type") == "planning_failed" + ] + + assert len(failure_calls) == 1 + message = failure_calls[0][0][0] + assert message["type"] == "planning_failed" + assert "timed out" in message["error"].lower() + + @pytest.mark.asyncio + async def test_decompose_prd_timeout_broadcasts_failure( + self, mock_db, mock_manager + ): + """Test that timeout during decompose_prd broadcasts planning_failed.""" + import asyncio + + mock_agent = MagicMock() + mock_agent.generate_issues.return_value = [MagicMock(id=1)] + + call_count = 0 + + async def conditional_slow(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + # First call (generate_issues) returns quickly + return [MagicMock(id=1)] + else: + # Second call (decompose_prd) times out + await asyncio.sleep(10) + return {} + + with patch("codeframe.ui.routers.discovery.manager", mock_manager), \ + patch("codeframe.ui.routers.discovery.LeadAgent", return_value=mock_agent), \ + patch("codeframe.ui.routers.discovery.asyncio.to_thread", side_effect=conditional_slow), \ + patch.dict("os.environ", {"PLANNING_OPERATION_TIMEOUT": "0.1"}): + await generate_planning_background( + project_id=1, db=mock_db, api_key="test-key" + ) + + calls = mock_manager.broadcast.call_args_list + failure_calls = [ + call for call in calls + if call[0][0].get("type") == "planning_failed" + ] + + assert len(failure_calls) == 1 + message = failure_calls[0][0][0] + assert "timed out" in message["error"].lower() + + +class TestPRDCompletionTrigger: + """Tests for planning automation trigger after PRD completion.""" + + @pytest.mark.asyncio + async def test_prd_completion_triggers_planning_automation(self): + """Test that planning automation is triggered after PRD completion.""" + # This test verifies the integration point in generate_prd_background + # We'll test that the function signature supports BackgroundTasks + from codeframe.ui.routers.discovery import generate_prd_background + import inspect + + # Check that the function can accept background_tasks parameter + # (This will be added as part of the implementation) + sig = inspect.signature(generate_prd_background) + params = list(sig.parameters.keys()) + + # The function should have project_id, db, api_key parameters + assert "project_id" in params + assert "db" in params + assert "api_key" in params diff --git a/tests/ui/test_task_approval.py b/tests/ui/test_task_approval.py new file mode 100644 index 00000000..e6a593e2 --- /dev/null +++ b/tests/ui/test_task_approval.py @@ -0,0 +1,670 @@ +""" +Tests for task approval endpoint (Feature: 016-planning-phase-automation). + +These tests verify: +- Task approval transitions project to development phase +- Approved tasks are updated to pending status +- Excluded tasks remain unchanged +- Validation errors for wrong phase +- WebSocket events are broadcast +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from codeframe.core.models import Task, TaskStatus +from codeframe.ui.routers.tasks import approve_tasks, TaskApprovalRequest + + +@pytest.fixture +def mock_db(): + """Create mock Database with project and tasks.""" + db = MagicMock() + db.get_project.return_value = { + "id": 1, + "name": "Test Project", + "phase": "planning" + } + db.user_has_project_access.return_value = True + + # Mock tasks (using PENDING status which is valid for planning phase) + mock_tasks = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), + Task(id=3, project_id=1, title="Task 3", status=TaskStatus.PENDING), + ] + db.get_project_tasks.return_value = mock_tasks + db.update_task.return_value = None + db.update_project.return_value = None + + return db + + +@pytest.fixture +def mock_user(): + """Create mock authenticated user.""" + user = MagicMock() + user.id = 1 + user.email = "test@example.com" + return user + + +@pytest.fixture +def mock_manager(): + """Create mock ConnectionManager.""" + manager = MagicMock() + manager.broadcast = AsyncMock() + return manager + + +class TestTaskApprovalEndpoint: + """Tests for POST /api/projects/{project_id}/tasks/approve.""" + + @pytest.mark.asyncio + async def test_approve_tasks_returns_success_response( + self, mock_db, mock_user, mock_manager + ): + """Test that approving tasks returns success response with summary.""" + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + response = await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert response.success is True + assert response.phase == "active" + assert response.approved_count == 3 + assert response.excluded_count == 0 + + @pytest.mark.asyncio + async def test_approve_tasks_with_exclusions( + self, mock_db, mock_user, mock_manager + ): + """Test that excluded tasks are not approved.""" + request = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + response = await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert response.approved_count == 1 + assert response.excluded_count == 2 + + @pytest.mark.asyncio + async def test_approve_tasks_updates_task_status_to_pending( + self, mock_db, mock_user, mock_manager + ): + """Test that approved tasks are updated to pending status.""" + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + # Verify update_task was called for each task + assert mock_db.update_task.call_count == 3 + # Verify each call updates status to pending + for call in mock_db.update_task.call_args_list: + task_id, updates = call[0] + assert updates.get("status") == "pending" + + @pytest.mark.asyncio + async def test_approve_tasks_transitions_phase_to_active( + self, mock_db, mock_user, mock_manager + ): + """Test that project phase is transitioned to active.""" + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager") as mock_phase_manager: + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + # Verify PhaseManager.transition was called + mock_phase_manager.transition.assert_called_once_with(1, "active", mock_db) + + @pytest.mark.asyncio + async def test_approve_tasks_broadcasts_development_started( + self, mock_db, mock_user, mock_manager + ): + """Test that development_started event is broadcast.""" + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + # Find the development_started broadcast + calls = mock_manager.broadcast.call_args_list + development_started_calls = [ + call for call in calls + if call[0][0].get("type") == "development_started" + ] + + assert len(development_started_calls) == 1 + message = development_started_calls[0][0][0] + assert message["type"] == "development_started" + assert message["project_id"] == 1 + assert message["approved_count"] == 3 + assert message["excluded_count"] == 0 + + @pytest.mark.asyncio + async def test_reject_tasks_returns_rejection_message( + self, mock_db, mock_user, mock_manager + ): + """Test that rejecting tasks returns rejection response.""" + request = TaskApprovalRequest(approved=False, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager): + response = await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert response.success is False + assert "not approved" in response.message.lower() + + +class TestTaskApprovalValidation: + """Tests for task approval validation.""" + + @pytest.mark.asyncio + async def test_approve_tasks_wrong_phase_returns_400( + self, mock_db, mock_user, mock_manager + ): + """Test that approving tasks in wrong phase returns 400.""" + from fastapi import HTTPException + + mock_db.get_project.return_value = { + "id": 1, + "name": "Test Project", + "phase": "discovery" # Wrong phase + } + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + pytest.raises(HTTPException) as exc_info: + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert exc_info.value.status_code == 400 + assert "planning" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_approve_tasks_no_tasks_returns_404( + self, mock_db, mock_user, mock_manager + ): + """Test that approving with no tasks returns 404.""" + from fastapi import HTTPException + + mock_db.get_project_tasks.return_value = [] # No tasks + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + pytest.raises(HTTPException) as exc_info: + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert exc_info.value.status_code == 404 + assert "no tasks" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_approve_tasks_project_not_found_returns_404( + self, mock_db, mock_user, mock_manager + ): + """Test that approving for non-existent project returns 404.""" + from fastapi import HTTPException + + mock_db.get_project.return_value = None + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + pytest.raises(HTTPException) as exc_info: + await approve_tasks( + project_id=999, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_approve_tasks_access_denied_returns_403( + self, mock_db, mock_user, mock_manager + ): + """Test that approving without access returns 403.""" + from fastapi import HTTPException + + mock_db.user_has_project_access.return_value = False + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + pytest.raises(HTTPException) as exc_info: + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + assert exc_info.value.status_code == 403 + + +class TestWebSocketBroadcastForDevelopmentStarted: + """Tests for broadcast_development_started function.""" + + @pytest.mark.asyncio + async def test_broadcast_development_started_message_format(self): + """Test that development_started message has correct format.""" + from codeframe.ui.websocket_broadcasts import broadcast_development_started + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_development_started( + manager=mock_manager, + project_id=1, + approved_count=5, + excluded_count=2 + ) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "development_started" + assert message["project_id"] == 1 + assert message["approved_count"] == 5 + assert message["excluded_count"] == 2 + assert "timestamp" in message + # Verify timestamp format ends with 'Z' + assert message["timestamp"].endswith("Z") + + +class TestPlanningBroadcastFunctions: + """Tests for planning-related WebSocket broadcast functions.""" + + @pytest.mark.asyncio + async def test_broadcast_planning_started(self): + """Test planning_started broadcast.""" + from codeframe.ui.websocket_broadcasts import broadcast_planning_started + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_planning_started(manager=mock_manager, project_id=1) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "planning_started" + assert message["project_id"] == 1 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_broadcast_issues_generated(self): + """Test issues_generated broadcast.""" + from codeframe.ui.websocket_broadcasts import broadcast_issues_generated + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_issues_generated( + manager=mock_manager, project_id=1, issue_count=5 + ) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "issues_generated" + assert message["project_id"] == 1 + assert message["issue_count"] == 5 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_broadcast_tasks_decomposed(self): + """Test tasks_decomposed broadcast.""" + from codeframe.ui.websocket_broadcasts import broadcast_tasks_decomposed + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_tasks_decomposed( + manager=mock_manager, project_id=1, task_count=10 + ) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "tasks_decomposed" + assert message["project_id"] == 1 + assert message["task_count"] == 10 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_broadcast_tasks_ready(self): + """Test tasks_ready broadcast.""" + from codeframe.ui.websocket_broadcasts import broadcast_tasks_ready + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_tasks_ready( + manager=mock_manager, project_id=1, total_tasks=10 + ) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "tasks_ready" + assert message["project_id"] == 1 + assert message["total_tasks"] == 10 + assert "timestamp" in message + + @pytest.mark.asyncio + async def test_broadcast_planning_failed(self): + """Test planning_failed broadcast.""" + from codeframe.ui.websocket_broadcasts import broadcast_planning_failed + + mock_manager = MagicMock() + mock_manager.broadcast = AsyncMock() + + await broadcast_planning_failed( + manager=mock_manager, project_id=1, error="API Error" + ) + + mock_manager.broadcast.assert_called_once() + message = mock_manager.broadcast.call_args[0][0] + + assert message["type"] == "planning_failed" + assert message["project_id"] == 1 + assert message["error"] == "API Error" + assert message["status"] == "failed" + assert "timestamp" in message + + +# ============================================================================ +# Integration and Edge Case Tests +# ============================================================================ + + +class TestPlanningAutomationIntegration: + """Integration tests for the complete planning automation flow.""" + + @pytest.fixture + def mock_db_with_state(self): + """Create mock Database that tracks state changes.""" + db = MagicMock() + # Track project phase changes + db._project_phase = "planning" + db._tasks = [] + + def get_project(project_id): + return { + "id": project_id, + "name": "Test Project", + "phase": db._project_phase + } + + def update_project(project_id, updates): + if "phase" in updates: + db._project_phase = updates["phase"] + + def get_project_tasks(project_id): + return db._tasks + + def update_task(task_id, updates): + for task in db._tasks: + if task.id == task_id: + if "status" in updates: + task.status = TaskStatus(updates["status"]) + + db.get_project.side_effect = get_project + db.update_project.side_effect = update_project + db.get_project_tasks.side_effect = get_project_tasks + db.update_task.side_effect = update_task + db.user_has_project_access.return_value = True + + return db + + @pytest.mark.asyncio + async def test_end_to_end_planning_to_approval_flow( + self, mock_db_with_state, mock_user, mock_manager + ): + """Test complete flow: planning phase → task approval → development phase.""" + # Setup: Create tasks as if generated by planning automation + mock_db_with_state._tasks = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), + ] + + # Verify starting state + assert mock_db_with_state._project_phase == "planning" + + # Execute approval + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: + # Simulate phase manager updating state + def transition_side_effect(pid, phase, db): + db._project_phase = phase + mock_pm.transition.side_effect = transition_side_effect + + response = await approve_tasks( + project_id=1, + request=request, + db=mock_db_with_state, + current_user=mock_user + ) + + # Verify end state + assert response.success is True + assert response.phase == "active" + assert response.approved_count == 2 + + # Verify WebSocket notification was sent + broadcast_calls = [ + call for call in mock_manager.broadcast.call_args_list + if call[0][0].get("type") == "development_started" + ] + assert len(broadcast_calls) == 1 + + @pytest.mark.asyncio + async def test_approval_with_tasks_modified_during_review( + self, mock_db_with_state, mock_user, mock_manager + ): + """Test approval when tasks are modified between generation and approval. + + Scenario: Tasks were generated, user reviews them, but meanwhile + some tasks are deleted or modified by another process. + """ + # Setup: Tasks exist initially + original_tasks = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), + Task(id=3, project_id=1, title="Task 3", status=TaskStatus.PENDING), + ] + mock_db_with_state._tasks = original_tasks.copy() + + # User tries to exclude task 2 and 3, but task 3 was deleted + # Simulate: task 3 no longer exists + mock_db_with_state._tasks = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), + # Task 3 was deleted + ] + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[2, 3]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + response = await approve_tasks( + project_id=1, + request=request, + db=mock_db_with_state, + current_user=mock_user + ) + + # Should still work - task 3 in exclusion list doesn't exist, which is fine + assert response.success is True + assert response.approved_count == 1 # Only task 1 approved + assert response.excluded_count == 1 # Only task 2 excluded (task 3 doesn't exist) + + +class TestConcurrentApprovalAttempts: + """Tests for race condition handling in task approval.""" + + @pytest.mark.asyncio + async def test_double_approval_second_fails(self, mock_db, mock_user, mock_manager): + """Test that approving already-approved project fails gracefully. + + Scenario: Two users try to approve at the same time. First succeeds, + second should fail because project is no longer in planning phase. + """ + from fastapi import HTTPException + + # First approval changes phase to active + def get_project_after_first_approval(project_id): + # Simulate state after first approval + return { + "id": project_id, + "name": "Test Project", + "phase": "active" # Already transitioned + } + + mock_db.get_project.side_effect = get_project_after_first_approval + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + pytest.raises(HTTPException) as exc_info: + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + # Should fail with 400 - wrong phase + assert exc_info.value.status_code == 400 + assert "planning" in exc_info.value.detail.lower() + + @pytest.mark.asyncio + async def test_phase_transition_failure_leaves_tasks_unchanged( + self, mock_user, mock_manager + ): + """Test that if phase transition fails, tasks are not modified. + + This verifies the transaction ordering fix - phase transition + happens before task updates. + """ + from fastapi import HTTPException + + mock_db = MagicMock() + mock_db.get_project.return_value = { + "id": 1, "name": "Test", "phase": "planning" + } + mock_db.user_has_project_access.return_value = True + mock_db.get_project_tasks.return_value = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + ] + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager") as mock_pm: + # Simulate phase transition failure + mock_pm.transition.side_effect = HTTPException( + status_code=400, detail="Invalid transition" + ) + + with pytest.raises(HTTPException): + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + ) + + # Critical: update_task should NOT have been called since phase transition failed first + mock_db.update_task.assert_not_called() + + @pytest.mark.asyncio + async def test_tasks_deleted_between_fetch_and_update( + self, mock_user, mock_manager + ): + """Test handling when tasks are deleted during approval process. + + Scenario: Tasks are fetched, but before update_task is called, + the task is deleted by another process. + """ + mock_db = MagicMock() + mock_db.get_project.return_value = { + "id": 1, "name": "Test", "phase": "planning" + } + mock_db.user_has_project_access.return_value = True + mock_db.get_project_tasks.return_value = [ + Task(id=1, project_id=1, title="Task 1", status=TaskStatus.PENDING), + Task(id=2, project_id=1, title="Task 2", status=TaskStatus.PENDING), + ] + + # Simulate update_task failing for task 2 (deleted) + def update_task_with_deletion(task_id, updates): + if task_id == 2: + raise Exception("Task not found") # Simulates deletion + return None + + mock_db.update_task.side_effect = update_task_with_deletion + + request = TaskApprovalRequest(approved=True, excluded_task_ids=[]) + + with patch("codeframe.ui.routers.tasks.manager", mock_manager), \ + patch("codeframe.ui.routers.tasks.PhaseManager"): + # Currently the implementation doesn't handle this - it would raise + # This test documents the current behavior + with pytest.raises(Exception, match="Task not found"): + await approve_tasks( + project_id=1, + request=request, + db=mock_db, + current_user=mock_user + )