""" Tests for Custom Fields feature. """ import pytest from app.models import User, Space, Project, Task, TaskStatus, CustomField, TaskCustomValue from app.services.formula_service import FormulaService, FormulaError, CircularReferenceError class TestCustomFieldsCRUD: """Test custom fields CRUD operations.""" def setup_project(self, db, owner_id: str): """Create a space and project for testing.""" space = Space( id="test-space-001", name="Test Space", owner_id=owner_id, ) db.add(space) project = Project( id="test-project-001", space_id=space.id, title="Test Project", owner_id=owner_id, ) db.add(project) # Add default task status status = TaskStatus( id="test-status-001", project_id=project.id, name="To Do", color="#3B82F6", position=0, ) db.add(status) db.commit() return project def test_create_text_field(self, client, db, admin_token): """Test creating a text custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") response = client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "Sprint Number", "field_type": "text", "is_required": False, }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Sprint Number" assert data["field_type"] == "text" assert data["is_required"] is False def test_create_number_field(self, client, db, admin_token): """Test creating a number custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") response = client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "Story Points", "field_type": "number", "is_required": True, }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Story Points" assert data["field_type"] == "number" assert data["is_required"] is True def test_create_dropdown_field(self, client, db, admin_token): """Test creating a dropdown custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") response = client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "Component", "field_type": "dropdown", "options": ["Frontend", "Backend", "Database", "API"], "is_required": False, }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Component" assert data["field_type"] == "dropdown" assert data["options"] == ["Frontend", "Backend", "Database", "API"] def test_create_dropdown_field_without_options_fails(self, client, db, admin_token): """Test that creating a dropdown field without options fails.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") response = client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "Component", "field_type": "dropdown", "options": [], }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 422 # Validation error def test_create_formula_field(self, client, db, admin_token): """Test creating a formula custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # First create a number field to reference client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "hours_worked", "field_type": "number", }, headers={"Authorization": f"Bearer {admin_token}"}, ) # Create formula field response = client.post( f"/api/projects/{project.id}/custom-fields", json={ "name": "Progress", "field_type": "formula", "formula": "{time_spent} / {original_estimate} * 100", }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Progress" assert data["field_type"] == "formula" assert "{time_spent}" in data["formula"] def test_list_custom_fields(self, client, db, admin_token): """Test listing custom fields for a project.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # Create some fields client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Field 1", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Field 2", "field_type": "number"}, headers={"Authorization": f"Bearer {admin_token}"}, ) response = client.get( f"/api/projects/{project.id}/custom-fields", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 data = response.json() assert data["total"] == 2 assert len(data["fields"]) == 2 def test_update_custom_field(self, client, db, admin_token): """Test updating a custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # Create a field create_response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Original Name", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) field_id = create_response.json()["id"] # Update it response = client.put( f"/api/custom-fields/{field_id}", json={"name": "Updated Name", "is_required": True}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 data = response.json() assert data["name"] == "Updated Name" assert data["is_required"] is True def test_delete_custom_field(self, client, db, admin_token): """Test deleting a custom field.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # Create a field create_response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "To Delete", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) field_id = create_response.json()["id"] # Delete it response = client.delete( f"/api/custom-fields/{field_id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 204 # Verify it's gone get_response = client.get( f"/api/custom-fields/{field_id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert get_response.status_code == 404 def test_max_fields_limit(self, client, db, admin_token): """Test that maximum 20 custom fields per project is enforced.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # Create 20 fields for i in range(20): response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": f"Field {i}", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 # Try to create the 21st field response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Field 21", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 400 assert "Maximum" in response.json()["detail"] def test_duplicate_name_rejected(self, client, db, admin_token): """Test that duplicate field names are rejected.""" project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") # Create a field client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Unique Name", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) # Try to create another with same name response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "Unique Name", "field_type": "number"}, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 400 assert "already exists" in response.json()["detail"] class TestFormulaService: """Test formula parsing and calculation.""" def test_extract_field_references(self): """Test extracting field references from formulas.""" formula = "{time_spent} / {original_estimate} * 100" refs = FormulaService.extract_field_references(formula) assert refs == {"time_spent", "original_estimate"} def test_extract_multiple_references(self): """Test extracting multiple field references.""" formula = "{field_a} + {field_b} - {field_c}" refs = FormulaService.extract_field_references(formula) assert refs == {"field_a", "field_b", "field_c"} def test_safe_eval_addition(self): """Test safe evaluation of addition.""" result = FormulaService._safe_eval("10 + 5") assert float(result) == 15.0 def test_safe_eval_division(self): """Test safe evaluation of division.""" result = FormulaService._safe_eval("20 / 4") assert float(result) == 5.0 def test_safe_eval_complex_expression(self): """Test safe evaluation of complex expression.""" result = FormulaService._safe_eval("(10 + 5) * 2 / 3") assert float(result) == 10.0 def test_safe_eval_division_by_zero(self): """Test that division by zero returns 0.""" result = FormulaService._safe_eval("10 / 0") assert float(result) == 0.0 def test_safe_eval_negative_numbers(self): """Test safe evaluation with negative numbers.""" result = FormulaService._safe_eval("-5 + 10") assert float(result) == 5.0 class TestCustomValuesWithTasks: """Test custom values integration with tasks.""" def setup_project_with_fields(self, db, client, admin_token, owner_id: str): """Create a project with custom fields for testing.""" space = Space( id="test-space-002", name="Test Space", owner_id=owner_id, ) db.add(space) project = Project( id="test-project-002", space_id=space.id, title="Test Project", owner_id=owner_id, ) db.add(project) status = TaskStatus( id="test-status-002", project_id=project.id, name="To Do", color="#3B82F6", position=0, ) db.add(status) db.commit() # Create custom fields via API text_response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "sprint_number", "field_type": "text"}, headers={"Authorization": f"Bearer {admin_token}"}, ) text_field_id = text_response.json()["id"] number_response = client.post( f"/api/projects/{project.id}/custom-fields", json={"name": "story_points", "field_type": "number"}, headers={"Authorization": f"Bearer {admin_token}"}, ) number_field_id = number_response.json()["id"] return project, text_field_id, number_field_id def test_create_task_with_custom_values(self, client, db, admin_token): """Test creating a task with custom values.""" project, text_field_id, number_field_id = self.setup_project_with_fields( db, client, admin_token, "00000000-0000-0000-0000-000000000001" ) response = client.post( f"/api/projects/{project.id}/tasks", json={ "title": "Test Task", "custom_values": [ {"field_id": text_field_id, "value": "Sprint 5"}, {"field_id": number_field_id, "value": "8"}, ], }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 201 def test_get_task_includes_custom_values(self, client, db, admin_token): """Test that getting a task includes custom values.""" project, text_field_id, number_field_id = self.setup_project_with_fields( db, client, admin_token, "00000000-0000-0000-0000-000000000001" ) # Create task with custom values create_response = client.post( f"/api/projects/{project.id}/tasks", json={ "title": "Test Task", "custom_values": [ {"field_id": text_field_id, "value": "Sprint 5"}, {"field_id": number_field_id, "value": "8"}, ], }, headers={"Authorization": f"Bearer {admin_token}"}, ) task_id = create_response.json()["id"] # Get task and check custom values get_response = client.get( f"/api/tasks/{task_id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert get_response.status_code == 200 data = get_response.json() assert data["custom_values"] is not None assert len(data["custom_values"]) >= 2 def test_update_task_custom_values(self, client, db, admin_token): """Test updating custom values on a task.""" project, text_field_id, number_field_id = self.setup_project_with_fields( db, client, admin_token, "00000000-0000-0000-0000-000000000001" ) # Create task create_response = client.post( f"/api/projects/{project.id}/tasks", json={ "title": "Test Task", "custom_values": [ {"field_id": text_field_id, "value": "Sprint 5"}, ], }, headers={"Authorization": f"Bearer {admin_token}"}, ) task_id = create_response.json()["id"] # Update custom values update_response = client.patch( f"/api/tasks/{task_id}", json={ "custom_values": [ {"field_id": text_field_id, "value": "Sprint 6"}, {"field_id": number_field_id, "value": "13"}, ], }, headers={"Authorization": f"Bearer {admin_token}"}, ) assert update_response.status_code == 200