diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7ef5469..224d2811 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -331,7 +331,7 @@ jobs: run: | mkdir -p .codeframe source .venv/bin/activate - python -c "from codeframe.persistence.database import Database; db = Database('.codeframe/state.db'); db.initialize(); db.close()" + python -c "from codeframe.platform_store.database import Database; db = Database('.codeframe/state.db'); db.initialize(); db.close()" echo "✅ Database initialized" - name: Start FastAPI server in background @@ -466,7 +466,7 @@ jobs: # # E2E tests expect database at tests/e2e/.codeframe/state.db # mkdir -p tests/e2e/.codeframe # source .venv/bin/activate - # python -c "from codeframe.persistence.database import Database; db = Database('tests/e2e/.codeframe/state.db'); db.initialize(); db.close()" + # python -c "from codeframe.platform_store.database import Database; db = Database('tests/e2e/.codeframe/state.db'); db.initialize(); db.close()" # echo "✅ Database initialized at tests/e2e/.codeframe/state.db" # # - name: Start backend server @@ -621,7 +621,7 @@ jobs: # run: | # mkdir -p .codeframe # source .venv/bin/activate - # python -c "from codeframe.persistence.database import Database; db = Database('.codeframe/state.db'); db.initialize(); db.close()" + # python -c "from codeframe.platform_store.database import Database; db = Database('.codeframe/state.db'); db.initialize(); db.close()" # echo "✅ Database initialized" # # - name: Start backend server diff --git a/CLAUDE.md b/CLAUDE.md index faabb651..753f4962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,10 +74,7 @@ Golden Path commands must work from the CLI with **no server running**. FastAPI This separation prevents duplicate state transitions (e.g., DONE→DONE errors). -### 4) Legacy can be read, not depended on -`server/` is reference only. Do NOT import legacy UI/server modules into core. - -### 5) Keep commits runnable +### 4) Keep commits runnable At all times: `codeframe --help` works, Golden Path stubs can run, no breaking renames/moves. --- @@ -89,7 +86,6 @@ At all times: `codeframe --help` works, Golden Path stubs can run, no breaking r - **CLI-first**: Golden Path works **without any running FastAPI server** - **Adapters**: LLM providers in `codeframe/adapters/llm/` - **Server/UI optional**: FastAPI and UI are thin adapters over core; web UI connects via REST/WebSocket -- `server/` contains v1 code retained as reference only; do not build toward v1 patterns ### Phase 3 Web UI (actively developed — not legacy) Next.js 16 App Router, TypeScript, Shadcn/UI, Tailwind CSS, Hugeicons, XTerm.js, WebSocket + SSE. @@ -128,14 +124,15 @@ codeframe/ │ ├── server.py, models.py, dependencies.py │ └── routers/ # 16 v2 router modules ├── auth/ # API key service + auth dependencies -├── lib/ # rate_limiter.py, audit_logger.py -└── server/ # Legacy v1 (reference only) +├── lib/ # rate_limiter.py, audit_logger.py, metrics_tracker.py +└── platform_store/ # Control-plane store: auth, api keys, audit logs, + # interactive sessions, token usage (slim Database + repos) web-ui/ # Phase 3 Web UI (Next.js, actively developed) tests/ ├── core/ # Core module tests (auto-marked v2) ├── adapters/ # LLM + E2B adapter tests -├── agents/ # Worker agent tests +├── agents/ # dependency_resolver tests ├── integration/ # Cross-module integration tests ├── lifecycle/ # End-to-end lifecycle tests (CLI + API + web, uses MockProvider) └── ui/ # FastAPI router tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ab5d784..066c50eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ When creating new API endpoints that access project resources: ```python from fastapi import HTTPException, Depends -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database from codeframe.ui.dependencies import get_db, get_current_user, User @router.get("/api/projects/{project_id}/resource") diff --git a/PRD.md b/PRD.md index e6d29611..e1f24e41 100644 --- a/PRD.md +++ b/PRD.md @@ -129,7 +129,7 @@ High‑level architecture is defined in [`specs/CODEFRAME_SPEC.md`](specs/CODEFR - **CLI** (`codeframe/cli.py`) – project initialization, server start, session commands. - **Backend API** (`codeframe/ui/server.py`) – projects, agents, tasks, blockers, discovery, PRD, context, session. - **Agent layer** (`codeframe/agents/*`) – lead + worker agents + pool + dependency resolver. -- **Persistence** (`codeframe/persistence/database.py`) – all state with flattened v1.0 schema. +- **Platform store** (`codeframe/platform_store/database.py`) – all state with flattened v1.0 schema. - **Context & session** (`codeframe/lib/context_manager.py`, `codeframe/core/session_manager.py`). - **Dashboard** (`web-ui/*`) – React/TypeScript UI with SWR and WebSocket integration. @@ -219,7 +219,7 @@ This section captures the **stepwise workflows**; the next section defines forma 2. DB keeps issues/tasks plus DAG dependencies; implementation lives in: - - `codeframe/persistence/database.py` methods for issues, tasks, `task_dependencies`. + - `codeframe/platform_store/database.py` methods for issues, tasks, `task_dependencies`. - `tests/api/test_api_issues.py` verifies the contract. 3. API and UI: @@ -264,7 +264,7 @@ This section captures the **stepwise workflows**; the next section defines forma 1. When blocked, worker agents create blockers in DB: - Schema and behavior in `specs/049-human-in-loop/spec.md`. - - Implementation in `codeframe/persistence/database.py` and `codeframe/ui/server.py` endpoints: + - Implementation in `codeframe/platform_store/database.py` and `codeframe/ui/server.py` endpoints: - `GET /api/projects/{id}/blockers` - `GET /api/blockers/{id}` - `POST /api/blockers/{id}/resolve` diff --git a/TESTING.md b/TESTING.md index 1d07fd70..d9034022 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,7 +70,7 @@ #### 2.2 Agent Creation - [ ] Use Python REPL or script: ```python - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database db = Database("test-project/.codeframe/state.db") db.initialize() agent_id = db.create_agent("lead-1", "lead", "claude", "directive") @@ -123,7 +123,7 @@ - [ ] Test Lead Agent initialization: ```python from codeframe.agents.lead_agent import LeadAgent - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database db = Database("test-project/.codeframe/state.db") db.initialize() @@ -190,7 +190,7 @@ - [ ] Test agent lifecycle: ```python from codeframe.agents.lead_agent import LeadAgent - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database from codeframe.core.models import AgentMaturity db = Database("test-project/.codeframe/state.db") diff --git a/codeframe/auth/api_key_router.py b/codeframe/auth/api_key_router.py index 3b5b73c7..fc25ac26 100644 --- a/codeframe/auth/api_key_router.py +++ b/codeframe/auth/api_key_router.py @@ -23,7 +23,7 @@ SCOPE_WRITE, ) from codeframe.core.api_key_service import ApiKeyService -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database logger = logging.getLogger(__name__) diff --git a/codeframe/auth/dependencies.py b/codeframe/auth/dependencies.py index f8358f81..88cbc736 100644 --- a/codeframe/auth/dependencies.py +++ b/codeframe/auth/dependencies.py @@ -183,7 +183,7 @@ async def get_api_key_auth( # Fallback: create database connection logger.warning("No db in app.state, creating fallback connection for API key auth") import os - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database db_path = os.getenv( "DATABASE_PATH", diff --git a/codeframe/cli/auth_commands.py b/codeframe/cli/auth_commands.py index b1de6315..00b3c611 100644 --- a/codeframe/cli/auth_commands.py +++ b/codeframe/cli/auth_commands.py @@ -41,7 +41,7 @@ CredentialSource, ) from codeframe.core.api_key_service import ApiKeyService -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database logger = logging.getLogger(__name__) diff --git a/codeframe/cli/stats_commands.py b/codeframe/cli/stats_commands.py index f4c5026f..ae6ca66b 100644 --- a/codeframe/cli/stats_commands.py +++ b/codeframe/cli/stats_commands.py @@ -45,7 +45,7 @@ def _get_db(): Raises: typer.Exit: If no workspace is found. """ - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database db_path = Path(".codeframe/state.db") if not db_path.exists(): diff --git a/codeframe/core/api_key_service.py b/codeframe/core/api_key_service.py index 68930472..f7246c35 100644 --- a/codeframe/core/api_key_service.py +++ b/codeframe/core/api_key_service.py @@ -15,7 +15,7 @@ SCOPE_READ, SCOPE_WRITE, ) -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database logger = logging.getLogger(__name__) diff --git a/codeframe/core/react_agent.py b/codeframe/core/react_agent.py index 93e6c030..6693e7ec 100644 --- a/codeframe/core/react_agent.py +++ b/codeframe/core/react_agent.py @@ -353,7 +353,7 @@ def _persist_token_usage(self, task_id: str) -> None: db = None try: from codeframe.lib.metrics_tracker import MetricsTracker - from codeframe.persistence.database import Database + from codeframe.platform_store.database import Database db = Database(str(self.workspace.db_path)) db.initialize() diff --git a/codeframe/lib/audit_logger.py b/codeframe/lib/audit_logger.py index adda05a7..21122c2b 100644 --- a/codeframe/lib/audit_logger.py +++ b/codeframe/lib/audit_logger.py @@ -13,7 +13,7 @@ from typing import Optional, Dict, Any from enum import Enum -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database class AuditEventType(Enum): diff --git a/codeframe/lib/metrics_tracker.py b/codeframe/lib/metrics_tracker.py index 8e80a743..cc40254c 100644 --- a/codeframe/lib/metrics_tracker.py +++ b/codeframe/lib/metrics_tracker.py @@ -11,7 +11,7 @@ Example: >>> from codeframe.lib.metrics_tracker import MetricsTracker - >>> from codeframe.persistence.database import Database + >>> from codeframe.platform_store.database import Database >>> from codeframe.core.models import CallType >>> >>> db = Database("state.db") @@ -42,7 +42,7 @@ from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Union from codeframe.core.models import CallType, TokenUsage -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database logger = logging.getLogger(__name__) diff --git a/codeframe/persistence/__init__.py b/codeframe/persistence/__init__.py deleted file mode 100644 index f0923aa2..00000000 --- a/codeframe/persistence/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Persistence layer for CodeFRAME state management.""" - -from codeframe.persistence.database import Database - -__all__ = ["Database"] diff --git a/codeframe/platform_store/__init__.py b/codeframe/platform_store/__init__.py new file mode 100644 index 00000000..2188a843 --- /dev/null +++ b/codeframe/platform_store/__init__.py @@ -0,0 +1,5 @@ +"""Platform store: control-plane state (auth, API keys, audit logs, interactive sessions, token usage).""" + +from codeframe.platform_store.database import Database + +__all__ = ["Database"] diff --git a/codeframe/persistence/database.py b/codeframe/platform_store/database.py similarity index 98% rename from codeframe/persistence/database.py rename to codeframe/platform_store/database.py index 8e4bbf13..979c2232 100644 --- a/codeframe/persistence/database.py +++ b/codeframe/platform_store/database.py @@ -21,13 +21,13 @@ import asyncio import aiosqlite -from codeframe.persistence.schema_manager import SchemaManager -from codeframe.persistence.repositories import ( +from codeframe.platform_store.schema_manager import SchemaManager +from codeframe.platform_store.repositories import ( TokenRepository, AuditRepository, APIKeyRepository, ) -from codeframe.persistence.repositories.interactive_sessions import InteractiveSessionRepository +from codeframe.platform_store.repositories.interactive_sessions import InteractiveSessionRepository logger = logging.getLogger(__name__) diff --git a/codeframe/persistence/database.py,cover b/codeframe/platform_store/database.py,cover similarity index 100% rename from codeframe/persistence/database.py,cover rename to codeframe/platform_store/database.py,cover diff --git a/codeframe/persistence/repositories/__init__.py b/codeframe/platform_store/repositories/__init__.py similarity index 62% rename from codeframe/persistence/repositories/__init__.py rename to codeframe/platform_store/repositories/__init__.py index 894c342f..07282ae1 100644 --- a/codeframe/persistence/repositories/__init__.py +++ b/codeframe/platform_store/repositories/__init__.py @@ -7,10 +7,10 @@ repository is imported directly from its module by ``database.py``. """ -from codeframe.persistence.repositories.base import BaseRepository -from codeframe.persistence.repositories.token_repository import TokenRepository -from codeframe.persistence.repositories.audit_repository import AuditRepository -from codeframe.persistence.repositories.api_key_repository import APIKeyRepository +from codeframe.platform_store.repositories.base import BaseRepository +from codeframe.platform_store.repositories.token_repository import TokenRepository +from codeframe.platform_store.repositories.audit_repository import AuditRepository +from codeframe.platform_store.repositories.api_key_repository import APIKeyRepository __all__ = [ "BaseRepository", diff --git a/codeframe/persistence/repositories/api_key_repository.py b/codeframe/platform_store/repositories/api_key_repository.py similarity index 99% rename from codeframe/persistence/repositories/api_key_repository.py rename to codeframe/platform_store/repositories/api_key_repository.py index a13af968..14a88772 100644 --- a/codeframe/persistence/repositories/api_key_repository.py +++ b/codeframe/platform_store/repositories/api_key_repository.py @@ -9,7 +9,7 @@ from typing import Optional, Dict, Any, List import logging -from codeframe.persistence.repositories.base import BaseRepository +from codeframe.platform_store.repositories.base import BaseRepository logger = logging.getLogger(__name__) diff --git a/codeframe/persistence/repositories/audit_repository.py b/codeframe/platform_store/repositories/audit_repository.py similarity index 96% rename from codeframe/persistence/repositories/audit_repository.py rename to codeframe/platform_store/repositories/audit_repository.py index e71bdab2..5b586fd5 100644 --- a/codeframe/persistence/repositories/audit_repository.py +++ b/codeframe/platform_store/repositories/audit_repository.py @@ -9,7 +9,7 @@ import logging -from codeframe.persistence.repositories.base import BaseRepository +from codeframe.platform_store.repositories.base import BaseRepository logger = logging.getLogger(__name__) diff --git a/codeframe/persistence/repositories/base.py b/codeframe/platform_store/repositories/base.py similarity index 100% rename from codeframe/persistence/repositories/base.py rename to codeframe/platform_store/repositories/base.py diff --git a/codeframe/persistence/repositories/interactive_sessions.py b/codeframe/platform_store/repositories/interactive_sessions.py similarity index 98% rename from codeframe/persistence/repositories/interactive_sessions.py rename to codeframe/platform_store/repositories/interactive_sessions.py index c25fabb3..2f80b38f 100644 --- a/codeframe/persistence/repositories/interactive_sessions.py +++ b/codeframe/platform_store/repositories/interactive_sessions.py @@ -7,7 +7,7 @@ from datetime import datetime, UTC from typing import Optional -from codeframe.persistence.repositories.base import BaseRepository +from codeframe.platform_store.repositories.base import BaseRepository class InteractiveSessionRepository(BaseRepository): diff --git a/codeframe/persistence/repositories/token_repository.py b/codeframe/platform_store/repositories/token_repository.py similarity index 99% rename from codeframe/persistence/repositories/token_repository.py rename to codeframe/platform_store/repositories/token_repository.py index ab421405..fc037c91 100644 --- a/codeframe/persistence/repositories/token_repository.py +++ b/codeframe/platform_store/repositories/token_repository.py @@ -11,7 +11,7 @@ from codeframe.core.models import ( CallType, ) -from codeframe.persistence.repositories.base import BaseRepository +from codeframe.platform_store.repositories.base import BaseRepository if TYPE_CHECKING: from codeframe.core.models import TokenUsage diff --git a/codeframe/persistence/schema_manager.py b/codeframe/platform_store/schema_manager.py similarity index 100% rename from codeframe/persistence/schema_manager.py rename to codeframe/platform_store/schema_manager.py diff --git a/codeframe/ui/routers/costs_v2.py b/codeframe/ui/routers/costs_v2.py index 0b125a3a..38028925 100644 --- a/codeframe/ui/routers/costs_v2.py +++ b/codeframe/ui/routers/costs_v2.py @@ -10,7 +10,7 @@ The handler opens the workspace SQLite database directly to avoid the pre-existing schema conflict between `codeframe/core/workspace.py` and -`codeframe/persistence/schema_manager.py` — wiring `TokenRepository` +`codeframe/platform_store/schema_manager.py` — wiring `TokenRepository` to a raw connection skips `Database.initialize()` entirely. """ @@ -25,7 +25,7 @@ from codeframe.core import tasks as tasks_module from codeframe.core.workspace import Workspace from codeframe.lib.rate_limiter import rate_limit_standard -from codeframe.persistence.repositories.token_repository import TokenRepository +from codeframe.platform_store.repositories.token_repository import TokenRepository from codeframe.ui.dependencies import get_v2_workspace logger = logging.getLogger(__name__) diff --git a/codeframe/ui/server.py b/codeframe/ui/server.py index 014fde93..e3f899ff 100644 --- a/codeframe/ui/server.py +++ b/codeframe/ui/server.py @@ -44,7 +44,7 @@ workspace_v2, ) from codeframe.auth import router as auth_router -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database from codeframe.lib.rate_limiter import ( get_rate_limiter, rate_limit_exceeded_handler, diff --git a/codeframe/ui/server.py,cover b/codeframe/ui/server.py,cover deleted file mode 100644 index 555d9baa..00000000 --- a/codeframe/ui/server.py,cover +++ /dev/null @@ -1,665 +0,0 @@ -> """FastAPI Status Server for CodeFRAME.""" - -> from contextlib import asynccontextmanager -> from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, BackgroundTasks -> from fastapi.responses import JSONResponse -> from fastapi.staticfiles import StaticFiles -> from fastapi.middleware.cors import CORSMiddleware -> from pathlib import Path -> from typing import List, Dict, Any -> import asyncio -> import json -> import os -> import sqlite3 - -> from codeframe.core.project import Project -> from codeframe.core.models import TaskStatus, AgentMaturity, ProjectStatus -> from codeframe.persistence.database import Database -> from codeframe.ui.models import ProjectCreateRequest, ProjectResponse -> from codeframe.agents.lead_agent import LeadAgent - - -> @asynccontextmanager -> async def lifespan(app: FastAPI): -> """Manage application lifespan - startup and shutdown.""" - # Startup: Initialize database -! db_path_str = os.environ.get("DATABASE_PATH", ".codeframe/state.db") -! db_path = Path(db_path_str) - -! app.state.db = Database(db_path) -! app.state.db.initialize() - -! yield - - # Shutdown: Close database connection -! if hasattr(app.state, "db") and app.state.db: -! app.state.db.close() - - -> app = FastAPI( -> title="CodeFRAME Status Server", -> description="Real-time monitoring and control for CodeFRAME projects", -> version="0.1.0", -> lifespan=lifespan -> ) - - # CORS for development -> app.add_middleware( -> CORSMiddleware, -> allow_origins=["http://localhost:3000", "http://localhost:5173"], # React/Vite dev servers -> allow_credentials=True, -> allow_methods=["*"], -> allow_headers=["*"], -> ) - - -> class ConnectionManager: -> """Manage WebSocket connections for real-time updates.""" - -> def __init__(self): -> self.active_connections: List[WebSocket] = [] - -> async def connect(self, websocket: WebSocket): -! await websocket.accept() -! self.active_connections.append(websocket) - -> def disconnect(self, websocket: WebSocket): -! self.active_connections.remove(websocket) - -> async def broadcast(self, message: dict): -> """Broadcast message to all connected clients.""" -! for connection in self.active_connections: -! try: -! await connection.send_json(message) -! except Exception: - # Client disconnected -- pass - - -> manager = ConnectionManager() - - # cf-10.1: Dictionary to track running agents by project_id -> running_agents: Dict[int, LeadAgent] = {} - - -> async def start_agent( -> project_id: int, -> db: Database, -> agents_dict: Dict[int, LeadAgent], -> api_key: str -> ) -> None: -> """Start Lead Agent for a project (cf-10.1). - -> Args: -> project_id: Project ID to start agent for -> db: Database connection -> agents_dict: Dictionary to store running agents -> api_key: Anthropic API key for Lead Agent - -> This function: -> - Creates LeadAgent instance -> - Updates project status to RUNNING -> - Saves greeting message to database -> - Broadcasts status updates via WebSocket -> """ -! try: - # cf-10.1: Create Lead Agent instance -! agent = LeadAgent( -! project_id=project_id, -! db=db, -! api_key=api_key -! ) - - # cf-10.1: Store agent reference -! agents_dict[project_id] = agent - - # cf-10.1: Update project status to RUNNING -! db.update_project(project_id, {"status": ProjectStatus.RUNNING}) - - # cf-10.4: Broadcast agent_started message -! try: -! await manager.broadcast({ -! "type": "agent_started", -! "project_id": project_id, -! "agent_type": "lead", -! "timestamp": asyncio.get_event_loop().time() -! }) -! except Exception: - # Continue even if broadcast fails -- pass - - # cf-10.4: Broadcast status_update message -! try: -! await manager.broadcast({ -! "type": "status_update", -! "project_id": project_id, -! "status": "running" -! }) -! except Exception: -- pass - - # cf-10.3: Send greeting message -! greeting = "Hi! I'm your Lead Agent. I'm here to help build your project. What would you like to create?" - - # cf-10.3: Save greeting to database -! db.create_memory( -! project_id=project_id, -! category="conversation", -! key="assistant", -! value=greeting -! ) - - # cf-10.4: Broadcast greeting via WebSocket -! try: -! await manager.broadcast({ -! "type": "chat_message", -! "project_id": project_id, -! "role": "assistant", -! "content": greeting -! }) -! except Exception: -- pass - -! except Exception as e: - # Log error but let it propagate -! raise - - - # API Routes - -> @app.get("/") -> async def root(): -> """Health check endpoint.""" -! return {"status": "online", "service": "CodeFRAME Status Server"} - - -> @app.get("/api/projects") -> async def list_projects(): -> """List all CodeFRAME projects.""" -! from fastapi import Request - - # Get projects from database -! projects = app.state.db.list_projects() - -! return {"projects": projects} - - -> @app.post("/api/projects", status_code=201, response_model=ProjectResponse) -> async def create_project(request: ProjectCreateRequest): -> """Create a new CodeFRAME project. - -> Task: cf-11.2 - POST /api/projects endpoint - -> Args: -> request: Project creation request with name and type - -> Returns: -> Created project details with ID and timestamp - -> Raises: -> HTTPException: -> - 400 Bad Request: Invalid input (empty name) -> - 409 Conflict: Project name already exists -> - 500 Internal Server Error: Database operation failed -> """ -! try: - # Validate input (additional check beyond Pydantic) -! if not request.project_name or not request.project_name.strip(): -! raise HTTPException( -! status_code=400, -! detail="Project name cannot be empty" -! ) - - # Check for duplicate project names -! existing_projects = app.state.db.list_projects() -! for project in existing_projects: -! if project["name"].lower() == request.project_name.strip().lower(): -! raise HTTPException( -! status_code=409, -! detail=f"Project with name '{request.project_name}' already exists" -! ) - - # Create project in database with INIT status -! project_id = app.state.db.create_project( -! name=request.project_name.strip(), -! status=ProjectStatus.INIT -! ) - - # Retrieve created project to get all fields -! created_project = app.state.db.get_project(project_id) - -! if not created_project: -! raise HTTPException( -! status_code=500, -! detail="Failed to retrieve created project" -! ) - - # Return created project -! return ProjectResponse( -! id=created_project["id"], -! name=created_project["name"], -! status=created_project["status"], -! created_at=created_project["created_at"], -! config=created_project.get("config") -! ) - -! except HTTPException: - # Re-raise HTTP exceptions as-is -! raise -! except sqlite3.IntegrityError as e: - # Handle database constraint violations -! raise HTTPException( -! status_code=409, -! detail=f"Database constraint violation: {str(e)}" -! ) -! except Exception as e: - # Handle any other database errors -! raise HTTPException( -! status_code=500, -! detail=f"Internal server error: {str(e)}" -! ) - - -> @app.post("/api/projects/{project_id}/start", status_code=202) -> async def start_project_agent(project_id: int, background_tasks: BackgroundTasks): -> """Start Lead Agent for a project (cf-10.2). - -> Returns 202 Accepted immediately and starts agent in background. - -> Args: -> project_id: Project ID to start agent for -> background_tasks: FastAPI background tasks - -> Returns: -> 202 Accepted with message -> 200 OK if already running -> 404 Not Found if project doesn't exist - -> Raises: -> HTTPException: 404 if project not found -> """ - # cf-10.2: Check if project exists -! project = app.state.db.get_project(project_id) - -! if not project: -! raise HTTPException( -! status_code=404, -! detail=f"Project {project_id} not found" -! ) - - # cf-10.2: Handle idempotent behavior - already running -! if project["status"] == ProjectStatus.RUNNING.value: -! return JSONResponse( -! status_code=200, -! content={ -! "message": f"Project {project_id} is already running", -! "status": "running" -! } -! ) - - # cf-10.2: Get API key from environment -! api_key = os.environ.get("ANTHROPIC_API_KEY") -! if not api_key: -! raise HTTPException( -! status_code=500, -! detail="ANTHROPIC_API_KEY not configured" -! ) - - # cf-10.2: Start agent in background task (non-blocking) -! background_tasks.add_task( -! start_agent, -! project_id, -! app.state.db, -! running_agents, -! api_key -! ) - - # cf-10.2: Return 202 Accepted immediately -! return { -! "message": f"Starting Lead Agent for project {project_id}", -! "status": "starting" -! } - - -> @app.get("/api/projects/{project_id}/status") -> async def get_project_status(project_id: int): -> """Get comprehensive project status.""" - # Get project from database -! project = app.state.db.get_project(project_id) - -! if not project: -! raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - -! return { -! "project_id": project["id"], -! "project_name": project["name"], -! "status": project["status"] -! } - - -> @app.get("/api/projects/{project_id}/agents") -> async def get_agent_status(project_id: int): -> """Get status of all agents.""" - # Get agents from database -! agents = app.state.db.list_agents() - -! return {"agents": agents} - - -> @app.get("/api/projects/{project_id}/tasks") -> async def get_tasks( -> project_id: int, -> status: str | None = None, -> limit: int = 50 -> ): -> """Get project tasks.""" - # TODO: Query database with filters -! return { -! "tasks": [ -! { -! "id": 27, -! "title": "JWT refresh token flow", -! "description": "Implement token refresh endpoint", -! "status": "in_progress", -! "assigned_to": "backend-1", -! "priority": 0, -! "workflow_step": 7, -! "progress": 45 -! } -! ], -! "total": 40 -! } - - -> @app.get("/api/projects/{project_id}/blockers") -> async def get_blockers(project_id: int): -> """Get pending blockers requiring user input.""" - # TODO: Query database for unresolved blockers -- return { -> "blockers": [ -> { -> "id": 1, -> "task_id": 30, -> "severity": "sync", -> "question": "Should password reset tokens expire after 1hr or 24hrs?", -> "reason": "Security vs UX trade-off", -> "created_at": "2025-01-15T14:00:00Z", -> "blocking_agents": ["backend-1", "test-1"] -> }, -> { -> "id": 2, -> "task_id": 25, -> "severity": "async", -> "question": "Use Material UI or Ant Design for form components?", -> "reason": "Design system choice", -> "created_at": "2025-01-15T12:00:00Z", -> "blocking_agents": [] -> } -> ] -> } - - -> @app.post("/api/projects/{project_id}/blockers/{blocker_id}/resolve") -> async def resolve_blocker(project_id: int, blocker_id: int, resolution: Dict[str, str]): -> """Resolve a blocker with user's answer.""" - # TODO: Update database and notify Lead Agent -! answer = resolution.get("answer") - - # Broadcast update to WebSocket clients -! await manager.broadcast({ -! "type": "blocker_resolved", -! "blocker_id": blocker_id, -! "answer": answer -! }) - -! return { -! "success": True, -! "blocker_id": blocker_id, -! "message": "Blocker resolved, agents resuming work" -! } - - -> @app.get("/api/projects/{project_id}/activity") -> async def get_activity(project_id: int, limit: int = 50): -> """Get recent activity log.""" - # TODO: Query changelog table -- return { -> "activity": [ -> { -> "timestamp": "2025-01-15T14:32:00Z", -> "type": "task_completed", -> "agent": "backend-1", -> "message": "Completed Task #26 (login endpoint)" -> }, -> { -> "timestamp": "2025-01-15T14:28:00Z", -> "type": "tests_passed", -> "agent": "test-1", -> "message": "All tests passed for auth module" -> }, -> { -> "timestamp": "2025-01-15T14:15:00Z", -> "type": "blocker_created", -> "agent": "backend-1", -> "message": "Escalated blocker on Task #30" -> } -> ] -> } - - -> @app.post("/api/projects/{project_id}/chat") -> async def chat_with_lead(project_id: int, message: Dict[str, str]): -> """Chat with Lead Agent (cf-14.1). - -> Send user message to Lead Agent and get AI response. -> Broadcasts message via WebSocket for real-time updates. - -> Args: -> project_id: Project ID -> message: Dict with 'message' key containing user message - -> Returns: -> Dict with 'response' and 'timestamp' - -> Raises: -> HTTPException: -> - 404: Project not found -> - 400: Empty message or agent not started -> - 500: Agent communication failure -> """ -> from datetime import datetime, UTC - - # Validate input -> user_message = message.get("message", "").strip() -> if not user_message: -> raise HTTPException( -> status_code=400, -> detail="Message cannot be empty" -> ) - - # Check if project exists -> project = app.state.db.get_project(project_id) -> if not project: -> raise HTTPException( -> status_code=404, -> detail=f"Project {project_id} not found" -> ) - - # Check if Lead Agent is running -> agent = running_agents.get(project_id) -> if not agent: -> raise HTTPException( -> status_code=400, -> detail="Lead Agent not started for this project. Start the agent first." -> ) - -> try: - # Send message to Lead Agent -> response_text = agent.chat(user_message) - - # Get current timestamp -> timestamp = datetime.now(UTC).isoformat().replace('+00:00', 'Z') - - # Broadcast assistant response via WebSocket -> try: -> await manager.broadcast({ -> "type": "chat_message", -> "project_id": project_id, -> "role": "assistant", -> "content": response_text, -> "timestamp": timestamp -> }) -> except Exception: - # Continue even if broadcast fails -- pass - -> return { -> "response": response_text, -> "timestamp": timestamp -> } - -> except Exception as e: - # Log error and return 500 -> raise HTTPException( -> status_code=500, -> detail=f"Error communicating with Lead Agent: {str(e)}" -> ) - - -> @app.get("/api/projects/{project_id}/chat/history") -> async def get_chat_history( -> project_id: int, -> limit: int = 100, -> offset: int = 0 -> ): -> """Get conversation history for a project (cf-14.1). - -> Args: -> project_id: Project ID -> limit: Maximum messages to return (default: 100) -> offset: Number of messages to skip (default: 0) - -> Returns: -> Dict with 'messages' list containing conversation history - -> Raises: -> HTTPException: -> - 404: Project not found -> """ - # Check if project exists -> project = app.state.db.get_project(project_id) -> if not project: -> raise HTTPException( -> status_code=404, -> detail=f"Project {project_id} not found" -> ) - - # Get conversation history from database -> db_messages = app.state.db.get_conversation(project_id) - - # Apply pagination -> start = offset -> end = offset + limit -> paginated_messages = db_messages[start:end] - - # Format messages for API response -> messages = [] -> for msg in paginated_messages: -> messages.append({ -> "role": msg["key"], # 'user' or 'assistant' -> "content": msg["value"], -> "timestamp": msg["created_at"] -> }) - -> return {"messages": messages} - - -> @app.post("/api/projects/{project_id}/pause") -> async def pause_project(project_id: int): -> """Pause project execution.""" - # TODO: Trigger flash save and pause agents -! return {"success": True, "message": "Project paused"} - - -> @app.post("/api/projects/{project_id}/resume") -> async def resume_project(project_id: int): -> """Resume project execution.""" - # TODO: Restore from checkpoint and resume agents -! return {"success": True, "message": "Project resuming"} - - - # WebSocket for real-time updates - -> @app.websocket("/ws") -> async def websocket_endpoint(websocket: WebSocket): -> """WebSocket connection for real-time updates.""" -! await manager.connect(websocket) -! try: -! while True: - # Keep connection alive and handle incoming messages -! data = await websocket.receive_text() -! message = json.loads(data) - - # Handle different message types -! if message.get("type") == "ping": -! await websocket.send_json({"type": "pong"}) -! elif message.get("type") == "subscribe": - # Subscribe to specific project updates -! project_id = message.get("project_id") - # TODO: Track subscriptions -! await websocket.send_json({ -! "type": "subscribed", -! "project_id": project_id -! }) - -! except WebSocketDisconnect: -! manager.disconnect(websocket) - - - # Background task to broadcast updates -> async def broadcast_updates(): -> """Periodically broadcast project updates to connected clients.""" -! while True: -! await asyncio.sleep(5) # Update every 5 seconds - - # TODO: Gather latest project state -! update = { -! "type": "status_update", -! "timestamp": "2025-01-15T14:35:00Z", -! "data": { -! "progress": 65, -! "active_agents": 3, -! "completed_tasks": 26 -! } -! } - -! await manager.broadcast(update) - - -> def run_server(host: str = "0.0.0.0", port: int = 8080): -> """Run the Status Server.""" -! import uvicorn -! uvicorn.run(app, host=host, port=port) - - -- if __name__ == "__main__": -- import argparse - - # Parse command line arguments -- parser = argparse.ArgumentParser(description="CodeFRAME Status Server") -- parser.add_argument( -! "--host", -! type=str, -! default=os.environ.get("HOST", "0.0.0.0"), -! help="Host to bind to (default: 0.0.0.0 or HOST env var)" -! ) -- parser.add_argument( -! "--port", -! type=int, -! default=int(os.environ.get("BACKEND_PORT", os.environ.get("PORT", "8080"))), -! help="Port to bind to (default: 8080 or BACKEND_PORT/PORT env var)" -! ) - -- args = parser.parse_args() - -- run_server(host=args.host, port=args.port) diff --git a/scripts/audit_mocked_tests.py b/scripts/audit_mocked_tests.py index 6fe67119..f266dc4e 100644 --- a/scripts/audit_mocked_tests.py +++ b/scripts/audit_mocked_tests.py @@ -59,7 +59,7 @@ class AuditResult: # Patterns that indicate excessive mocking (mocking core functionality) CORE_FUNCTIONALITY_PATTERNS = [ # Database operations - should use real in-memory database - ("codeframe.persistence.database.Database", "database operations"), + ("codeframe.platform_store.database.Database", "database operations"), ("db.create_", "database creation methods"), ("db.get_", "database retrieval methods"), ("db.update_", "database update methods"), diff --git a/tests/api/test_health_endpoint.py b/tests/api/test_health_endpoint.py index 33b88d33..0bf4fcf1 100644 --- a/tests/api/test_health_endpoint.py +++ b/tests/api/test_health_endpoint.py @@ -38,4 +38,3 @@ def test_health_endpoint_structure(client): assert "version" in data assert "commit" in data assert "deployed_at" in data - assert "database" in data diff --git a/tests/auth/test_api_key_endpoints.py b/tests/auth/test_api_key_endpoints.py deleted file mode 100644 index 2549a4ed..00000000 --- a/tests/auth/test_api_key_endpoints.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Tests for API key management endpoints. - -Following TDD: tests written first, implementation follows. -""" - -import os -import pytest -import jwt -from datetime import datetime, timedelta, timezone -from fastapi.testclient import TestClient - -from codeframe.persistence.database import Database -from codeframe.auth.api_keys import generate_api_key, SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN - - -@pytest.fixture -def db(tmp_path): - """Create test database.""" - from codeframe.auth.manager import reset_auth_engine - - db_path = tmp_path / "test_api_key_endpoints.db" - - # Set DATABASE_PATH so auth engine uses the same database - original_db_path = os.environ.get("DATABASE_PATH") - os.environ["DATABASE_PATH"] = str(db_path) - - # Reset auth engine to pick up new DATABASE_PATH - reset_auth_engine() - - db = Database(db_path) - db.initialize() - - # Create test users - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'alice@example.com', 'Alice', '!DISABLED!', 1, 0, 1, 1), - (2, 'bob@example.com', 'Bob', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - yield db - - # Restore original DATABASE_PATH - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - elif "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] - - reset_auth_engine() - - -@pytest.fixture -def client(db): - """Create test client with database injection. - - Sets app.state.db directly to ensure the test database is used - by all routes that access request.app.state.db. - """ - from codeframe.ui import server - - # Store original db (if any) and inject test db - original_db = getattr(server.app.state, "db", None) - server.app.state.db = db - - client = TestClient(server.app) - yield client - - # Restore original db - if original_db is not None: - server.app.state.db = original_db - - -def create_jwt_token(user_id: int, secret: str = "CHANGE-ME-IN-PRODUCTION") -> str: - """Create a JWT token for testing.""" - from codeframe.auth.manager import JWT_ALGORITHM, JWT_AUDIENCE, JWT_LIFETIME_SECONDS - - now = datetime.now(timezone.utc) - payload = { - "sub": str(user_id), - "aud": JWT_AUDIENCE, - "exp": now + timedelta(seconds=JWT_LIFETIME_SECONDS), - "iat": now, - } - return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) - - -class TestCreateApiKey: - """Tests for POST /api/auth/api-keys endpoint.""" - - def test_create_api_key_success(self, client, db): - """Create API key with valid JWT returns key details.""" - token = create_jwt_token(user_id=1) - - response = client.post( - "/api/auth/api-keys", - json={"name": "My New Key", "scopes": ["read", "write"]}, - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 201 - data = response.json() - - assert "key" in data # Full key shown only once - assert "id" in data - assert "prefix" in data - assert "created_at" in data - assert data["key"].startswith("cf_live_") - assert data["prefix"] == data["key"][:12] - - def test_create_api_key_requires_jwt(self, client, db): - """Create API key requires JWT authentication (not API key).""" - # First create an API key to try using it - _, key_hash, prefix = generate_api_key() - db.api_keys.create( - user_id=1, - name="Existing Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN], - expires_at=None, - ) - - # Try to create new key using API key auth - full_key = f"cf_live_{prefix[8:]}{'a' * 28}" # Fake but right format - - response = client.post( - "/api/auth/api-keys", - json={"name": "New Key", "scopes": ["read"]}, - headers={"X-API-Key": full_key}, - ) - - # Should fail - API key creation requires JWT - assert response.status_code == 401 - - def test_create_api_key_no_auth(self, client): - """Create API key without authentication returns 401.""" - response = client.post( - "/api/auth/api-keys", - json={"name": "My Key", "scopes": ["read"]}, - ) - - assert response.status_code == 401 - - def test_create_api_key_invalid_scopes(self, client): - """Create API key with invalid scopes returns 422 (validation error).""" - token = create_jwt_token(user_id=1) - - response = client.post( - "/api/auth/api-keys", - json={"name": "My Key", "scopes": ["invalid_scope"]}, - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 422 # FastAPI validation error - - def test_create_api_key_with_expiration(self, client, db): - """Create API key with expiration date.""" - token = create_jwt_token(user_id=1) - expires = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat() - - response = client.post( - "/api/auth/api-keys", - json={"name": "Expiring Key", "scopes": ["read"], "expires_at": expires}, - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 201 - - -class TestListApiKeys: - """Tests for GET /api/auth/api-keys endpoint.""" - - def test_list_api_keys_success(self, client, db): - """List API keys returns user's keys.""" - # Create some keys for user 1 - for i, name in enumerate(["Key A", "Key B"]): - _, key_hash, prefix = generate_api_key() - db.api_keys.create( - user_id=1, - name=name, - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - token = create_jwt_token(user_id=1) - response = client.get( - "/api/auth/api-keys", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 200 - data = response.json() - - assert len(data) == 2 - assert {k["name"] for k in data} == {"Key A", "Key B"} - - def test_list_api_keys_no_hash_exposed(self, client, db): - """List API keys does not expose key hashes.""" - _, key_hash, prefix = generate_api_key() - db.api_keys.create( - user_id=1, - name="My Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - token = create_jwt_token(user_id=1) - response = client.get( - "/api/auth/api-keys", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 200 - data = response.json() - - for key in data: - assert "key_hash" not in key - assert "key" not in key # Full key also not exposed - - def test_list_api_keys_with_api_key_auth(self, client, db): - """List API keys works with API key authentication.""" - full_key, key_hash, prefix = generate_api_key() - db.api_keys.create( - user_id=1, - name="Auth Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - response = client.get( - "/api/auth/api-keys", - headers={"X-API-Key": full_key}, - ) - - assert response.status_code == 200 - - def test_list_api_keys_no_auth(self, client): - """List API keys without authentication returns 401.""" - response = client.get("/api/auth/api-keys") - assert response.status_code == 401 - - -class TestRevokeApiKey: - """Tests for DELETE /api/auth/api-keys/{key_id} endpoint.""" - - def test_revoke_api_key_success(self, client, db): - """Revoke API key successfully.""" - _, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="To Revoke", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - token = create_jwt_token(user_id=1) - response = client.delete( - f"/api/auth/api-keys/{key_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 200 - - # Verify key is revoked - record = db.api_keys.get_by_id(key_id) - assert record["is_active"] is False - - def test_revoke_api_key_not_owner(self, client, db): - """Cannot revoke another user's API key.""" - _, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, # User 1 owns this key - name="User 1 Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - # User 2 tries to revoke - token = create_jwt_token(user_id=2) - response = client.delete( - f"/api/auth/api-keys/{key_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 404 # Not found (from user's perspective) - - # Verify key is still active - record = db.api_keys.get_by_id(key_id) - assert record["is_active"] is True - - def test_revoke_api_key_not_found(self, client): - """Revoke non-existent API key returns 404.""" - token = create_jwt_token(user_id=1) - response = client.delete( - "/api/auth/api-keys/nonexistent-id", - headers={"Authorization": f"Bearer {token}"}, - ) - - assert response.status_code == 404 - - def test_revoke_api_key_no_auth(self, client, db): - """Revoke API key without authentication returns 401.""" - _, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="Some Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - response = client.delete(f"/api/auth/api-keys/{key_id}") - assert response.status_code == 401 - - -class TestApiKeyUsageAfterRevoke: - """Tests for using revoked API keys.""" - - def test_revoked_key_cannot_authenticate(self, client, db): - """Revoked API key cannot be used for authentication.""" - full_key, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="To Revoke", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - - # Verify key works before revocation - response = client.get( - "/api/auth/api-keys", - headers={"X-API-Key": full_key}, - ) - assert response.status_code == 200 - - # Revoke the key - db.api_keys.revoke(key_id, user_id=1) - - # Verify key no longer works - response = client.get( - "/api/auth/api-keys", - headers={"X-API-Key": full_key}, - ) - assert response.status_code == 401 diff --git a/tests/auth/test_api_key_repository.py b/tests/auth/test_api_key_repository.py index 74f362f5..0c911f50 100644 --- a/tests/auth/test_api_key_repository.py +++ b/tests/auth/test_api_key_repository.py @@ -6,7 +6,7 @@ import pytest from datetime import datetime, timedelta, timezone -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database from codeframe.auth.api_keys import generate_api_key, SCOPE_READ, SCOPE_WRITE diff --git a/tests/auth/test_authorization_integration.py b/tests/auth/test_authorization_integration.py deleted file mode 100644 index 4030f7c3..00000000 --- a/tests/auth/test_authorization_integration.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -Authorization integration tests for FastAPI endpoints. - -Tests representative endpoints across different routers to ensure -authorization is properly enforced. - -NOTE: These tests use JWT tokens for authentication with FastAPI Users. -Cross-user authorization (Bob accessing Alice's resources) depends on -endpoint-level authorization checks, which may not be fully implemented. -""" - -import jwt -import pytest -from datetime import datetime, timedelta, timezone -from fastapi.testclient import TestClient - -from codeframe.persistence.database import Database -from codeframe.ui.server import app -from codeframe.auth.manager import SECRET, JWT_LIFETIME_SECONDS - - -@pytest.fixture -def db(tmp_path): - """Create test database.""" - import os - from codeframe.auth.manager import reset_auth_engine - - db_path = tmp_path / "test_integration.db" - - # Set DATABASE_PATH so auth engine uses the same database - original_db_path = os.environ.get("DATABASE_PATH") - os.environ["DATABASE_PATH"] = str(db_path) - - # Reset auth engine to pick up new DATABASE_PATH - reset_auth_engine() - - db = Database(db_path) - db.initialize() - - # Create test users (FastAPI Users schema) - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES - (1, 'alice@example.com', 'Alice', '!DISABLED!', 1, 0, 1, 1), - (2, 'bob@example.com', 'Bob', '!DISABLED!', 1, 0, 1, 1) - """ - ) - - # Create test projects - db.conn.execute( - """ - INSERT INTO projects (id, name, description, user_id, workspace_path, status) - VALUES - (1, 'Alice Project', 'Test', 1, '/tmp/alice', 'init'), - (2, 'Bob Project', 'Test', 2, '/tmp/bob', 'init') - """ - ) - - db.conn.commit() - yield db - db.close() - - # Restore original DATABASE_PATH - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - elif "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] - - # Reset auth engine for next test - reset_auth_engine() - - -@pytest.fixture -def client(db): - """Create FastAPI test client with database.""" - app.state.db = db - return TestClient(app) - - -@pytest.fixture -def alice_token(): - """Create JWT token for Alice (user_id=1).""" - payload = { - "sub": "1", # User ID as string - "aud": ["fastapi-users:auth"], - "exp": datetime.now(timezone.utc) + timedelta(seconds=JWT_LIFETIME_SECONDS), - } - return jwt.encode(payload, SECRET, algorithm="HS256") - - -@pytest.fixture -def bob_token(): - """Create JWT token for Bob (user_id=2).""" - payload = { - "sub": "2", # User ID as string - "aud": ["fastapi-users:auth"], - "exp": datetime.now(timezone.utc) + timedelta(seconds=JWT_LIFETIME_SECONDS), - } - return jwt.encode(payload, SECRET, algorithm="HS256") - - -class TestProjectEndpointsAuthorization: - """Test authorization on /api/projects endpoints.""" - - def test_get_project_owner_has_access(self, client, alice_token): - """Test that project owner can access their project.""" - response = client.get("/api/projects/1", headers={"Authorization": f"Bearer {alice_token}"}) - assert response.status_code == 200 - assert response.json()["id"] == 1 - - def test_get_project_non_owner_denied(self, client, bob_token): - """Test that non-owner cannot access project.""" - response = client.get("/api/projects/1", headers={"Authorization": f"Bearer {bob_token}"}) - assert response.status_code == 403 - assert response.json()["detail"] == "Access denied" - - def test_get_project_no_token_unauthorized(self, client): - """Test that request without token returns 401 Unauthorized.""" - response = client.get("/api/projects/1") - # Authentication is always required - expect 401 Unauthorized - assert response.status_code == 401 - - -class TestTaskEndpointsAuthorization: - """Test authorization on /api/tasks endpoints.""" - - def test_create_task_requires_project_access(self, client, alice_token, bob_token, db): - """Test that creating task requires access to project.""" - # Alice can create task in her project - response = client.post( - "/api/tasks", - headers={"Authorization": f"Bearer {alice_token}"}, - json={"project_id": 1, "title": "Test Task", "description": "Test", "priority": 3}, - ) - assert response.status_code in [200, 201] - - # Bob cannot create task in Alice's project - response = client.post( - "/api/tasks", - headers={"Authorization": f"Bearer {bob_token}"}, - json={ - "project_id": 1, - "title": "Unauthorized Task", - "description": "Test", - "priority": 3, - }, - ) - assert response.status_code == 403 - - -class TestMetricsEndpointsAuthorization: - """Test authorization on /api/projects/{id}/metrics endpoints.""" - - def test_get_project_costs_requires_access(self, client, alice_token, bob_token): - """Test that metrics endpoints enforce project access.""" - # Alice can access her project metrics - response = client.get( - "/api/projects/1/metrics/costs", headers={"Authorization": f"Bearer {alice_token}"} - ) - assert response.status_code == 200 - - # Bob cannot access Alice's project metrics - response = client.get( - "/api/projects/1/metrics/costs", headers={"Authorization": f"Bearer {bob_token}"} - ) - assert response.status_code == 403 - - -class TestCrossProjectDataLeak: - """Test that agent metrics don't leak cross-project data.""" - - def test_agent_metrics_filtered_by_user_access(self, client, alice_token, bob_token, db): - """Test that /api/agents/{id}/metrics filters by accessible projects.""" - # Create agent with home project - db.conn.execute( - """ - INSERT INTO agents (id, type, status, project_id) - VALUES ('test_agent', 'backend', 'idle', 1) - """ - ) - - # Assign agent to both projects via junction table - db.conn.execute( - """ - INSERT INTO project_agents (project_id, agent_id, role, is_active) - VALUES - (1, 'test_agent', 'backend', TRUE), - (2, 'test_agent', 'backend', TRUE) - """ - ) - - # Create token usage for both projects - db.conn.execute( - """ - INSERT INTO token_usage (agent_id, project_id, model_name, input_tokens, output_tokens, estimated_cost_usd, call_type) - VALUES - ('test_agent', 1, 'claude-sonnet-4-5', 1000, 500, 0.01, 'task_execution'), - ('test_agent', 2, 'claude-sonnet-4-5', 2000, 1000, 0.02, 'task_execution') - """ - ) - db.conn.commit() - - # Alice should only see metrics for her project - response = client.get( - "/api/agents/test_agent/metrics", headers={"Authorization": f"Bearer {alice_token}"} - ) - assert response.status_code == 200 - data = response.json() - - # Verify only Alice's project appears in by_project breakdown - project_ids = [p["project_id"] for p in data["by_project"]] - assert 1 in project_ids - assert 2 not in project_ids # Bob's project shouldn't leak - - -class TestExceptionHandling: - """Test that authorization exceptions aren't masked by generic handlers.""" - - def test_review_status_403_not_masked(self, client, bob_token, db): - """Test that 403 from review endpoints isn't converted to 500.""" - # Create task in Alice's project - db.conn.execute( - """ - INSERT INTO tasks (id, project_id, title, description, status, priority) - VALUES (1, 1, 'Test Task', 'Test', 'pending', 3) - """ - ) - db.conn.commit() - - # Bob tries to access Alice's task review status - response = client.get( - "/api/tasks/1/review-status", headers={"Authorization": f"Bearer {bob_token}"} - ) - - # Should return 403, not 500 - assert response.status_code == 403 - assert "Access denied" in response.json()["detail"] diff --git a/tests/auth/test_dual_auth.py b/tests/auth/test_dual_auth.py deleted file mode 100644 index 76b79bd3..00000000 --- a/tests/auth/test_dual_auth.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Tests for dual authentication (API key + JWT) dependency. - -Following TDD: tests written first, implementation follows. -""" - -import os -import pytest -import jwt -from datetime import datetime, timedelta, timezone -from unittest.mock import MagicMock -from fastapi import HTTPException - -from codeframe.persistence.database import Database -from codeframe.auth.api_keys import generate_api_key, SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN - - -@pytest.fixture -def db(tmp_path): - """Create test database.""" - from codeframe.auth.manager import reset_auth_engine - - db_path = tmp_path / "test_dual_auth.db" - - # Set DATABASE_PATH so auth engine uses the same database - original_db_path = os.environ.get("DATABASE_PATH") - os.environ["DATABASE_PATH"] = str(db_path) - - # Reset auth engine to pick up new DATABASE_PATH - reset_auth_engine() - - db = Database(db_path) - db.initialize() - - # Create test users - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'alice@example.com', 'Alice', '!DISABLED!', 1, 0, 1, 1), - (2, 'inactive@example.com', 'Inactive', '!DISABLED!', 0, 0, 1, 1) - """ - ) - db.conn.commit() - - yield db - - # Restore original DATABASE_PATH - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - elif "DATABASE_PATH" in os.environ: - del os.environ["DATABASE_PATH"] - - reset_auth_engine() - - -@pytest.fixture -def api_key_for_user_1(db): - """Create an API key for user 1.""" - full_key, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="Test Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ, SCOPE_WRITE], - expires_at=None, - ) - return full_key, key_id - - -@pytest.fixture -def admin_api_key(db): - """Create an admin-scoped API key for user 1.""" - full_key, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="Admin Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN], - expires_at=None, - ) - return full_key, key_id - - -@pytest.fixture -def read_only_api_key(db): - """Create a read-only API key for user 1.""" - full_key, key_hash, prefix = generate_api_key() - key_id = db.api_keys.create( - user_id=1, - name="Read-Only Key", - key_hash=key_hash, - prefix=prefix, - scopes=[SCOPE_READ], - expires_at=None, - ) - return full_key, key_id - - -def create_jwt_token(user_id: int, secret: str = "CHANGE-ME-IN-PRODUCTION") -> str: - """Create a JWT token for testing.""" - from codeframe.auth.manager import JWT_ALGORITHM, JWT_AUDIENCE, JWT_LIFETIME_SECONDS - - now = datetime.now(timezone.utc) - payload = { - "sub": str(user_id), - "aud": JWT_AUDIENCE, - "exp": now + timedelta(seconds=JWT_LIFETIME_SECONDS), - "iat": now, - } - return jwt.encode(payload, secret, algorithm=JWT_ALGORITHM) - - -class TestGetApiKeyAuth: - """Tests for API key authentication extraction.""" - - @pytest.mark.asyncio - async def test_api_key_auth_valid(self, db, api_key_for_user_1): - """Valid API key returns auth dict.""" - from codeframe.auth.dependencies import get_api_key_auth - - full_key, _ = api_key_for_user_1 - - # Create mock request with database in app.state (preferred) and request.state (fallback) - mock_request = MagicMock() - mock_request.app.state.db = db - mock_request.state.db = db - - result = await get_api_key_auth(api_key=full_key, request=mock_request) - - assert result is not None - assert result["type"] == "api_key" - assert result["user_id"] == 1 - assert result["scopes"] == [SCOPE_READ, SCOPE_WRITE] - - @pytest.mark.asyncio - async def test_api_key_auth_no_header(self, db): - """No API key header returns None.""" - from codeframe.auth.dependencies import get_api_key_auth - - mock_request = MagicMock() - mock_request.app.state.db = db - mock_request.state.db = db - - result = await get_api_key_auth(api_key=None, request=mock_request) - assert result is None - - @pytest.mark.asyncio - async def test_api_key_auth_invalid_key(self, db): - """Invalid API key returns None.""" - from codeframe.auth.dependencies import get_api_key_auth - - mock_request = MagicMock() - mock_request.app.state.db = db - mock_request.state.db = db - - result = await get_api_key_auth(api_key="cf_live_invalid_key_000000000", request=mock_request) - assert result is None - - @pytest.mark.asyncio - async def test_api_key_auth_revoked_key(self, db, api_key_for_user_1): - """Revoked API key returns None.""" - from codeframe.auth.dependencies import get_api_key_auth - - full_key, key_id = api_key_for_user_1 - db.api_keys.revoke(key_id, user_id=1) - - mock_request = MagicMock() - mock_request.app.state.db = db - mock_request.state.db = db - - result = await get_api_key_auth(api_key=full_key, request=mock_request) - assert result is None - - -class TestRequireAuth: - """Tests for dual authentication requirement.""" - - @pytest.mark.asyncio - async def test_require_auth_with_api_key(self, db, api_key_for_user_1): - """API key authentication works.""" - from codeframe.auth.dependencies import require_auth - - full_key, _ = api_key_for_user_1 - api_key_auth = { - "type": "api_key", - "user_id": 1, - "scopes": [SCOPE_READ, SCOPE_WRITE], - "key_id": "test-key-id", - } - - result = await require_auth(api_key_auth=api_key_auth, jwt_user=None) - - assert result["type"] == "api_key" - assert result["user_id"] == 1 - - @pytest.mark.asyncio - async def test_require_auth_with_jwt(self): - """JWT authentication works.""" - from codeframe.auth.dependencies import require_auth - from codeframe.auth.models import User - - # Create mock user - mock_user = MagicMock(spec=User) - mock_user.id = 1 - mock_user.email = "alice@example.com" - - result = await require_auth(api_key_auth=None, jwt_user=mock_user) - - assert result["type"] == "jwt" - assert result["user_id"] == 1 - # JWT users get all scopes - assert SCOPE_ADMIN in result["scopes"] - - @pytest.mark.asyncio - async def test_require_auth_no_credentials(self): - """No credentials raises 401.""" - from codeframe.auth.dependencies import require_auth - - with pytest.raises(HTTPException) as exc_info: - await require_auth(api_key_auth=None, jwt_user=None) - - assert exc_info.value.status_code == 401 - - @pytest.mark.asyncio - async def test_require_auth_prefers_api_key(self): - """When both present, API key takes precedence.""" - from codeframe.auth.dependencies import require_auth - from codeframe.auth.models import User - - api_key_auth = { - "type": "api_key", - "user_id": 1, - "scopes": [SCOPE_READ], - "key_id": "test-key-id", - } - - mock_user = MagicMock(spec=User) - mock_user.id = 2 # Different user - - result = await require_auth(api_key_auth=api_key_auth, jwt_user=mock_user) - - # Should use API key auth, not JWT - assert result["type"] == "api_key" - assert result["user_id"] == 1 - - -class TestRequireScope: - """Tests for scope-based authorization.""" - - @pytest.mark.asyncio - async def test_require_scope_has_scope(self): - """Principal with required scope passes.""" - from codeframe.auth.dependencies import require_scope - - auth = { - "type": "api_key", - "user_id": 1, - "scopes": [SCOPE_READ, SCOPE_WRITE], - } - - # Should not raise - checker = require_scope(SCOPE_WRITE) - result = await checker(auth=auth) - assert result == auth - - @pytest.mark.asyncio - async def test_require_scope_missing_scope(self): - """Principal without required scope raises 403.""" - from codeframe.auth.dependencies import require_scope - - auth = { - "type": "api_key", - "user_id": 1, - "scopes": [SCOPE_READ], # Only read, not write - } - - checker = require_scope(SCOPE_WRITE) - with pytest.raises(HTTPException) as exc_info: - await checker(auth=auth) - - assert exc_info.value.status_code == 403 - - @pytest.mark.asyncio - async def test_require_scope_admin_has_all(self): - """Admin scope implies all other scopes.""" - from codeframe.auth.dependencies import require_scope - - auth = { - "type": "api_key", - "user_id": 1, - "scopes": [SCOPE_ADMIN], - } - - # Admin should pass any scope check - for scope in [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN]: - checker = require_scope(scope) - result = await checker(auth=auth) - assert result == auth - - -class TestScopeHierarchy: - """Tests for scope hierarchy logic.""" - - def test_has_scope_direct_match(self): - """Direct scope match returns True.""" - from codeframe.auth.scopes import has_scope - - auth = {"scopes": [SCOPE_READ]} - assert has_scope(auth, SCOPE_READ) is True - - def test_has_scope_admin_grants_all(self): - """Admin scope grants all permissions.""" - from codeframe.auth.scopes import has_scope - - auth = {"scopes": [SCOPE_ADMIN]} - assert has_scope(auth, SCOPE_READ) is True - assert has_scope(auth, SCOPE_WRITE) is True - assert has_scope(auth, SCOPE_ADMIN) is True - - def test_has_scope_write_grants_read(self): - """Write scope grants read permission.""" - from codeframe.auth.scopes import has_scope - - auth = {"scopes": [SCOPE_WRITE]} - assert has_scope(auth, SCOPE_READ) is True - assert has_scope(auth, SCOPE_WRITE) is True - assert has_scope(auth, SCOPE_ADMIN) is False - - def test_has_scope_read_only(self): - """Read scope only grants read permission.""" - from codeframe.auth.scopes import has_scope - - auth = {"scopes": [SCOPE_READ]} - assert has_scope(auth, SCOPE_READ) is True - assert has_scope(auth, SCOPE_WRITE) is False - assert has_scope(auth, SCOPE_ADMIN) is False diff --git a/tests/blockers/test_blocker_expiration_simple.py b/tests/blockers/test_blocker_expiration_simple.py deleted file mode 100644 index df41595c..00000000 --- a/tests/blockers/test_blocker_expiration_simple.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Simplified tests for blocker expiration functionality (049-human-in-loop, Phase 8).""" - -from datetime import datetime, timedelta - -import pytest - -from codeframe.persistence.database import Database - - -@pytest.fixture -def temp_db(): - """Create a temporary in-memory database for testing.""" - # Use in-memory database for speed (no migrations needed) - db = Database(":memory:") - db.initialize() - - # Create test project - cursor = db.conn.execute( - """INSERT INTO projects (name, description, workspace_path, status) - VALUES (?, ?, ?, ?) - RETURNING id""", - ("test-project", "Test project", "/tmp/test-workspace", "active"), - ) - project_id = cursor.fetchone()[0] - - # Create test task to satisfy foreign key constraints - db.conn.execute( - """INSERT INTO tasks (id, project_id, title, description, status, priority) - VALUES (?, ?, ?, ?, ?, ?)""", - (1, project_id, "Test Task", "Test task for blocker tests", "pending", 0), - ) - db.conn.commit() - - yield db - - # Cleanup - db.close() - - -class TestExpireStaleBlockers: - """Test suite for expire_stale_blockers() database method.""" - - def test_expire_stale_blockers_no_blockers(self, temp_db): - """Test expire_stale_blockers with no blockers.""" - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert expired_ids == [] - - def test_expire_stale_blockers_pending_within_threshold(self, temp_db): - """Test expire_stale_blockers doesn't expire recent blockers.""" - # Create blocker 2 hours ago (within 24h threshold) - recent_time = (datetime.now() - timedelta(hours=2)).isoformat() - - temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ("backend-worker-1", 1, 1, "SYNC", "Test question?", "PENDING", recent_time), - ) - temp_db.conn.commit() - - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert expired_ids == [] - - def test_expire_stale_blockers_pending_beyond_threshold(self, temp_db): - """Test expire_stale_blockers expires stale blockers.""" - # Create blocker 25 hours ago (beyond 24h threshold) - stale_time = (datetime.now() - timedelta(hours=25)).isoformat() - - cursor = temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id""", - ("backend-worker-1", 1, 1, "SYNC", "Stale question?", "PENDING", stale_time), - ) - blocker_id = cursor.fetchone()[0] - temp_db.conn.commit() - - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert len(expired_ids) == 1 - assert expired_ids[0] == blocker_id - - # Verify status was updated to EXPIRED - blocker = temp_db.get_blocker(blocker_id) - assert blocker["status"] == "EXPIRED" - - def test_expire_stale_blockers_custom_threshold(self, temp_db): - """Test expire_stale_blockers with custom hour threshold.""" - # Create blocker 3 hours ago - stale_time = (datetime.now() - timedelta(hours=3)).isoformat() - - cursor = temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id""", - ("backend-worker-1", 1, 1, "SYNC", "Question?", "PENDING", stale_time), - ) - blocker_id = cursor.fetchone()[0] - temp_db.conn.commit() - - # Expire with 2-hour threshold (blocker is 3 hours old, should expire) - expired_ids = temp_db.expire_stale_blockers(hours=2) - - assert len(expired_ids) == 1 - assert expired_ids[0] == blocker_id - - def test_expire_stale_blockers_ignores_resolved(self, temp_db): - """Test expire_stale_blockers ignores already resolved blockers.""" - # Create old blocker but already resolved - stale_time = (datetime.now() - timedelta(hours=25)).isoformat() - - temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at, answer) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ("backend-worker-1", 1, 1, "SYNC", "Question?", "RESOLVED", stale_time, "Answer"), - ) - temp_db.conn.commit() - - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert expired_ids == [] - - def test_expire_stale_blockers_ignores_already_expired(self, temp_db): - """Test expire_stale_blockers ignores already expired blockers.""" - # Create old blocker but already expired - stale_time = (datetime.now() - timedelta(hours=25)).isoformat() - - temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ("backend-worker-1", 1, 1, "SYNC", "Question?", "EXPIRED", stale_time), - ) - temp_db.conn.commit() - - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert expired_ids == [] - - def test_expire_stale_blockers_multiple_blockers(self, temp_db): - """Test expire_stale_blockers handles multiple blockers correctly.""" - stale_time = (datetime.now() - timedelta(hours=25)).isoformat() - recent_time = (datetime.now() - timedelta(hours=2)).isoformat() - - # Insert 2 stale blockers (all using task_id=1 to avoid FK constraints) - cursor1 = temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id""", - ("backend-worker-1", 1, 1, "SYNC", "Stale 1?", "PENDING", stale_time), - ) - stale_id_1 = cursor1.fetchone()[0] - - cursor2 = temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id""", - ("backend-worker-2", 1, 1, "ASYNC", "Stale 2?", "PENDING", stale_time), - ) - stale_id_2 = cursor2.fetchone()[0] - - # Insert 1 recent blocker - temp_db.conn.execute( - """INSERT INTO blockers (agent_id, project_id, task_id, blocker_type, question, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?)""", - ("backend-worker-3", 1, 1, "SYNC", "Recent?", "PENDING", recent_time), - ) - temp_db.conn.commit() - - expired_ids = temp_db.expire_stale_blockers(hours=24) - - assert len(expired_ids) == 2 - assert stale_id_1 in expired_ids - assert stale_id_2 in expired_ids diff --git a/tests/cli/test_api_key_commands.py b/tests/cli/test_api_key_commands.py index 35ef8410..339a2557 100644 --- a/tests/cli/test_api_key_commands.py +++ b/tests/cli/test_api_key_commands.py @@ -9,7 +9,7 @@ from unittest.mock import patch from codeframe.cli.app import app -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database from codeframe.auth.api_keys import generate_api_key, SCOPE_READ, SCOPE_WRITE # Mark all tests in this module as v2 tests diff --git a/tests/conftest.py b/tests/conftest.py index 4d9583ac..691daa9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,26 +8,8 @@ from typing import Generator import pytest -# Skip v1 legacy tests that depend on removed v1 persistence layer (app.state.db) -# These tests use v1 routers/APIs that were removed in the v2 refactor -# NOTE: collect_ignore must be at module level but can come after imports -collect_ignore = [ - # v1 API tests (use app.state.db) - "api/test_chat_api.py", - "api/test_discovery_restart.py", - "api/test_generate_tasks_endpoint.py", - "api/test_health_endpoint.py", - "api/test_project_creation_api.py", - "api/test_schedule_api.py", - "api/test_templates_api.py", - "api/test_workspace_cleanup.py", - # v1 agent tests (use v1 routers) - "agents/test_agent_lifecycle.py", - # v1 auth tests (use v1 routers) - "auth/test_api_key_endpoints.py", - "auth/test_authorization_integration.py", - "auth/test_dual_auth.py", -] +# All v1 legacy tests have been removed; nothing to ignore at the root. +collect_ignore: list[str] = [] def create_test_jwt_token(user_id: int = 1, secret: str = None) -> str: @@ -139,33 +121,6 @@ def set_env(key: str, value: str) -> None: return env -@pytest.fixture -def sample_project_config() -> dict: - """Provide sample project configuration data. - - Returns: - Dictionary with valid project configuration - """ - return { - "project_name": "test-project", - "project_type": "python", - "providers": { - "lead_agent": "claude", - "backend_agent": "claude", - "frontend_agent": "gpt4", - }, - "agent_policy": { - "require_review_below_maturity": "supporting", - "allow_full_autonomy": False, - }, - "interruption_mode": { - "enabled": True, - "sync_blockers": ["requirement", "security"], - "async_blockers": ["technical", "external"], - }, - } - - @pytest.fixture(autouse=True) def reset_singletons(): """Reset any singleton instances between tests. diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index c45cc1ca..3f920529 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -56,7 +56,7 @@ function initializeTestDatabase(): void { try { console.log('🔧 Creating database schema...'); const projectRoot = path.resolve(__dirname, '../..'); - const pythonCode = `from codeframe.persistence.database import Database; import sys; db = Database(sys.argv[1]); db.initialize()`; + const pythonCode = `from codeframe.platform_store.database import Database; import sys; db = Database(sys.argv[1]); db.initialize()`; const result = spawnSync('uv', ['run', 'python', '-c', pythonCode, TEST_DB_PATH], { cwd: projectRoot, stdio: 'inherit', diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8d405c4f..b995874c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -21,7 +21,7 @@ async def test_something(real_db, test_workspace, mock_llm_api): import pytest -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database # ============================================================================= diff --git a/tests/lib/test_metrics_tracker.py b/tests/lib/test_metrics_tracker.py deleted file mode 100644 index e1ca92da..00000000 --- a/tests/lib/test_metrics_tracker.py +++ /dev/null @@ -1,762 +0,0 @@ -"""Tests for MetricsTracker (Sprint 10 Phase 5: Metrics and Cost Tracking). - -Following TDD approach - these tests are written FIRST and will initially fail. -""" - -import pytest -from datetime import datetime, timedelta, timezone -from codeframe.lib.metrics_tracker import MetricsTracker -from codeframe.core.models import CallType -from codeframe.persistence.database import Database - - -@pytest.fixture -def db(): - """Create in-memory database for testing.""" - database = Database(":memory:") - database.initialize() - - # Create test project - cursor = database.conn.cursor() - cursor.execute( - "INSERT INTO projects (name, description, workspace_path, status) VALUES (?, ?, ?, ?)", - ("test-project", "Test project", "/tmp/test", "active"), - ) - database.conn.commit() - - return database - - -@pytest.fixture -def tracker(db): - """Create MetricsTracker instance.""" - return MetricsTracker(db=db) - - -# ============================================================================ -# T108: test_record_token_usage - Records tokens after LLM call -# ============================================================================ - - -@pytest.mark.asyncio -async def test_record_token_usage(tracker, db): - """Test recording token usage after an LLM call.""" - # Given: A task exists - cursor = db.conn.cursor() - cursor.execute( - "INSERT INTO tasks (project_id, title, description, status) VALUES (?, ?, ?, ?)", - (1, "Test task", "Test description", "in_progress"), - ) - db.conn.commit() - task_id = cursor.lastrowid - - # When: We record token usage - usage_id = await tracker.record_token_usage( - task_id=task_id, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - call_type=CallType.TASK_EXECUTION, - ) - - # Then: Token usage is saved to database - assert usage_id > 0 - - # And: We can retrieve it - cursor.execute("SELECT * FROM token_usage WHERE id = ?", (usage_id,)) - row = cursor.fetchone() - assert row is not None - assert row["task_id"] == task_id - assert row["agent_id"] == "backend-001" - assert row["project_id"] == 1 - assert row["model_name"] == "claude-sonnet-4-5" - assert row["input_tokens"] == 1000 - assert row["output_tokens"] == 500 - assert row["call_type"] == "task_execution" - assert row["estimated_cost_usd"] > 0 - - -@pytest.mark.asyncio -async def test_record_token_usage_without_task(tracker, db): - """Test recording token usage for non-task LLM calls (e.g., coordination).""" - # When: We record token usage without a task - usage_id = await tracker.record_token_usage( - task_id=None, - agent_id="orchestrator-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=500, - output_tokens=200, - call_type=CallType.COORDINATION, - ) - - # Then: Token usage is saved - assert usage_id > 0 - - # And: task_id is NULL - cursor = db.conn.cursor() - cursor.execute("SELECT task_id FROM token_usage WHERE id = ?", (usage_id,)) - row = cursor.fetchone() - assert row["task_id"] is None - - -# ============================================================================ -# T109: test_calculate_cost_sonnet - Cost calculation for Sonnet 4.5 -# ============================================================================ - - -def test_calculate_cost_sonnet(): - """Test cost calculation for Claude Sonnet 4.5 ($3/$15 per MTok).""" - # Given: Sonnet pricing ($3 input, $15 output per million tokens) - model_name = "claude-sonnet-4-5" - input_tokens = 1_000_000 # 1M tokens - output_tokens = 500_000 # 0.5M tokens - - # When: We calculate cost - cost = MetricsTracker.calculate_cost(model_name, input_tokens, output_tokens) - - # Then: Cost is correct (1M * $3 + 0.5M * $15 = $3 + $7.50 = $10.50) - assert cost == 10.50 - - -def test_calculate_cost_sonnet_small(): - """Test cost calculation for small Sonnet usage.""" - # Given: Small token counts - model_name = "claude-sonnet-4-5" - input_tokens = 1000 # 0.001M tokens - output_tokens = 500 # 0.0005M tokens - - # When: We calculate cost - cost = MetricsTracker.calculate_cost(model_name, input_tokens, output_tokens) - - # Then: Cost is correct (1000 * $3 / 1M + 500 * $15 / 1M = $0.003 + $0.0075 = $0.0105) - expected = (1000 * 3.00 / 1_000_000) + (500 * 15.00 / 1_000_000) - assert cost == pytest.approx(expected, abs=1e-6) - - -# ============================================================================ -# T110: test_calculate_cost_opus - Cost calculation for Opus 4 -# ============================================================================ - - -def test_calculate_cost_opus(): - """Test cost calculation for Claude Opus 4 ($15/$75 per MTok).""" - # Given: Opus pricing ($15 input, $75 output per million tokens) - model_name = "claude-opus-4" - input_tokens = 1_000_000 # 1M tokens - output_tokens = 500_000 # 0.5M tokens - - # When: We calculate cost - cost = MetricsTracker.calculate_cost(model_name, input_tokens, output_tokens) - - # Then: Cost is correct (1M * $15 + 0.5M * $75 = $15 + $37.50 = $52.50) - assert cost == 52.50 - - -# ============================================================================ -# T111: test_calculate_cost_haiku - Cost calculation for Haiku 4 -# ============================================================================ - - -def test_calculate_cost_haiku(): - """Test cost calculation for Claude Haiku 4 ($0.80/$4 per MTok).""" - # Given: Haiku pricing ($0.80 input, $4 output per million tokens) - model_name = "claude-haiku-4" - input_tokens = 1_000_000 # 1M tokens - output_tokens = 500_000 # 0.5M tokens - - # When: We calculate cost - cost = MetricsTracker.calculate_cost(model_name, input_tokens, output_tokens) - - # Then: Cost is correct (1M * $0.80 + 0.5M * $4 = $0.80 + $2.00 = $2.80) - assert cost == 2.80 - - -def test_calculate_cost_unknown_model(): - """Test that unknown model returns $0 instead of crashing.""" - # When: We calculate cost for an unknown model - cost = MetricsTracker.calculate_cost("claude-unknown-99", 1000, 500) - - # Then: Returns 0.0 (graceful degradation) - assert cost == 0.0 - - -# ============================================================================ -# T112: test_get_project_total_cost - Aggregate project costs -# ============================================================================ - - -@pytest.mark.asyncio -async def test_get_project_total_cost(tracker, db): - """Test aggregating total costs for a project.""" - # Given: Multiple token usages for different agents - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - call_type=CallType.TASK_EXECUTION, - ) - - await tracker.record_token_usage( - task_id=None, - agent_id="frontend-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=500_000, - output_tokens=250_000, - call_type=CallType.TASK_EXECUTION, - ) - - # When: We get project costs - result = await tracker.get_project_costs(project_id=1) - - # Then: Total cost is sum of both usages - # Sonnet: (1M * $3 + 0.5M * $15) = $10.50 - # Haiku: (0.5M * $0.80 + 0.25M * $4) = $1.40 - # Total: $11.90 - assert result["total_cost_usd"] == pytest.approx(11.90, abs=0.01) - assert result["total_tokens"] == 2_250_000 - assert result["project_id"] == 1 - - # And: Breakdown by agent is provided - assert len(result["by_agent"]) == 2 - agent_costs = {a["agent_id"]: a["cost_usd"] for a in result["by_agent"]} - assert agent_costs["backend-001"] == pytest.approx(10.50, abs=0.01) - assert agent_costs["frontend-001"] == pytest.approx(1.40, abs=0.01) - - # And: Breakdown by model is provided - assert len(result["by_model"]) == 2 - model_costs = {m["model_name"]: m["cost_usd"] for m in result["by_model"]} - assert model_costs["claude-sonnet-4-5"] == pytest.approx(10.50, abs=0.01) - assert model_costs["claude-haiku-4"] == pytest.approx(1.40, abs=0.01) - - -@pytest.mark.asyncio -async def test_get_project_total_cost_empty(tracker, db): - """Test getting costs for project with no usage.""" - # When: We get costs for empty project - result = await tracker.get_project_costs(project_id=1) - - # Then: All values are zero - assert result["total_cost_usd"] == 0.0 - assert result["total_tokens"] == 0 - assert len(result["by_agent"]) == 0 - assert len(result["by_model"]) == 0 - - -# ============================================================================ -# T113: test_get_cost_by_agent - Cost breakdown per agent -# ============================================================================ - - -@pytest.mark.asyncio -async def test_get_cost_by_agent(tracker, db): - """Test getting cost breakdown for a specific agent.""" - # Given: Multiple usages for an agent across different projects - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - call_type=CallType.TASK_EXECUTION, - ) - - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=200_000, - output_tokens=100_000, - call_type=CallType.CODE_REVIEW, - ) - - # When: We get costs for this agent - result = await tracker.get_agent_costs(agent_id="backend-001") - - # Then: Total cost is sum of all usages - # Sonnet: $10.50, Haiku: $0.56 - assert result["total_cost_usd"] == pytest.approx(11.06, abs=0.01) - assert result["agent_id"] == "backend-001" - - # And: Breakdown by call type is provided - assert len(result["by_call_type"]) == 2 - call_costs = {c["call_type"]: c["cost_usd"] for c in result["by_call_type"]} - assert "task_execution" in call_costs - assert "code_review" in call_costs - - -# ============================================================================ -# T114: test_get_cost_by_model - Cost breakdown per model -# ============================================================================ - - -@pytest.mark.asyncio -async def test_get_cost_by_model(tracker, db): - """Test getting cost breakdown by model for a project.""" - # Given: Multiple usages with different models - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - call_type=CallType.TASK_EXECUTION, - ) - - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=500_000, - output_tokens=250_000, - call_type=CallType.TASK_EXECUTION, - ) - - await tracker.record_token_usage( - task_id=None, - agent_id="frontend-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=500_000, - output_tokens=250_000, - call_type=CallType.TASK_EXECUTION, - ) - - # When: We get costs - result = await tracker.get_project_costs(project_id=1) - - # Then: Model breakdown is correct - model_costs = {m["model_name"]: m for m in result["by_model"]} - - # Sonnet: 2 calls totaling (1.5M * $3 + 0.75M * $15) = $15.75 - assert model_costs["claude-sonnet-4-5"]["cost_usd"] == pytest.approx(15.75, abs=0.01) - assert model_costs["claude-sonnet-4-5"]["call_count"] == 2 - assert model_costs["claude-sonnet-4-5"]["total_tokens"] == 2_250_000 - - # Haiku: 1 call totaling (0.5M * $0.80 + 0.25M * $4) = $1.40 - assert model_costs["claude-haiku-4"]["cost_usd"] == pytest.approx(1.40, abs=0.01) - assert model_costs["claude-haiku-4"]["call_count"] == 1 - - -# ============================================================================ -# T115: test_get_token_usage_timeline - Token usage over time -# ============================================================================ - - -@pytest.mark.asyncio -async def test_get_token_usage_timeline(tracker, db): - """Test getting token usage timeline with date filtering.""" - # Given: Usages at different times - now = datetime.now(timezone.utc) - - # Recent usage (today) - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - call_type=CallType.TASK_EXECUTION, - ) - - # Older usage (manually insert with backdated timestamp) - old_timestamp = now - timedelta(days=10) - cursor = db.conn.cursor() - cursor.execute( - """ - INSERT INTO token_usage - (agent_id, project_id, model_name, input_tokens, output_tokens, - estimated_cost_usd, call_type, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "backend-001", - 1, - "claude-haiku-4", - 500_000, - 250_000, - MetricsTracker.calculate_cost("claude-haiku-4", 500_000, 250_000), - "task_execution", - old_timestamp.isoformat(), # Convert to ISO string - ), - ) - db.conn.commit() - - # When: We get timeline for last 7 days - start_date = now - timedelta(days=7) - result = await tracker.get_token_usage_stats(project_id=1, start_date=start_date, end_date=None) - - # Then: Only recent usage is included - assert result["total_cost_usd"] == pytest.approx(10.50, abs=0.01) - assert result["total_calls"] == 1 - assert result["date_range"]["start"] == start_date.isoformat() - - # When: We get all usage (no date filter) - result_all = await tracker.get_token_usage_stats(project_id=1, start_date=None, end_date=None) - - # Then: Both usages are included - assert result_all["total_cost_usd"] == pytest.approx(11.90, abs=0.01) - assert result_all["total_calls"] == 2 - - -@pytest.mark.asyncio -async def test_get_token_usage_stats_with_end_date(tracker, db): - """Test filtering usage by end date.""" - # Given: Usage exists - now = datetime.now(timezone.utc) - await tracker.record_token_usage( - task_id=None, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - call_type=CallType.TASK_EXECUTION, - ) - - # When: We filter with end_date in the past - past_date = now - timedelta(days=1) - result = await tracker.get_token_usage_stats(project_id=1, start_date=None, end_date=past_date) - - # Then: No usages match - assert result["total_cost_usd"] == 0.0 - assert result["total_calls"] == 0 - - -# ============================================================================ -# Step 2: Sync recording, aggregation, export -# ============================================================================ - - -def test_record_token_usage_sync(tracker, db): - """Test synchronous recording of token usage.""" - # Given: A task exists - cursor = db.conn.cursor() - cursor.execute( - "INSERT INTO tasks (project_id, title, description, status) VALUES (?, ?, ?, ?)", - (1, "Test task", "Test description", "in_progress"), - ) - db.conn.commit() - task_id = cursor.lastrowid - - # When: We record token usage synchronously - usage_id = tracker.record_token_usage_sync( - task_id=task_id, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - call_type=CallType.TASK_EXECUTION, - ) - - # Then: Token usage is saved to database - assert usage_id > 0 - - # And: We can retrieve it - cursor.execute("SELECT * FROM token_usage WHERE id = ?", (usage_id,)) - row = cursor.fetchone() - assert row is not None - assert row["task_id"] == task_id - assert row["agent_id"] == "backend-001" - assert row["model_name"] == "claude-sonnet-4-5" - assert row["input_tokens"] == 1000 - assert row["output_tokens"] == 500 - assert row["estimated_cost_usd"] > 0 - - -def test_record_token_usage_sync_negative_tokens(tracker): - """Test sync recording rejects negative token counts.""" - with pytest.raises(ValueError, match="Token counts cannot be negative"): - tracker.record_token_usage_sync( - task_id=1, - agent_id="agent-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=-1, - output_tokens=500, - ) - - -def test_get_task_token_summary(tracker, db): - """Test getting aggregated token summary for a single task.""" - # Given: A task exists and has multiple token usages - cursor = db.conn.cursor() - cursor.execute( - "INSERT INTO tasks (project_id, title, description, status) VALUES (?, ?, ?, ?)", - (1, "Summary task", "Test", "in_progress"), - ) - db.conn.commit() - task_id = cursor.lastrowid - - tracker.record_token_usage_sync( - task_id=task_id, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - call_type=CallType.TASK_EXECUTION, - ) - tracker.record_token_usage_sync( - task_id=task_id, - agent_id="backend-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=2000, - output_tokens=1000, - call_type=CallType.CODE_REVIEW, - ) - - # When: We get the task summary - summary = tracker.get_task_token_summary(task_id=task_id) - - # Then: Aggregated values are correct - assert summary["task_id"] == task_id - assert summary["total_input_tokens"] == 3000 - assert summary["total_output_tokens"] == 1500 - assert summary["total_tokens"] == 4500 - assert summary["call_count"] == 2 - assert summary["total_cost_usd"] > 0 - - -def test_get_task_token_summary_no_records(tracker): - """Test task summary with no records returns zeros.""" - summary = tracker.get_task_token_summary(task_id=999) - - assert summary["task_id"] == 999 - assert summary["total_tokens"] == 0 - assert summary["total_cost_usd"] == 0.0 - assert summary["call_count"] == 0 - - -def _create_task_helper(db): - """Helper to create a task and return its ID.""" - cursor = db.conn.cursor() - cursor.execute( - "INSERT INTO tasks (project_id, title, description, status) VALUES (?, ?, ?, ?)", - (1, "Test task", "Test", "in_progress"), - ) - db.conn.commit() - return cursor.lastrowid - - -def test_get_workspace_costs(tracker, db): - """Test getting aggregated costs across the workspace.""" - # Given: Token usages across different tasks/projects - tid1 = _create_task_helper(db) - tid2 = _create_task_helper(db) - tracker.record_token_usage_sync( - task_id=tid1, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1_000_000, - output_tokens=500_000, - ) - tracker.record_token_usage_sync( - task_id=tid2, - agent_id="frontend-001", - project_id=1, - model_name="claude-haiku-4", - input_tokens=500_000, - output_tokens=250_000, - ) - - # When: We get workspace costs - result = tracker.get_workspace_costs() - - # Then: All records are aggregated - # Sonnet: $10.50, Haiku: $1.40 => Total: $11.90 - assert result["total_cost_usd"] == pytest.approx(11.90, abs=0.01) - assert result["total_tokens"] == 2_250_000 - assert result["total_calls"] == 2 - - -def test_get_workspace_costs_with_date_filter(tracker, db): - """Test workspace costs with date range filtering.""" - now = datetime.now(timezone.utc) - - # Recent usage - tid = _create_task_helper(db) - tracker.record_token_usage_sync( - task_id=tid, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - ) - - # Old usage (manually backdated) - old_timestamp = now - timedelta(days=10) - cursor = db.conn.cursor() - cursor.execute( - """INSERT INTO token_usage - (agent_id, project_id, model_name, input_tokens, output_tokens, - estimated_cost_usd, call_type, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ("agent-old", 1, "claude-haiku-4", 500, 250, 0.001, "other", old_timestamp.isoformat()), - ) - db.conn.commit() - - # When: We filter to last 7 days - start = now - timedelta(days=7) - result = tracker.get_workspace_costs(start_date=start) - - # Then: Only recent usage is included - assert result["total_calls"] == 1 - - -def test_get_workspace_costs_empty(tracker): - """Test workspace costs with no records.""" - result = tracker.get_workspace_costs() - - assert result["total_cost_usd"] == 0.0 - assert result["total_tokens"] == 0 - assert result["total_calls"] == 0 - - -def test_export_to_csv(tracker, db, tmp_path): - """Test exporting token usage records to CSV.""" - # Given: Some token usage records - tid = _create_task_helper(db) - tracker.record_token_usage_sync( - task_id=tid, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - ) - records = db.get_workspace_token_usage() - - # When: We export to CSV - output_path = tmp_path / "usage.csv" - tracker.export_to_csv(records, str(output_path)) - - # Then: CSV file is created with correct content - assert output_path.exists() - content = output_path.read_text() - lines = content.strip().split("\n") - assert len(lines) == 2 # header + 1 record - header = lines[0] - assert "task_id" in header - assert "model_name" in header - assert "input_tokens" in header - assert "estimated_cost_usd" in header - - -def test_export_to_csv_empty(tracker, tmp_path): - """Test exporting empty records to CSV.""" - output_path = tmp_path / "empty.csv" - tracker.export_to_csv([], str(output_path)) - - assert output_path.exists() - content = output_path.read_text() - lines = content.strip().split("\n") - assert len(lines) == 1 # header only - - -def test_export_to_json(tracker, db, tmp_path): - """Test exporting token usage records to JSON.""" - import json - - # Given: Some token usage records - tid = _create_task_helper(db) - tracker.record_token_usage_sync( - task_id=tid, - agent_id="backend-001", - project_id=1, - model_name="claude-sonnet-4-5", - input_tokens=1000, - output_tokens=500, - ) - records = db.get_workspace_token_usage() - - # When: We export to JSON - output_path = tmp_path / "usage.json" - tracker.export_to_json(records, str(output_path)) - - # Then: JSON file is created with correct structure - assert output_path.exists() - data = json.loads(output_path.read_text()) - assert "metadata" in data - assert "records" in data - assert data["metadata"]["record_count"] == 1 - assert "exported_at" in data["metadata"] - assert len(data["records"]) == 1 - assert data["records"][0]["model_name"] == "claude-sonnet-4-5" - - -def test_export_to_json_empty(tracker, tmp_path): - """Test exporting empty records to JSON.""" - import json - - output_path = tmp_path / "empty.json" - tracker.export_to_json([], str(output_path)) - - assert output_path.exists() - data = json.loads(output_path.read_text()) - assert data["metadata"]["record_count"] == 0 - assert len(data["records"]) == 0 - - -# ============================================================================ -# Step 5: Model name normalization -# ============================================================================ - - -def test_normalize_model_name_with_date_suffix(): - """Test that date suffixes are stripped from model names.""" - from codeframe.lib.metrics_tracker import normalize_model_name - - assert normalize_model_name("claude-sonnet-4-5-20250514") == "claude-sonnet-4-5" - assert normalize_model_name("claude-opus-4-20250514") == "claude-opus-4" - assert normalize_model_name("claude-haiku-4-20250514") == "claude-haiku-4" - - -def test_normalize_model_name_exact_match(): - """Test that exact model names pass through unchanged.""" - from codeframe.lib.metrics_tracker import normalize_model_name - - assert normalize_model_name("claude-sonnet-4-5") == "claude-sonnet-4-5" - assert normalize_model_name("claude-opus-4") == "claude-opus-4" - assert normalize_model_name("claude-haiku-4") == "claude-haiku-4" - - -def test_normalize_model_name_unknown_model(): - """Test that unknown models return as-is.""" - from codeframe.lib.metrics_tracker import normalize_model_name - - assert normalize_model_name("gpt-4-turbo") == "gpt-4-turbo" - assert normalize_model_name("some-unknown-model") == "some-unknown-model" - - -def test_calculate_cost_with_date_suffix(): - """Test that calculate_cost handles model names with date suffixes.""" - # Should work the same as without the suffix - cost_with_suffix = MetricsTracker.calculate_cost( - "claude-sonnet-4-5-20250514", 1000, 500 - ) - cost_without_suffix = MetricsTracker.calculate_cost( - "claude-sonnet-4-5", 1000, 500 - ) - assert cost_with_suffix == cost_without_suffix - - -def test_calculate_cost_unknown_model_returns_zero(): - """Test that unknown models return $0 cost instead of raising.""" - cost = MetricsTracker.calculate_cost("totally-unknown-model", 1000, 500) - assert cost == 0.0 diff --git a/tests/test_issues.md b/tests/test_issues.md index 552806a6..6adf7e80 100644 --- a/tests/test_issues.md +++ b/tests/test_issues.md @@ -28,7 +28,7 @@ tests/blockers/test_blockers.py::TestDuplicateResolution::test_concurrent_resolu File "/home/frankbria/projects/codeframe/tests/blockers/test_blockers.py", line 227, in resolve_a results.append(db.resolve_blocker(blocker_id, "Answer A")) ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/frankbria/projects/codeframe/codeframe/persistence/database.py", line 749, in resolve_blocker + File "/home/frankbria/projects/codeframe/codeframe/platform_store/database.py", line 749, in resolve_blocker cursor.execute( ~~~~~~~~~~~~~~^ """UPDATE blockers diff --git a/tests/ui/conftest.py b/tests/ui/conftest.py index 6a108326..21e4df8d 100644 --- a/tests/ui/conftest.py +++ b/tests/ui/conftest.py @@ -21,22 +21,15 @@ import requests import shutil -# Skip v1 legacy tests that import removed dependencies -# These tests rely on v1 routers/persistence that use get_db/get_db_websocket -# NOTE: collect_ignore must be at module level but can come after imports +# Two legacy WebSocket tests are still in transition. They reference live +# managers but use the v1 get_db/get_db_websocket plumbing; until they are +# rewritten on the v2 dependencies, keep them excluded from collection. collect_ignore = [ "test_websocket_integration.py", "test_websocket_subscriptions.py", - "test_deployment_mode.py", - "test_project_api.py", - "test_session_router.py", ] -# Guard v1 import - only needed by skipped tests -try: - from codeframe.persistence.database import Database -except ImportError: - Database = None # type: ignore +from codeframe.platform_store.database import Database # noqa: E402 def create_test_jwt_token(user_id: int = 1, secret: str = None) -> str: diff --git a/tests/ui/test_deployment_mode.py b/tests/ui/test_deployment_mode.py deleted file mode 100644 index ada9f680..00000000 --- a/tests/ui/test_deployment_mode.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Tests for deployment mode validation.""" - -import pytest -import os -import tempfile -import shutil -from pathlib import Path -from fastapi.testclient import TestClient -from codeframe.persistence.database import Database - -from conftest import create_test_jwt_token - - -@pytest.fixture -def test_client_hosted(): - """Test client with HOSTED mode and authentication.""" - temp_dir = Path(tempfile.mkdtemp()) - db_path = temp_dir / "test.db" - workspace_root = temp_dir / "workspaces" - - # Save original environment - original_db_path = os.environ.get("DATABASE_PATH") - original_workspace_root = os.environ.get("WORKSPACE_ROOT") - original_deployment_mode = os.environ.get("CODEFRAME_DEPLOYMENT_MODE") - - # Set environment variables - os.environ["DATABASE_PATH"] = str(db_path) - os.environ["WORKSPACE_ROOT"] = str(workspace_root) - os.environ["CODEFRAME_DEPLOYMENT_MODE"] = "hosted" - - # Reload server module to pick up new environment - from codeframe.ui import server - from importlib import reload - - reload(server) - - # Initialize database - db = Database(db_path) - db.initialize() - server.app.state.db = db - - # Create test user (user_id=1) - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'test@example.com', 'Test User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - # Initialize workspace manager - from codeframe.workspace import WorkspaceManager - - server.app.state.workspace_manager = WorkspaceManager(workspace_root) - - # Create test client with authentication headers - auth_token = create_test_jwt_token(user_id=1) - client = TestClient(server.app, headers={"Authorization": f"Bearer {auth_token}"}) - - yield client - - # Cleanup - db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - - # Restore original environment - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - if original_workspace_root is not None: - os.environ["WORKSPACE_ROOT"] = original_workspace_root - else: - os.environ.pop("WORKSPACE_ROOT", None) - - if original_deployment_mode is not None: - os.environ["CODEFRAME_DEPLOYMENT_MODE"] = original_deployment_mode - else: - os.environ.pop("CODEFRAME_DEPLOYMENT_MODE", None) - - -@pytest.fixture -def test_client_self_hosted(): - """Test client with SELF_HOSTED mode and authentication.""" - temp_dir = Path(tempfile.mkdtemp()) - db_path = temp_dir / "test.db" - workspace_root = temp_dir / "workspaces" - - # Save original environment - original_db_path = os.environ.get("DATABASE_PATH") - original_workspace_root = os.environ.get("WORKSPACE_ROOT") - original_deployment_mode = os.environ.get("CODEFRAME_DEPLOYMENT_MODE") - - # Set environment variables - os.environ["DATABASE_PATH"] = str(db_path) - os.environ["WORKSPACE_ROOT"] = str(workspace_root) - os.environ["CODEFRAME_DEPLOYMENT_MODE"] = "self_hosted" - - # Reload server module to pick up new environment - from codeframe.ui import server - from importlib import reload - - reload(server) - - # Initialize database - db = Database(db_path) - db.initialize() - server.app.state.db = db - - # Create test user (user_id=1) - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'test@example.com', 'Test User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - # Initialize workspace manager - from codeframe.workspace import WorkspaceManager - - server.app.state.workspace_manager = WorkspaceManager(workspace_root) - - # Create test client with authentication headers - auth_token = create_test_jwt_token(user_id=1) - client = TestClient(server.app, headers={"Authorization": f"Bearer {auth_token}"}) - - yield client - - # Cleanup - db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - - # Restore original environment - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - if original_workspace_root is not None: - os.environ["WORKSPACE_ROOT"] = original_workspace_root - else: - os.environ.pop("WORKSPACE_ROOT", None) - - if original_deployment_mode is not None: - os.environ["CODEFRAME_DEPLOYMENT_MODE"] = original_deployment_mode - else: - os.environ.pop("CODEFRAME_DEPLOYMENT_MODE", None) - - -def test_hosted_mode_blocks_local_path(test_client_hosted): - """Verify hosted mode rejects local_path source type.""" - response = test_client_hosted.post( - "/api/projects", - json={ - "name": "Test", - "description": "Test", - "source_type": "local_path", - "source_location": "/home/user/project", - }, - ) - - assert response.status_code == 403 - assert "not available in hosted mode" in response.json()["detail"] - - -def test_hosted_mode_allows_git_remote(test_client_hosted): - """Verify hosted mode allows git_remote.""" - response = test_client_hosted.post( - "/api/projects", - json={ - "name": "Test", - "description": "Test", - "source_type": "git_remote", - "source_location": "https://github.com/user/repo.git", - }, - ) - - # Should not be blocked (may fail for other reasons, but not 403) - assert response.status_code != 403 - - -def test_self_hosted_allows_all_sources(test_client_self_hosted): - """Verify self-hosted mode allows all source types.""" - # Test local_path is allowed - response = test_client_self_hosted.post( - "/api/projects", - json={ - "name": "Test", - "description": "Test", - "source_type": "local_path", - "source_location": "/tmp/test", - }, - ) - - # Should not be blocked with 403 - assert response.status_code != 403 diff --git a/tests/ui/test_project_api.py b/tests/ui/test_project_api.py deleted file mode 100644 index 6b64739a..00000000 --- a/tests/ui/test_project_api.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for project API endpoints.""" - -import pytest -import os -import tempfile -import shutil -from pathlib import Path -from fastapi.testclient import TestClient -from codeframe.persistence.database import Database - -from conftest import create_test_jwt_token - - -@pytest.fixture -def test_client(): - """Create test client with temporary database and authentication.""" - temp_dir = Path(tempfile.mkdtemp()) - db_path = temp_dir / "test.db" - workspace_root = temp_dir / "workspaces" - - # Save original environment - original_db_path = os.environ.get("DATABASE_PATH") - original_workspace_root = os.environ.get("WORKSPACE_ROOT") - - # Set environment variables - os.environ["DATABASE_PATH"] = str(db_path) - os.environ["WORKSPACE_ROOT"] = str(workspace_root) - - # Reload server module to pick up new environment - from codeframe.ui import server - from importlib import reload - - reload(server) - - # Initialize database - db = Database(db_path) - db.initialize() - server.app.state.db = db - - # Create test user (user_id=1) - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'test@example.com', 'Test User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - # Initialize workspace manager - from codeframe.workspace import WorkspaceManager - - server.app.state.workspace_manager = WorkspaceManager(workspace_root) - - # Create test client with authentication headers - auth_token = create_test_jwt_token(user_id=1) - client = TestClient(server.app, headers={"Authorization": f"Bearer {auth_token}"}) - - yield client - - # Cleanup - db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - - # Restore original environment - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - if original_workspace_root is not None: - os.environ["WORKSPACE_ROOT"] = original_workspace_root - else: - os.environ.pop("WORKSPACE_ROOT", None) - - -def test_create_project_minimal(test_client): - """Test creating project with minimal required fields.""" - response = test_client.post( - "/api/projects", json={"name": "Test Project", "description": "A test project"} - ) - - assert response.status_code == 201 - data = response.json() - assert data["name"] == "Test Project" - # Note: API response doesn't include description/source fields currently - - -def test_create_project_git_remote(test_client): - """Test creating project from git repository (will fail on fake URL).""" - response = test_client.post( - "/api/projects", - json={ - "name": "Git Project", - "description": "From git", - "source_type": "git_remote", - "source_location": "https://github.com/user/repo.git", - }, - ) - - # This will fail with 500 because the git URL is fake - # For now, just verify it's not a validation error (422) - assert response.status_code in [201, 500] # 201 if git works, 500 if repo doesn't exist - - -def test_create_project_validation_error(test_client): - """Test validation error for missing source_location.""" - response = test_client.post( - "/api/projects", - json={ - "name": "Test", - "description": "Test", - "source_type": "git_remote", - # Missing source_location - }, - ) - - assert response.status_code == 422 - - -def test_create_project_missing_description(test_client): - """Test validation error for missing description.""" - response = test_client.post("/api/projects", json={"name": "Test"}) - - assert response.status_code == 422 diff --git a/tests/ui/test_session_router.py b/tests/ui/test_session_router.py deleted file mode 100644 index f8eed580..00000000 --- a/tests/ui/test_session_router.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Tests for session router endpoints. - -Tests session state retrieval with phase and step information. -Following TDD principles - these tests define the expected API contract. -""" - -import pytest -import os -import sys -import tempfile -import shutil -from pathlib import Path -from fastapi.testclient import TestClient - -from tests.conftest import create_test_jwt_token - - -@pytest.fixture -def test_client(): - """Create test client with temporary database and authentication.""" - from fastapi import FastAPI - - temp_dir = Path(tempfile.mkdtemp()) - db_path = temp_dir / "test.db" - workspace_root = temp_dir / "workspaces" - - # Save original environment - original_db_path = os.environ.get("DATABASE_PATH") - original_workspace_root = os.environ.get("WORKSPACE_ROOT") - - # Set environment variables FIRST - os.environ["DATABASE_PATH"] = str(db_path) - os.environ["WORKSPACE_ROOT"] = str(workspace_root) - - # Force fresh imports by removing cached modules - # Save original modules so we can restore them after test - saved_modules = {k: v for k, v in sys.modules.items() if k.startswith("codeframe")} - for mod in list(saved_modules.keys()): - del sys.modules[mod] - - # Now import with fresh state - from codeframe.ui.routers import session as session_router - from codeframe.ui.routers import projects as projects_router - from codeframe.ui.routers import discovery as discovery_router - from codeframe.persistence.database import Database - from codeframe.ui.dependencies import get_db as original_get_db - - # Create a new FastAPI app to avoid duplicate route issues - app = FastAPI() - app.include_router(session_router.router) - app.include_router(projects_router.router) - app.include_router(discovery_router.router) - - # Initialize database - db = Database(db_path) - db.initialize() - app.state.db = db - - # Override the database dependency - def get_test_db(): - return db - - app.dependency_overrides[original_get_db] = get_test_db - - # Create test user (user_id=1) - db.conn.execute( - """ - INSERT OR REPLACE INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (1, 'test@example.com', 'Test User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - db.conn.commit() - - # Initialize workspace manager - from codeframe.workspace import WorkspaceManager - - app.state.workspace_manager = WorkspaceManager(workspace_root) - - # Override auth dependency to return test user - from codeframe.auth.dependencies import get_current_user - from codeframe.auth.models import User - - async def get_test_user(): - return User(id=1, email="test@example.com", hashed_password="!DISABLED!") - - app.dependency_overrides[get_current_user] = get_test_user - - # Create test client with authentication headers - auth_token = create_test_jwt_token(user_id=1) - client = TestClient(app, headers={"Authorization": f"Bearer {auth_token}"}) - - # Attach db to client for test access - client.db = db - - yield client - - # Cleanup - db.close() - shutil.rmtree(temp_dir, ignore_errors=True) - - # Restore original environment - if original_db_path is not None: - os.environ["DATABASE_PATH"] = original_db_path - else: - os.environ.pop("DATABASE_PATH", None) - - if original_workspace_root is not None: - os.environ["WORKSPACE_ROOT"] = original_workspace_root - else: - os.environ.pop("WORKSPACE_ROOT", None) - - # Restore original modules to avoid affecting other tests - # First clear any modules we imported during this test - test_modules = [k for k in sys.modules.keys() if k.startswith("codeframe")] - for mod in test_modules: - del sys.modules[mod] - # Then restore the original modules - sys.modules.update(saved_modules) - - -class TestSessionEndpointPhaseAndStep: - """Tests for phase and step fields in session response.""" - - def test_get_session_state_includes_phase(self, test_client): - """Session state should include phase field.""" - # Create a project - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test description"} - ) - assert response.status_code == 201 - project_id = response.json()["id"] - - # Get session state - response = test_client.get(f"/api/projects/{project_id}/session") - assert response.status_code == 200 - - data = response.json() - assert "phase" in data - assert data["phase"] == "discovery" # Default phase - - def test_get_session_state_includes_step(self, test_client): - """Session state should include step object with current, total, description.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert "step" in data - assert "current" in data["step"] - assert "total" in data["step"] - assert "description" in data["step"] - assert isinstance(data["step"]["current"], int) - assert isinstance(data["step"]["total"], int) - assert isinstance(data["step"]["description"], str) - - def test_get_session_state_discovery_phase_values(self, test_client): - """Discovery phase should have correct step configuration.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["phase"] == "discovery" - assert data["step"]["total"] == 4 - assert data["step"]["description"] == "Discovery Phase" - assert data["step"]["current"] == 1 # Default to step 1 - - def test_get_session_state_planning_phase_values(self, test_client): - """Planning phase should have correct step configuration.""" - # Create project - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - # Update phase directly in database - test_client.db.update_project(project_id, {"phase": "planning"}) - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["phase"] == "planning" - assert data["step"]["total"] == 4 - assert data["step"]["description"] == "Planning Phase" - - def test_get_session_state_active_phase_values(self, test_client): - """Active phase should have correct step configuration.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - test_client.db.update_project(project_id, {"phase": "active"}) - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["phase"] == "active" - assert data["step"]["total"] == 5 - assert data["step"]["description"] == "Development Phase" - - def test_get_session_state_review_phase_values(self, test_client): - """Review phase should have correct step configuration.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - test_client.db.update_project(project_id, {"phase": "review"}) - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["phase"] == "review" - assert data["step"]["total"] == 3 - assert data["step"]["description"] == "Review Phase" - - def test_get_session_state_complete_phase_values(self, test_client): - """Complete phase should have correct step configuration.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - test_client.db.update_project(project_id, {"phase": "complete"}) - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - assert data["phase"] == "complete" - assert data["step"]["total"] == 1 - assert data["step"]["description"] == "Complete" - - -class TestSessionEndpointNoWorkspace: - """Tests for session state when project has no workspace.""" - - def test_get_session_state_no_workspace_includes_phase(self, test_client): - """Session with no workspace should still include phase and step.""" - # Create project - it won't have workspace_path initially - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - # Clear workspace path to simulate no workspace - test_client.db.update_project(project_id, {"workspace_path": ""}) - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - # Should still have phase and step even without workspace - assert "phase" in data - assert "step" in data - assert data["phase"] == "discovery" - - -class TestSessionEndpointAuthorization: - """Tests for session endpoint authorization.""" - - @pytest.mark.skip(reason="Test requires real auth which is overridden by fixture") - def test_get_session_state_unauthorized_user(self, test_client): - """Non-owner should get 403 for session access. - - Note: This test is skipped because the fixture overrides get_current_user - to always return user 1. Authorization logic is tested in integration tests. - """ - # Create project as user 1 - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - # Create user 2 and get their token - test_client.db.conn.execute( - """ - INSERT INTO users ( - id, email, name, hashed_password, - is_active, is_superuser, is_verified, email_verified - ) - VALUES (2, 'other@example.com', 'Other User', '!DISABLED!', 1, 0, 1, 1) - """ - ) - test_client.db.conn.commit() - - other_token = create_test_jwt_token(user_id=2) - - # Try to access project 1's session with user 2's token - response = test_client.get( - f"/api/projects/{project_id}/session", - headers={"Authorization": f"Bearer {other_token}"} - ) - - assert response.status_code == 403 - - def test_get_session_state_not_found(self, test_client): - """Non-existent project should return 404.""" - response = test_client.get("/api/projects/99999/session") - assert response.status_code == 404 - - -class TestSessionResponseFormat: - """Tests for complete session response structure.""" - - def test_session_response_complete_structure(self, test_client): - """Session response should have all required fields.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - # Existing fields - assert "last_session" in data - assert "next_actions" in data - assert "progress_pct" in data - assert "active_blockers" in data - - # New phase/step fields - assert "phase" in data - assert "step" in data - - # Validate last_session structure - assert "summary" in data["last_session"] - assert "timestamp" in data["last_session"] - - # Validate step structure - assert "current" in data["step"] - assert "total" in data["step"] - assert "description" in data["step"] - - def test_session_response_types(self, test_client): - """Session response fields should have correct types.""" - response = test_client.post( - "/api/projects", - json={"name": "Test Project", "description": "Test"} - ) - project_id = response.json()["id"] - - response = test_client.get(f"/api/projects/{project_id}/session") - data = response.json() - - # Type checks - assert isinstance(data["last_session"], dict) - assert isinstance(data["next_actions"], list) - assert isinstance(data["progress_pct"], (int, float)) - assert isinstance(data["active_blockers"], list) - assert isinstance(data["phase"], str) - assert isinstance(data["step"], dict) - assert isinstance(data["step"]["current"], int) - assert isinstance(data["step"]["total"], int) - assert isinstance(data["step"]["description"], str) diff --git a/tests/unit/test_interactive_sessions_api.py b/tests/unit/test_interactive_sessions_api.py index 12720c7d..154602c7 100644 --- a/tests/unit/test_interactive_sessions_api.py +++ b/tests/unit/test_interactive_sessions_api.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from codeframe.ui.routers.interactive_sessions_v2 import router -from codeframe.persistence.database import Database +from codeframe.platform_store.database import Database pytestmark = pytest.mark.v2