diff --git a/backend/tests/e2e/test_agent_workflows.py b/backend/tests/e2e/test_agent_workflows.py new file mode 100644 index 0000000..c5a1a8c --- /dev/null +++ b/backend/tests/e2e/test_agent_workflows.py @@ -0,0 +1,646 @@ +""" +Agent E2E Workflow Tests. + +Tests complete workflows for AI agents including: +- Agent type management (admin-only) +- Agent instance spawning and lifecycle +- Agent status transitions (pause/resume/terminate) +- Authorization and access control + +Usage: + make test-e2e # Run all E2E tests +""" + +from uuid import uuid4 + +import pytest + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.postgres, + pytest.mark.asyncio, +] + + +class TestAgentTypesAdminWorkflows: + """Test agent type management (admin-only operations).""" + + async def test_create_agent_type_requires_superuser(self, e2e_client): + """Test that creating agent types requires superuser privileges.""" + # Register regular user + email = f"regular-{uuid4().hex[:8]}@example.com" + password = "RegularPass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Regular", + "last_name": "User", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + # Try to create agent type + response = await e2e_client.post( + "/api/v1/agent-types", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "name": "Test Agent", + "slug": f"test-agent-{uuid4().hex[:8]}", + "personality_prompt": "You are a helpful assistant.", + "primary_model": "claude-3-sonnet", + }, + ) + + assert response.status_code == 403 + + async def test_superuser_can_create_agent_type(self, e2e_client, e2e_superuser): + """Test that superuser can create and manage agent types.""" + slug = f"test-type-{uuid4().hex[:8]}" + + # Create agent type + create_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Product Owner Agent", + "slug": slug, + "description": "A product owner agent for requirements gathering", + "expertise": ["requirements", "user_stories", "prioritization"], + "personality_prompt": "You are a product owner focused on delivering value.", + "primary_model": "claude-3-opus", + "fallback_models": ["claude-3-sonnet"], + "model_params": {"temperature": 0.7, "max_tokens": 4000}, + "mcp_servers": ["knowledge-base"], + "is_active": True, + }, + ) + + assert create_resp.status_code == 201, f"Failed: {create_resp.text}" + agent_type = create_resp.json() + + assert agent_type["name"] == "Product Owner Agent" + assert agent_type["slug"] == slug + assert agent_type["primary_model"] == "claude-3-opus" + assert agent_type["is_active"] is True + assert "requirements" in agent_type["expertise"] + + async def test_list_agent_types_public(self, e2e_client, e2e_superuser): + """Test that any authenticated user can list agent types.""" + # First create an agent type as superuser + slug = f"list-test-{uuid4().hex[:8]}" + await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": f"List Test Agent {slug}", + "slug": slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + + # Register regular user + email = f"lister-{uuid4().hex[:8]}@example.com" + password = "ListerPass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "List", + "last_name": "User", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + # List agent types as regular user + list_resp = await e2e_client.get( + "/api/v1/agent-types", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + + assert list_resp.status_code == 200 + data = list_resp.json() + assert "data" in data + assert "pagination" in data + assert data["pagination"]["total"] >= 1 + + async def test_get_agent_type_by_slug(self, e2e_client, e2e_superuser): + """Test getting agent type by slug.""" + slug = f"slug-test-{uuid4().hex[:8]}" + + # Create agent type + await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": f"Slug Test {slug}", + "slug": slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + + # Get by slug (route is /slug/{slug}, not /by-slug/{slug}) + get_resp = await e2e_client.get( + f"/api/v1/agent-types/slug/{slug}", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["slug"] == slug + + async def test_update_agent_type(self, e2e_client, e2e_superuser): + """Test updating an agent type.""" + slug = f"update-test-{uuid4().hex[:8]}" + + # Create agent type + create_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Original Name", + "slug": slug, + "personality_prompt": "Original prompt.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = create_resp.json()["id"] + + # Update agent type + update_resp = await e2e_client.patch( + f"/api/v1/agent-types/{agent_type_id}", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Updated Name", + "description": "Added description", + }, + ) + + assert update_resp.status_code == 200 + updated = update_resp.json() + assert updated["name"] == "Updated Name" + assert updated["description"] == "Added description" + assert updated["personality_prompt"] == "Original prompt." # Unchanged + + +class TestAgentInstanceWorkflows: + """Test agent instance spawning and lifecycle.""" + + async def test_spawn_agent_workflow(self, e2e_client, e2e_superuser): + """Test complete workflow: create type -> create project -> spawn agent.""" + # 1. Create agent type as superuser + type_slug = f"spawn-test-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Spawn Test Agent", + "slug": type_slug, + "personality_prompt": "You are a helpful agent.", + "primary_model": "claude-3-sonnet", + }, + ) + assert type_resp.status_code == 201 + agent_type = type_resp.json() + agent_type_id = agent_type["id"] + + # 2. Create a project (superuser can create projects too) + project_slug = f"spawn-test-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Spawn Test Project", "slug": project_slug}, + ) + assert project_resp.status_code == 201 + project = project_resp.json() + project_id = project["id"] + + # 3. Spawn agent instance + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "My PO Agent", + }, + ) + + assert spawn_resp.status_code == 201, f"Failed: {spawn_resp.text}" + agent = spawn_resp.json() + + assert agent["name"] == "My PO Agent" + assert agent["status"] == "idle" + assert agent["project_id"] == project_id + assert agent["agent_type_id"] == agent_type_id + + async def test_list_project_agents(self, e2e_client, e2e_superuser): + """Test listing agents in a project.""" + # Setup: Create agent type and project + type_slug = f"list-agents-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "List Agents Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = type_resp.json()["id"] + + project_slug = f"list-agents-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "List Agents Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + # Spawn multiple agents + for i in range(3): + await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": f"Agent {i + 1}", + }, + ) + + # List agents + list_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + + assert list_resp.status_code == 200 + data = list_resp.json() + assert data["pagination"]["total"] == 3 + assert len(data["data"]) == 3 + + +class TestAgentLifecycle: + """Test agent lifecycle operations (pause/resume/terminate).""" + + async def test_agent_pause_and_resume(self, e2e_client, e2e_superuser): + """Test pausing and resuming an agent.""" + # Setup: Create agent type, project, and agent + type_slug = f"pause-test-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Pause Test Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = type_resp.json()["id"] + + project_slug = f"pause-test-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Pause Test Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "Pausable Agent", + }, + ) + agent_id = spawn_resp.json()["id"] + assert spawn_resp.json()["status"] == "idle" + + # Pause agent + pause_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/pause", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + assert pause_resp.status_code == 200, f"Failed: {pause_resp.text}" + assert pause_resp.json()["status"] == "paused" + + # Resume agent + resume_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/resume", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + assert resume_resp.status_code == 200, f"Failed: {resume_resp.text}" + assert resume_resp.json()["status"] == "idle" + + async def test_agent_terminate(self, e2e_client, e2e_superuser): + """Test terminating an agent.""" + # Setup + type_slug = f"terminate-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Terminate Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = type_resp.json()["id"] + + project_slug = f"terminate-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Terminate Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "To Be Terminated", + }, + ) + agent_id = spawn_resp.json()["id"] + + # Terminate agent (returns MessageResponse, not agent status) + terminate_resp = await e2e_client.delete( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + + assert terminate_resp.status_code == 200 + assert "message" in terminate_resp.json() + + # Verify terminated agent cannot be resumed (returns 400 or 422) + resume_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents/{agent_id}/resume", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + assert resume_resp.status_code in [400, 422] # Invalid transition + + +class TestAgentAccessControl: + """Test agent access control and authorization.""" + + async def test_user_cannot_access_other_project_agents( + self, e2e_client, e2e_superuser + ): + """Test that users cannot access agents in projects they don't own.""" + # Superuser creates agent type + type_slug = f"access-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Access Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = type_resp.json()["id"] + + # Superuser creates project and spawns agent + project_slug = f"protected-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Protected Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "Protected Agent", + }, + ) + agent_id = spawn_resp.json()["id"] + + # Create a different user + email = f"other-user-{uuid4().hex[:8]}@example.com" + password = "OtherPass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Other", + "last_name": "User", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + other_tokens = login_resp.json() + + # Other user tries to access the agent + get_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/agents/{agent_id}", + headers={"Authorization": f"Bearer {other_tokens['access_token']}"}, + ) + + # Should be forbidden or not found + assert get_resp.status_code in [403, 404] + + async def test_cannot_spawn_with_inactive_agent_type( + self, e2e_client, e2e_superuser + ): + """Test that agents cannot be spawned from inactive agent types.""" + # Create agent type + type_slug = f"inactive-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Inactive Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + "is_active": True, + }, + ) + agent_type_id = type_resp.json()["id"] + + # Deactivate the agent type + await e2e_client.patch( + f"/api/v1/agent-types/{agent_type_id}", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"is_active": False}, + ) + + # Create project + project_slug = f"inactive-spawn-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Inactive Spawn Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + # Try to spawn agent with inactive type + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "Should Fail", + }, + ) + + # 422 is correct for validation errors per REST conventions + assert spawn_resp.status_code == 422 + + +class TestAgentMetrics: + """Test agent metrics endpoint.""" + + async def test_get_agent_metrics(self, e2e_client, e2e_superuser): + """Test retrieving agent metrics.""" + # Setup + type_slug = f"metrics-type-{uuid4().hex[:8]}" + type_resp = await e2e_client.post( + "/api/v1/agent-types", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "name": "Metrics Type", + "slug": type_slug, + "personality_prompt": "Test agent.", + "primary_model": "claude-3-sonnet", + }, + ) + agent_type_id = type_resp.json()["id"] + + project_slug = f"metrics-project-{uuid4().hex[:8]}" + project_resp = await e2e_client.post( + "/api/v1/projects", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={"name": "Metrics Project", "slug": project_slug}, + ) + project_id = project_resp.json()["id"] + + spawn_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/agents", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "agent_type_id": agent_type_id, + "project_id": project_id, + "name": "Metrics Agent", + }, + ) + agent_id = spawn_resp.json()["id"] + + # Get metrics + metrics_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/agents/{agent_id}/metrics", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + + assert metrics_resp.status_code == 200 + metrics = metrics_resp.json() + + # Verify AgentInstanceMetrics structure + assert "total_instances" in metrics + assert "active_instances" in metrics + assert "idle_instances" in metrics + assert "total_tasks_completed" in metrics + assert "total_tokens_used" in metrics + assert "total_cost_incurred" in metrics diff --git a/backend/tests/e2e/test_mcp_workflows.py b/backend/tests/e2e/test_mcp_workflows.py new file mode 100644 index 0000000..264bfca --- /dev/null +++ b/backend/tests/e2e/test_mcp_workflows.py @@ -0,0 +1,460 @@ +""" +MCP and Context Engine E2E Workflow Tests. + +Tests complete workflows involving MCP servers and the Context Engine +against real PostgreSQL. These tests verify: +- MCP server listing and tool discovery +- Context engine operations +- Admin-only MCP operations with proper authentication +- Error handling for MCP operations + +Usage: + make test-e2e # Run all E2E tests +""" + +from uuid import uuid4 + +import pytest + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.postgres, + pytest.mark.asyncio, +] + + +class TestMCPServerDiscovery: + """Test MCP server listing and discovery workflows.""" + + async def test_list_mcp_servers(self, e2e_client): + """Test listing MCP servers returns expected configuration.""" + response = await e2e_client.get("/api/v1/mcp/servers") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + # Should have servers configured + assert "servers" in data + assert "total" in data + assert isinstance(data["servers"], list) + + # Should have at least llm-gateway and knowledge-base + server_names = [s["name"] for s in data["servers"]] + assert "llm-gateway" in server_names + assert "knowledge-base" in server_names + + async def test_list_all_mcp_tools(self, e2e_client): + """Test listing all tools from all MCP servers.""" + response = await e2e_client.get("/api/v1/mcp/tools") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "tools" in data + assert "total" in data + assert isinstance(data["tools"], list) + + async def test_mcp_health_check(self, e2e_client): + """Test MCP health check returns server status.""" + response = await e2e_client.get("/api/v1/mcp/health") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "servers" in data + assert "healthy_count" in data + assert "unhealthy_count" in data + assert "total" in data + + async def test_list_circuit_breakers(self, e2e_client): + """Test listing circuit breaker status.""" + response = await e2e_client.get("/api/v1/mcp/circuit-breakers") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "circuit_breakers" in data + assert isinstance(data["circuit_breakers"], list) + + +class TestMCPServerTools: + """Test MCP server tool listing.""" + + async def test_list_llm_gateway_tools(self, e2e_client): + """Test listing tools from LLM Gateway server.""" + response = await e2e_client.get("/api/v1/mcp/servers/llm-gateway/tools") + + # May return 200 with tools or 404 if server not connected + assert response.status_code in [200, 404, 502] + + if response.status_code == 200: + data = response.json() + assert "tools" in data + assert "total" in data + + async def test_list_knowledge_base_tools(self, e2e_client): + """Test listing tools from Knowledge Base server.""" + response = await e2e_client.get("/api/v1/mcp/servers/knowledge-base/tools") + + # May return 200 with tools or 404/502 if server not connected + assert response.status_code in [200, 404, 502] + + if response.status_code == 200: + data = response.json() + assert "tools" in data + assert "total" in data + + async def test_invalid_server_returns_404(self, e2e_client): + """Test that invalid server name returns 404.""" + response = await e2e_client.get("/api/v1/mcp/servers/nonexistent-server/tools") + + assert response.status_code == 404 + + +class TestContextEngineWorkflows: + """Test Context Engine operations.""" + + async def test_context_engine_health(self, e2e_client): + """Test context engine health endpoint.""" + response = await e2e_client.get("/api/v1/context/health") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert data["status"] == "healthy" + assert "mcp_connected" in data + assert "cache_enabled" in data + + async def test_get_token_budget_claude_sonnet(self, e2e_client): + """Test getting token budget for Claude 3 Sonnet.""" + response = await e2e_client.get("/api/v1/context/budget/claude-3-sonnet") + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert data["model"] == "claude-3-sonnet" + assert "total_tokens" in data + assert "system_tokens" in data + assert "knowledge_tokens" in data + assert "conversation_tokens" in data + assert "tool_tokens" in data + assert "response_reserve" in data + + # Verify budget allocation makes sense + assert data["total_tokens"] > 0 + total_allocated = ( + data["system_tokens"] + + data["knowledge_tokens"] + + data["conversation_tokens"] + + data["tool_tokens"] + + data["response_reserve"] + ) + assert total_allocated <= data["total_tokens"] + + async def test_get_token_budget_with_custom_max(self, e2e_client): + """Test getting token budget with custom max tokens.""" + response = await e2e_client.get( + "/api/v1/context/budget/claude-3-sonnet", + params={"max_tokens": 50000}, + ) + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert data["model"] == "claude-3-sonnet" + # Custom max should be respected or capped + assert data["total_tokens"] <= 50000 + + async def test_count_tokens(self, e2e_client): + """Test token counting endpoint.""" + response = await e2e_client.post( + "/api/v1/context/count-tokens", + json={ + "content": "Hello, this is a test message for token counting.", + "model": "claude-3-sonnet", + }, + ) + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "token_count" in data + assert data["token_count"] > 0 + assert data["model"] == "claude-3-sonnet" + + +class TestAdminMCPOperations: + """Test admin-only MCP operations require authentication.""" + + async def test_tool_call_requires_auth(self, e2e_client): + """Test that tool execution requires authentication.""" + response = await e2e_client.post( + "/api/v1/mcp/call", + json={ + "server": "llm-gateway", + "tool": "count_tokens", + "arguments": {"text": "test"}, + }, + ) + + # Should require authentication + assert response.status_code in [401, 403] + + async def test_circuit_reset_requires_auth(self, e2e_client): + """Test that circuit breaker reset requires authentication.""" + response = await e2e_client.post( + "/api/v1/mcp/circuit-breakers/llm-gateway/reset" + ) + + assert response.status_code in [401, 403] + + async def test_server_reconnect_requires_auth(self, e2e_client): + """Test that server reconnect requires authentication.""" + response = await e2e_client.post("/api/v1/mcp/servers/llm-gateway/reconnect") + + assert response.status_code in [401, 403] + + async def test_context_stats_requires_auth(self, e2e_client): + """Test that context stats requires authentication.""" + response = await e2e_client.get("/api/v1/context/stats") + + assert response.status_code in [401, 403] + + async def test_context_assemble_requires_auth(self, e2e_client): + """Test that context assembly requires authentication.""" + response = await e2e_client.post( + "/api/v1/context/assemble", + json={ + "project_id": "test-project", + "agent_id": "test-agent", + "query": "test query", + "model": "claude-3-sonnet", + }, + ) + + assert response.status_code in [401, 403] + + async def test_cache_invalidate_requires_auth(self, e2e_client): + """Test that cache invalidation requires authentication.""" + response = await e2e_client.post("/api/v1/context/cache/invalidate") + + assert response.status_code in [401, 403] + + +class TestAdminMCPWithAuthentication: + """Test admin MCP operations with superuser authentication.""" + + async def test_superuser_can_get_context_stats(self, e2e_client, e2e_superuser): + """Test that superuser can get context engine stats.""" + response = await e2e_client.get( + "/api/v1/context/stats", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "cache" in data + assert "settings" in data + + @pytest.mark.skip( + reason="Requires MCP servers (llm-gateway, knowledge-base) to be running" + ) + async def test_superuser_can_assemble_context(self, e2e_client, e2e_superuser): + """Test that superuser can assemble context.""" + response = await e2e_client.post( + "/api/v1/context/assemble", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "project_id": f"test-project-{uuid4().hex[:8]}", + "agent_id": f"test-agent-{uuid4().hex[:8]}", + "query": "What is the status of the project?", + "model": "claude-3-sonnet", + "system_prompt": "You are a helpful assistant.", + "compress": True, + "use_cache": False, + }, + ) + + assert response.status_code == 200, f"Failed: {response.text}" + data = response.json() + + assert "content" in data + assert "total_tokens" in data + assert "context_count" in data + assert "budget_used_percent" in data + assert "metadata" in data + + async def test_superuser_can_invalidate_cache(self, e2e_client, e2e_superuser): + """Test that superuser can invalidate cache.""" + response = await e2e_client.post( + "/api/v1/context/cache/invalidate", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + params={"project_id": "test-project"}, + ) + + assert response.status_code == 204 + + async def test_regular_user_cannot_access_admin_operations(self, e2e_client): + """Test that regular (non-superuser) cannot access admin operations.""" + email = f"regular-{uuid4().hex[:8]}@example.com" + password = "RegularUser123!" + + # Register regular user + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Regular", + "last_name": "User", + }, + ) + + # Login + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + # Try to access admin endpoint + response = await e2e_client.get( + "/api/v1/context/stats", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + + # Should be forbidden for non-superuser + assert response.status_code == 403 + + +class TestMCPInputValidation: + """Test input validation for MCP endpoints.""" + + async def test_server_name_max_length(self, e2e_client): + """Test that server name has max length validation.""" + long_name = "a" * 100 # Exceeds 64 char limit + + response = await e2e_client.get(f"/api/v1/mcp/servers/{long_name}/tools") + + assert response.status_code == 422 + + async def test_server_name_invalid_characters(self, e2e_client): + """Test that server name rejects invalid characters.""" + invalid_name = "server@name!invalid" + + response = await e2e_client.get(f"/api/v1/mcp/servers/{invalid_name}/tools") + + assert response.status_code == 422 + + async def test_token_count_empty_content(self, e2e_client): + """Test token counting with empty content.""" + response = await e2e_client.post( + "/api/v1/context/count-tokens", + json={"content": ""}, + ) + + # Empty content is valid, should return 0 tokens + if response.status_code == 200: + data = response.json() + assert data["token_count"] == 0 + else: + # Or it might be rejected as invalid + assert response.status_code == 422 + + +class TestMCPWorkflowIntegration: + """Test complete MCP workflows end-to-end.""" + + async def test_discovery_to_budget_workflow(self, e2e_client): + """Test complete workflow: discover servers -> check budget -> ready for use.""" + # 1. Discover available servers + servers_resp = await e2e_client.get("/api/v1/mcp/servers") + assert servers_resp.status_code == 200 + servers = servers_resp.json()["servers"] + assert len(servers) > 0 + + # 2. Check context engine health + health_resp = await e2e_client.get("/api/v1/context/health") + assert health_resp.status_code == 200 + health = health_resp.json() + assert health["status"] == "healthy" + + # 3. Get token budget for a model + budget_resp = await e2e_client.get("/api/v1/context/budget/claude-3-sonnet") + assert budget_resp.status_code == 200 + budget = budget_resp.json() + + # 4. Verify system is ready for context assembly + assert budget["total_tokens"] > 0 + assert health["mcp_connected"] is True + + @pytest.mark.skip( + reason="Requires MCP servers (llm-gateway, knowledge-base) to be running" + ) + async def test_full_context_assembly_workflow(self, e2e_client, e2e_superuser): + """Test complete context assembly workflow with superuser.""" + project_id = f"e2e-project-{uuid4().hex[:8]}" + agent_id = f"e2e-agent-{uuid4().hex[:8]}" + + # 1. Check budget before assembly + budget_resp = await e2e_client.get("/api/v1/context/budget/claude-3-sonnet") + assert budget_resp.status_code == 200 + _ = budget_resp.json() # Verify valid response + + # 2. Count tokens in sample content + count_resp = await e2e_client.post( + "/api/v1/context/count-tokens", + json={"content": "This is a test message for context assembly."}, + ) + assert count_resp.status_code == 200 + token_count = count_resp.json()["token_count"] + assert token_count > 0 + + # 3. Assemble context + assemble_resp = await e2e_client.post( + "/api/v1/context/assemble", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + json={ + "project_id": project_id, + "agent_id": agent_id, + "query": "Summarize the current project status", + "model": "claude-3-sonnet", + "system_prompt": "You are a project management assistant.", + "task_description": "Generate a status report", + "conversation_history": [ + {"role": "user", "content": "What's the project status?"}, + { + "role": "assistant", + "content": "Let me check the current status.", + }, + ], + "compress": True, + "use_cache": False, + }, + ) + assert assemble_resp.status_code == 200 + assembled = assemble_resp.json() + + # 4. Verify assembly results + assert assembled["total_tokens"] > 0 + assert assembled["context_count"] > 0 + assert assembled["budget_used_percent"] > 0 + assert assembled["budget_used_percent"] <= 100 + + # 5. Get stats to verify the operation was recorded + stats_resp = await e2e_client.get( + "/api/v1/context/stats", + headers={ + "Authorization": f"Bearer {e2e_superuser['tokens']['access_token']}" + }, + ) + assert stats_resp.status_code == 200 diff --git a/backend/tests/e2e/test_project_workflows.py b/backend/tests/e2e/test_project_workflows.py new file mode 100644 index 0000000..5218f14 --- /dev/null +++ b/backend/tests/e2e/test_project_workflows.py @@ -0,0 +1,684 @@ +""" +Project and Agent E2E Workflow Tests. + +Tests complete project management workflows with real PostgreSQL: +- Project CRUD and lifecycle management +- Agent spawning and lifecycle +- Issue management within projects +- Sprint planning and execution + +Usage: + make test-e2e # Run all E2E tests +""" + +from datetime import date, timedelta +from uuid import uuid4 + +import pytest + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.postgres, + pytest.mark.asyncio, +] + + +class TestProjectCRUDWorkflows: + """Test complete project CRUD workflows.""" + + async def test_create_project_workflow(self, e2e_client): + """Test creating a project as authenticated user.""" + # Register and login + email = f"project-owner-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Project", + "last_name": "Owner", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + # Create project + project_slug = f"test-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "name": "E2E Test Project", + "slug": project_slug, + "description": "A project for E2E testing", + "autonomy_level": "milestone", + }, + ) + + assert create_resp.status_code == 201, f"Failed: {create_resp.text}" + project = create_resp.json() + assert project["name"] == "E2E Test Project" + assert project["slug"] == project_slug + assert project["status"] == "active" + assert project["agent_count"] == 0 + assert project["issue_count"] == 0 + + async def test_list_projects_only_shows_owned(self, e2e_client): + """Test that users only see their own projects.""" + # Create two users with projects + users = [] + for i in range(2): + email = f"user-{i}-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": f"User{i}", + "last_name": "Test", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + # Each user creates their own project + project_slug = f"user{i}-project-{uuid4().hex[:8]}" + await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "name": f"User {i} Project", + "slug": project_slug, + }, + ) + users.append({"email": email, "tokens": tokens, "slug": project_slug}) + + # User 0 should only see their project + list_resp = await e2e_client.get( + "/api/v1/projects", + headers={"Authorization": f"Bearer {users[0]['tokens']['access_token']}"}, + ) + assert list_resp.status_code == 200 + data = list_resp.json() + slugs = [p["slug"] for p in data["data"]] + assert users[0]["slug"] in slugs + assert users[1]["slug"] not in slugs + + async def test_project_lifecycle_pause_resume(self, e2e_client): + """Test pausing and resuming a project.""" + # Setup user and project + email = f"lifecycle-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Lifecycle", + "last_name": "Test", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"lifecycle-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Lifecycle Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Pause the project + pause_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/pause", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert pause_resp.status_code == 200 + assert pause_resp.json()["status"] == "paused" + + # Resume the project + resume_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/resume", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert resume_resp.status_code == 200 + assert resume_resp.json()["status"] == "active" + + async def test_project_archive(self, e2e_client): + """Test archiving a project (soft delete).""" + # Setup user and project + email = f"archive-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Archive", + "last_name": "Test", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"archive-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Archive Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Archive the project + archive_resp = await e2e_client.delete( + f"/api/v1/projects/{project_id}", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert archive_resp.status_code == 200 + assert archive_resp.json()["success"] is True + + # Verify project is archived + get_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert get_resp.status_code == 200 + assert get_resp.json()["status"] == "archived" + + +class TestIssueWorkflows: + """Test issue management workflows within projects.""" + + async def test_create_and_list_issues(self, e2e_client): + """Test creating and listing issues in a project.""" + # Setup user and project + email = f"issue-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Issue", + "last_name": "Tester", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"issue-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Issue Test Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create multiple issues + issues = [] + for i in range(3): + issue_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": f"Test Issue {i + 1}", + "body": f"Description for issue {i + 1}", + "priority": ["low", "medium", "high"][i], + }, + ) + assert issue_resp.status_code == 201, f"Failed: {issue_resp.text}" + issues.append(issue_resp.json()) + + # List issues + list_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert list_resp.status_code == 200 + data = list_resp.json() + assert data["pagination"]["total"] == 3 + + async def test_issue_status_transitions(self, e2e_client): + """Test issue status workflow transitions.""" + # Setup user and project + email = f"status-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Status", + "last_name": "Tester", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"status-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Status Test Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create issue + issue_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": "Status Workflow Issue", + "body": "Testing status transitions", + }, + ) + issue = issue_resp.json() + issue_id = issue["id"] + assert issue["status"] == "open" + + # Transition through statuses + for new_status in ["in_progress", "in_review", "closed"]: + update_resp = await e2e_client.patch( + f"/api/v1/projects/{project_id}/issues/{issue_id}", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"status": new_status}, + ) + assert update_resp.status_code == 200, f"Failed: {update_resp.text}" + assert update_resp.json()["status"] == new_status + + async def test_issue_filtering(self, e2e_client): + """Test issue filtering by status and priority.""" + # Setup user and project + email = f"filter-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Filter", + "last_name": "Tester", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"filter-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Filter Test Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create issues with different priorities + for priority in ["low", "medium", "high"]: + await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": f"{priority.title()} Priority Issue", + "priority": priority, + }, + ) + + # Filter by high priority + filter_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + params={"priority": "high"}, + ) + assert filter_resp.status_code == 200 + data = filter_resp.json() + assert data["pagination"]["total"] == 1 + assert data["data"][0]["priority"] == "high" + + +class TestSprintWorkflows: + """Test sprint planning and execution workflows.""" + + async def test_sprint_lifecycle(self, e2e_client): + """Test complete sprint lifecycle: plan -> start -> complete.""" + # Setup user and project + email = f"sprint-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Sprint", + "last_name": "Tester", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"sprint-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Sprint Test Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create sprint + today = date.today() + sprint_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/sprints", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "name": "Sprint 1", + "number": 1, + "goal": "Complete initial features", + "start_date": today.isoformat(), + "end_date": (today + timedelta(days=14)).isoformat(), + }, + ) + assert sprint_resp.status_code == 201, f"Failed: {sprint_resp.text}" + sprint = sprint_resp.json() + sprint_id = sprint["id"] + assert sprint["status"] == "planned" + + # Start sprint + start_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/start", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert start_resp.status_code == 200, f"Failed: {start_resp.text}" + assert start_resp.json()["status"] == "active" + + # Complete sprint + complete_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/complete", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert complete_resp.status_code == 200, f"Failed: {complete_resp.text}" + assert complete_resp.json()["status"] == "completed" + + async def test_add_issues_to_sprint(self, e2e_client): + """Test adding issues to a sprint.""" + # Setup user and project + email = f"sprint-issues-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "SprintIssues", + "last_name": "Tester", + }, + ) + login_resp = await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + tokens = login_resp.json() + + project_slug = f"sprint-issues-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Sprint Issues Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create sprint + today = date.today() + sprint_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/sprints", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "name": "Sprint 1", + "number": 1, + "start_date": today.isoformat(), + "end_date": (today + timedelta(days=14)).isoformat(), + }, + ) + assert sprint_resp.status_code == 201, f"Failed: {sprint_resp.text}" + sprint = sprint_resp.json() + sprint_id = sprint["id"] + + # Create issue + issue_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": "Sprint Issue", + "story_points": 5, + }, + ) + issue = issue_resp.json() + issue_id = issue["id"] + + # Add issue to sprint + add_resp = await e2e_client.post( + f"/api/v1/projects/{project_id}/sprints/{sprint_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + params={"issue_id": issue_id}, + ) + assert add_resp.status_code == 200, f"Failed: {add_resp.text}" + + # Verify issue is in sprint + issue_check = await e2e_client.get( + f"/api/v1/projects/{project_id}/issues/{issue_id}", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert issue_check.json()["sprint_id"] == sprint_id + + +class TestCrossEntityValidation: + """Test validation across related entities.""" + + async def test_cannot_access_other_users_project(self, e2e_client): + """Test that users cannot access projects they don't own.""" + # Create two users + owner_email = f"owner-{uuid4().hex[:8]}@example.com" + other_email = f"other-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + # Register owner + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": owner_email, + "password": password, + "first_name": "Owner", + "last_name": "User", + }, + ) + owner_tokens = ( + await e2e_client.post( + "/api/v1/auth/login", + json={"email": owner_email, "password": password}, + ) + ).json() + + # Register other user + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": other_email, + "password": password, + "first_name": "Other", + "last_name": "User", + }, + ) + other_tokens = ( + await e2e_client.post( + "/api/v1/auth/login", + json={"email": other_email, "password": password}, + ) + ).json() + + # Owner creates project + project_slug = f"private-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {owner_tokens['access_token']}"}, + json={"name": "Private Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Other user tries to access + access_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}", + headers={"Authorization": f"Bearer {other_tokens['access_token']}"}, + ) + assert access_resp.status_code == 403 + + async def test_duplicate_project_slug_rejected(self, e2e_client): + """Test that duplicate project slugs are rejected.""" + email = f"dup-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Dup", + "last_name": "Tester", + }, + ) + tokens = ( + await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + ).json() + + slug = f"unique-slug-{uuid4().hex[:8]}" + + # First creation should succeed + resp1 = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "First Project", "slug": slug}, + ) + assert resp1.status_code == 201 + + # Second creation with same slug should fail + resp2 = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Second Project", "slug": slug}, + ) + assert resp2.status_code == 409 # Conflict + + +class TestIssueStats: + """Test issue statistics endpoints.""" + + async def test_issue_stats_aggregation(self, e2e_client): + """Test that issue stats are correctly aggregated.""" + email = f"stats-test-{uuid4().hex[:8]}@example.com" + password = "SecurePass123!" + + await e2e_client.post( + "/api/v1/auth/register", + json={ + "email": email, + "password": password, + "first_name": "Stats", + "last_name": "Tester", + }, + ) + tokens = ( + await e2e_client.post( + "/api/v1/auth/login", + json={"email": email, "password": password}, + ) + ).json() + + project_slug = f"stats-project-{uuid4().hex[:8]}" + create_resp = await e2e_client.post( + "/api/v1/projects", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={"name": "Stats Project", "slug": project_slug}, + ) + project = create_resp.json() + project_id = project["id"] + + # Create issues with different priorities and story points + await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": "High Priority", + "priority": "high", + "story_points": 8, + }, + ) + await e2e_client.post( + f"/api/v1/projects/{project_id}/issues", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + json={ + "project_id": project_id, + "title": "Low Priority", + "priority": "low", + "story_points": 2, + }, + ) + + # Get stats + stats_resp = await e2e_client.get( + f"/api/v1/projects/{project_id}/issues/stats", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert stats_resp.status_code == 200 + stats = stats_resp.json() + assert stats["total"] == 2 + assert stats["total_story_points"] == 10