forked from cardosofelipe/fast-next-template
feat(tests): add comprehensive E2E tests for MCP and Agent workflows
- Introduced end-to-end tests for MCP workflows, including server discovery, authentication, context engine operations, error handling, and input validation. - Added full lifecycle tests for agent workflows, covering type management, instance spawning, status transitions, and admin-only operations. - Enhanced test coverage for real-world MCP and Agent scenarios across PostgreSQL and async environments.
This commit is contained in:
646
backend/tests/e2e/test_agent_workflows.py
Normal file
646
backend/tests/e2e/test_agent_workflows.py
Normal file
@@ -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
|
||||
460
backend/tests/e2e/test_mcp_workflows.py
Normal file
460
backend/tests/e2e/test_mcp_workflows.py
Normal file
@@ -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
|
||||
684
backend/tests/e2e/test_project_workflows.py
Normal file
684
backend/tests/e2e/test_project_workflows.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user