diff --git a/examples/project.py b/examples/project.py index afc385c..3866365 100644 --- a/examples/project.py +++ b/examples/project.py @@ -32,7 +32,13 @@ from tfe._http import HTTPTransport from tfe.config import TFEConfig from tfe.resources.projects import Projects -from tfe.types import ProjectCreateOptions, ProjectListOptions, ProjectUpdateOptions +from tfe.types import ( + ProjectAddTagBindingsOptions, + ProjectCreateOptions, + ProjectListOptions, + ProjectUpdateOptions, + TagBinding, +) @pytest.fixture @@ -567,6 +573,258 @@ def test_error_handling_integration(integration_client): print("โœ… All error handling scenarios tested successfully") +def test_project_tag_bindings_integration(integration_client): + """ + Integration test for project tag binding operations + + Note: Project tag bindings may not be available in all HCP Terraform plans. + This test gracefully handles unavailable features while testing what's available. + """ + projects, org = integration_client + + unique_id = str(uuid.uuid4())[:8] + test_name = f"tag-test-{unique_id}" + test_description = f"Project for testing tag bindings - {unique_id}" + project_id = None + + try: + # Create a test project for tagging operations + print(f"๐Ÿท๏ธ Setting up test project for tagging: {test_name}") + create_options = ProjectCreateOptions( + name=test_name, description=test_description + ) + created_project = projects.create(org, create_options) + project_id = created_project.id + print(f"โœ… Created test project: {project_id}") + + # Test 1: List tag bindings (this should work) + print("๐Ÿท๏ธ Testing LIST_TAG_BINDINGS") + try: + initial_tag_bindings = projects.list_tag_bindings(project_id) + assert isinstance(initial_tag_bindings, list), "Should return a list" + print(f"โœ… list_tag_bindings works: {len(initial_tag_bindings)} bindings") + list_tag_bindings_available = True + except Exception as e: + print(f"โŒ list_tag_bindings not available: {e}") + list_tag_bindings_available = False + + # Test 2: List effective tag bindings + print("๐Ÿท๏ธ Testing LIST_EFFECTIVE_TAG_BINDINGS") + try: + effective_bindings = projects.list_effective_tag_bindings(project_id) + assert isinstance(effective_bindings, list), "Should return a list" + print( + f"โœ… list_effective_tag_bindings works: {len(effective_bindings)} bindings" + ) + effective_tag_bindings_available = True + except Exception as e: + print(f"โŒ list_effective_tag_bindings not available: {e}") + print(" This feature may require a higher HCP Terraform plan") + effective_tag_bindings_available = False + + # Test 3: Add tag bindings (if basic listing works) + if list_tag_bindings_available: + print("๐Ÿท๏ธ Testing ADD_TAG_BINDINGS") + try: + test_tags = [ + TagBinding(key="environment", value="testing"), + TagBinding(key="integration-test", value="true"), + ] + add_options = ProjectAddTagBindingsOptions(tag_bindings=test_tags) + added_bindings = projects.add_tag_bindings(project_id, add_options) + + assert isinstance(added_bindings, list), "Should return a list" + assert len(added_bindings) == len(test_tags), ( + "Should return all added tags" + ) + print( + f"โœ… add_tag_bindings works: added {len(added_bindings)} bindings" + ) + + # Verify tags were actually added + current_bindings = projects.list_tag_bindings(project_id) + added_keys = {binding.key for binding in current_bindings} + for tag in test_tags: + assert tag.key in added_keys, ( + f"Tag {tag.key} not found after adding" + ) + print(f"โœ… Verified tags added: {len(current_bindings)} total bindings") + + add_tag_bindings_available = True + + # Test 4: Delete tag bindings + print("๐Ÿท๏ธ Testing DELETE_TAG_BINDINGS") + try: + result = projects.delete_tag_bindings(project_id) + assert result is None, "Delete should return None" + + # Verify deletion + final_bindings = projects.list_tag_bindings(project_id) + print( + f"โœ… delete_tag_bindings works: {len(final_bindings)} bindings remain" + ) + delete_tag_bindings_available = True + except Exception as e: + print(f"โŒ delete_tag_bindings not available: {e}") + delete_tag_bindings_available = False + + except Exception as e: + print(f"โŒ add_tag_bindings not available: {e}") + print(" This feature may require a higher HCP Terraform plan") + add_tag_bindings_available = False + delete_tag_bindings_available = False + else: + add_tag_bindings_available = False + delete_tag_bindings_available = False + + # Summary + print("\n๐Ÿ“Š Project Tag Bindings API Availability Summary:") + features = [ + ("list_tag_bindings", list_tag_bindings_available), + ("list_effective_tag_bindings", effective_tag_bindings_available), + ("add_tag_bindings", add_tag_bindings_available), + ("delete_tag_bindings", delete_tag_bindings_available), + ] + + for feature_name, available in features: + status = "โœ… Available" if available else "โŒ Not Available" + print(f" {feature_name}: {status}") + + available_count = sum(available for _, available in features) + print( + f"\n๐ŸŽฏ {available_count}/4 tag binding features are available in this HCP Terraform organization" + ) + + if available_count == 4: + print("๐ŸŽ‰ All project tag binding operations work perfectly!") + elif available_count > 0: + print("โœ… Partial functionality available - basic operations work!") + else: + print("โš ๏ธ Tag binding features may require a higher HCP Terraform plan") + + except Exception as e: + pytest.fail( + f"Project tag binding integration test failed unexpectedly. " + f"This may indicate a configuration or connectivity issue. Error: {e}" + ) + + finally: + # Clean up: Delete the test project + if project_id: + try: + print(f"๐Ÿงน Cleaning up test project: {project_id}") + projects.delete(project_id) + print("โœ… Test project deleted successfully") + except Exception as cleanup_error: + print( + f"โš ๏ธ Warning: Failed to clean up test project {project_id}: {cleanup_error}" + ) + + +def test_project_tag_bindings_error_scenarios(integration_client): + """ + Test error handling for project tag binding operations + + Tests various error conditions: + - Invalid project IDs + - Empty tag binding lists + - Non-existent projects + """ + projects, org = integration_client + + print("๐Ÿท๏ธ Testing tag binding error scenarios") + + # Test invalid project ID validation + print("๐Ÿšซ Testing invalid project ID scenarios") + + invalid_project_ids = ["", "x", "invalid-id", None] + + for invalid_id in invalid_project_ids: + if invalid_id is None: + continue # Skip None as it will cause different error + + try: + projects.list_tag_bindings(invalid_id) + pytest.fail( + f"Should have raised ValueError for invalid project ID: {invalid_id}" + ) + except ValueError as e: + print(f"โœ… Correctly rejected invalid project ID '{invalid_id}': {e}") + assert "Project ID is required and must be valid" in str(e) + + try: + projects.list_effective_tag_bindings(invalid_id) + pytest.fail( + f"Should have raised ValueError for invalid project ID: {invalid_id}" + ) + except ValueError as e: + print(f"โœ… Correctly rejected invalid project ID '{invalid_id}': {e}") + + try: + projects.delete_tag_bindings(invalid_id) + pytest.fail( + f"Should have raised ValueError for invalid project ID: {invalid_id}" + ) + except ValueError as e: + print(f"โœ… Correctly rejected invalid project ID '{invalid_id}': {e}") + + # Test empty tag binding list + print("๐Ÿšซ Testing empty tag binding list") + try: + fake_project_id = "prj-fakefakefake123" + empty_options = ProjectAddTagBindingsOptions(tag_bindings=[]) + projects.add_tag_bindings(fake_project_id, empty_options) + pytest.fail("Should have raised ValueError for empty tag binding list") + except ValueError as e: + print(f"โœ… Correctly rejected empty tag binding list: {e}") + assert "At least one tag binding is required" in str(e) + + # Test non-existent project operations + print("๐Ÿšซ Testing operations on non-existent project") + fake_project_id = "prj-doesnotexist123" + + # These should raise HTTP errors (404) from the API + for operation_name, operation_func in [ + ("list_tag_bindings", lambda: projects.list_tag_bindings(fake_project_id)), + ( + "list_effective_tag_bindings", + lambda: projects.list_effective_tag_bindings(fake_project_id), + ), + ("delete_tag_bindings", lambda: projects.delete_tag_bindings(fake_project_id)), + ]: + try: + operation_func() + pytest.fail(f"{operation_name} should have failed for non-existent project") + except Exception as e: + print( + f"โœ… {operation_name} correctly failed for non-existent project: {type(e).__name__}" + ) + # Should be some kind of HTTP error (404, not found, etc.) + assert ( + "404" in str(e) + or "not found" in str(e).lower() + or "does not exist" in str(e).lower() + ) + + # Test add_tag_bindings on non-existent project + try: + test_tags = [TagBinding(key="test", value="value")] + add_options = ProjectAddTagBindingsOptions(tag_bindings=test_tags) + projects.add_tag_bindings(fake_project_id, add_options) + pytest.fail("add_tag_bindings should have failed for non-existent project") + except Exception as e: + print( + f"โœ… add_tag_bindings correctly failed for non-existent project: {type(e).__name__}" + ) + assert ( + "404" in str(e) + or "not found" in str(e).lower() + or "does not exist" in str(e).lower() + ) + + print("โœ… All tag binding error scenarios tested successfully") + + if __name__ == "__main__": """ You can also run this file directly for quick testing: diff --git a/src/tfe/resources/projects.py b/src/tfe/resources/projects.py index b2f9bac..e6a35dc 100644 --- a/src/tfe/resources/projects.py +++ b/src/tfe/resources/projects.py @@ -6,10 +6,13 @@ from typing import Any from ..types import ( + EffectiveTagBinding, Project, + ProjectAddTagBindingsOptions, ProjectCreateOptions, ProjectListOptions, ProjectUpdateOptions, + TagBinding, ) from ..utils import valid_string, valid_string_id from ._base import _Service @@ -261,3 +264,113 @@ def delete(self, project_id: str) -> None: path = f"/api/v2/projects/{project_id}" self.t.request("DELETE", path) + + def list_tag_bindings(self, project_id: str) -> builtins.list[TagBinding]: + """List tag bindings for a project""" + # Validate inputs + if not valid_string_id(project_id): + raise ValueError("Project ID is required and must be valid") + + path = f"/api/v2/projects/{project_id}/tag-bindings" + response = self.t.request("GET", path) + data = response.json()["data"] + + tag_bindings = [] + for item in data: + attr = item.get("attributes", {}) or {} + tag_binding = TagBinding( + id=_safe_str(item.get("id")), + key=_safe_str(attr.get("key")), + value=_safe_str(attr.get("value")), + ) + tag_bindings.append(tag_binding) + + return tag_bindings + + def list_effective_tag_bindings( + self, project_id: str + ) -> builtins.list[EffectiveTagBinding]: + """List effective tag bindings for a project""" + # Validate inputs + if not valid_string_id(project_id): + raise ValueError("Project ID is required and must be valid") + + path = f"/api/v2/projects/{project_id}/tag-bindings/effective" + response = self.t.request("GET", path) + data = response.json()["data"] + + effective_tag_bindings = [] + for item in data: + attr = item.get("attributes", {}) or {} + links = item.get("links", {}) or {} + effective_tag_binding = EffectiveTagBinding( + id=_safe_str(item.get("id")), + key=_safe_str(attr.get("key")), + value=_safe_str(attr.get("value")), + links=links, + ) + effective_tag_bindings.append(effective_tag_binding) + + return effective_tag_bindings + + def add_tag_bindings( + self, project_id: str, options: ProjectAddTagBindingsOptions + ) -> builtins.list[TagBinding]: + """Add or update tag bindings on a project + + This endpoint adds key-value tag bindings to an existing project or updates + existing tag binding values. It cannot be used to remove tag bindings. + This operation is additive. + + Constraints: + - A project can have up to 10 tags + - Keys can have up to 128 characters + - Values can have up to 256 characters + - Keys/values support alphanumeric chars and symbols: _, ., =, +, -, @, : + - Cannot use hc: and hcp: as key prefixes + """ + # Validate inputs + if not valid_string_id(project_id): + raise ValueError("Project ID is required and must be valid") + + if not options.tag_bindings: + raise ValueError("At least one tag binding is required") + + path = f"/api/v2/projects/{project_id}/tag-bindings" + + # Build payload with tag binding data + data_items = [] + for tag_binding in options.tag_bindings: + attributes = {"key": tag_binding.key} + if tag_binding.value is not None: + attributes["value"] = tag_binding.value + + data_items.append({"type": "tag-bindings", "attributes": attributes}) + + payload = {"data": data_items} + + # Use PATCH method as per API documentation + response = self.t.request("PATCH", path, json_body=payload) + data = response.json()["data"] + + # Parse response into TagBinding objects + tag_bindings = [] + for item in data: + attr = item.get("attributes", {}) or {} + tag_binding = TagBinding( + id=_safe_str(item.get("id")), + key=_safe_str(attr.get("key")), + value=_safe_str(attr.get("value")), + ) + tag_bindings.append(tag_binding) + + return tag_bindings + + def delete_tag_bindings(self, project_id: str) -> None: + """Delete all tag bindings from a project""" + # Validate inputs + if not valid_string_id(project_id): + raise ValueError("Project ID is required and must be valid") + + path = f"/api/v2/projects/{project_id}/tag-bindings" + self.t.request("DELETE", path) diff --git a/src/tfe/types.py b/src/tfe/types.py index f03f4c4..3cfb372 100644 --- a/src/tfe/types.py +++ b/src/tfe/types.py @@ -147,6 +147,12 @@ class ProjectUpdateOptions(BaseModel): description: str | None = None +class ProjectAddTagBindingsOptions(BaseModel): + """Options for adding tag bindings to a project""" + + tag_bindings: list[TagBinding] = Field(default_factory=list) + + class Workspace(BaseModel): id: str name: str diff --git a/tests/units/test_project.py b/tests/units/test_project.py index 7124bd2..7876f74 100644 --- a/tests/units/test_project.py +++ b/tests/units/test_project.py @@ -1,7 +1,14 @@ from unittest.mock import Mock from tfe.resources.projects import Projects, _safe_str -from tfe.types import Project, ProjectCreateOptions, ProjectUpdateOptions +from tfe.types import ( + EffectiveTagBinding, + Project, + ProjectAddTagBindingsOptions, + ProjectCreateOptions, + ProjectUpdateOptions, + TagBinding, +) class TestProjects: @@ -9,12 +16,50 @@ def setup_method(self): """Setup method that runs before each test""" self.mock_transport = Mock() self.projects_service = Projects(self.mock_transport) + self.project_id = "prj-test123" + + def test_add_tag_bindings_with_none_value(self): + """Test adding tag bindings with None value""" + # Prepare test data with None value + tag_bindings = [TagBinding(key="flag", value=None)] + options = ProjectAddTagBindingsOptions(tag_bindings=tag_bindings) + + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-flag123", + "type": "tag-bindings", + "attributes": {"key": "flag", "value": None}, + } + ] + } + self.mock_transport.request.return_value = mock_response + + # Call the method + self.projects_service.add_tag_bindings(self.project_id, options) + + # Verify payload doesn't include value for None values + expected_payload = { + "data": [ + { + "type": "tag-bindings", + "attributes": {"key": "flag"}, # No value field + } + ] + } + self.mock_transport.request.assert_called_once_with( + "PATCH", + f"/api/v2/projects/{self.project_id}/tag-bindings", + json_body=expected_payload, + ) def test_projects_service_init(self): """Test that Projects service initializes correctly""" mock_transport = Mock() - service = Projects(mock_transport) - assert service.t == mock_transport + projects_service = Projects(mock_transport) + assert projects_service.t == mock_transport def test_list_projects_success(self): """Test successful listing of projects""" @@ -222,3 +267,277 @@ def test_read_project_missing_organization(self): result = self.projects_service.read(project_id) assert result.organization == "" # Should default to empty string + + +class TestProjectTagBindings: + """Test class for project tag binding operations""" + + def setup_method(self): + """Setup method that runs before each test""" + self.mock_transport = Mock() + self.projects_service = Projects(self.mock_transport) + self.project_id = "prj-test123" + + def test_list_tag_bindings_success(self): + """Test successful listing of tag bindings""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-123", + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "production"}, + }, + { + "id": "tb-456", + "type": "tag-bindings", + "attributes": {"key": "team", "value": "platform"}, + }, + ] + } + self.mock_transport.request.return_value = mock_response + + # Call the method + result = self.projects_service.list_tag_bindings(self.project_id) + + # Assertions + assert len(result) == 2 + assert isinstance(result[0], TagBinding) + assert isinstance(result[1], TagBinding) + + assert result[0].id == "tb-123" + assert result[0].key == "environment" + assert result[0].value == "production" + + assert result[1].id == "tb-456" + assert result[1].key == "team" + assert result[1].value == "platform" + + # Verify API call + self.mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/projects/{self.project_id}/tag-bindings" + ) + + def test_list_tag_bindings_empty_response(self): + """Test listing tag bindings with empty response""" + # Mock empty API response + mock_response = Mock() + mock_response.json.return_value = {"data": []} + self.mock_transport.request.return_value = mock_response + + result = self.projects_service.list_tag_bindings(self.project_id) + + assert len(result) == 0 + assert isinstance(result, list) + + def test_list_tag_bindings_invalid_project_id(self): + """Test listing tag bindings with invalid project ID""" + import pytest + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.list_tag_bindings("") + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.list_tag_bindings("x") # Too short + + def test_list_effective_tag_bindings_success(self): + """Test successful listing of effective tag bindings""" + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "etb-123", + "type": "effective-tag-bindings", + "attributes": {"key": "environment", "value": "production"}, + "links": { + "self": "/api/v2/projects/prj-test123/tag-bindings/etb-123" + }, + } + ] + } + self.mock_transport.request.return_value = mock_response + + # Call the method + result = self.projects_service.list_effective_tag_bindings(self.project_id) + + # Assertions + assert len(result) == 1 + assert isinstance(result[0], EffectiveTagBinding) + + assert result[0].id == "etb-123" + assert result[0].key == "environment" + assert result[0].value == "production" + assert "self" in result[0].links + + # Verify API call + self.mock_transport.request.assert_called_once_with( + "GET", f"/api/v2/projects/{self.project_id}/tag-bindings/effective" + ) + + def test_list_effective_tag_bindings_invalid_project_id(self): + """Test listing effective tag bindings with invalid project ID""" + import pytest + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.list_effective_tag_bindings(None) + + def test_add_tag_bindings_success(self): + """Test successful addition of tag bindings""" + # Prepare test data + tag_bindings = [ + TagBinding(key="environment", value="staging"), + TagBinding(key="team", value="backend"), + ] + options = ProjectAddTagBindingsOptions(tag_bindings=tag_bindings) + + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-new123", + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "staging"}, + }, + { + "id": "tb-new456", + "type": "tag-bindings", + "attributes": {"key": "team", "value": "backend"}, + }, + ] + } + self.mock_transport.request.return_value = mock_response + + # Call the method + result = self.projects_service.add_tag_bindings(self.project_id, options) + + # Assertions + assert len(result) == 2 + assert isinstance(result[0], TagBinding) + assert isinstance(result[1], TagBinding) + + assert result[0].id == "tb-new123" + assert result[0].key == "environment" + assert result[0].value == "staging" + + assert result[1].id == "tb-new456" + assert result[1].key == "team" + assert result[1].value == "backend" + + # Verify API call was made with correct payload + expected_payload = { + "data": [ + { + "type": "tag-bindings", + "attributes": {"key": "environment", "value": "staging"}, + }, + { + "type": "tag-bindings", + "attributes": {"key": "team", "value": "backend"}, + }, + ] + } + self.mock_transport.request.assert_called_once_with( + "PATCH", + f"/api/v2/projects/{self.project_id}/tag-bindings", + json_body=expected_payload, + ) + + def test_add_tag_bindings_with_none_value(self): + """Test adding tag bindings with None value""" + # Prepare test data with None value + tag_bindings = [TagBinding(key="flag", value=None)] + options = ProjectAddTagBindingsOptions(tag_bindings=tag_bindings) + + # Mock API response + mock_response = Mock() + mock_response.json.return_value = { + "data": [ + { + "id": "tb-flag123", + "type": "tag-bindings", + "attributes": {"key": "flag", "value": None}, + } + ] + } + self.mock_transport.request.return_value = mock_response + + # Call the method + self.projects_service.add_tag_bindings(self.project_id, options) + + # Verify payload doesn't include value for None values + expected_payload = { + "data": [ + { + "type": "tag-bindings", + "attributes": {"key": "flag"}, # No value field + } + ] + } + self.mock_transport.request.assert_called_once_with( + "PATCH", + f"/api/v2/projects/{self.project_id}/tag-bindings", + json_body=expected_payload, + ) + + def test_add_tag_bindings_invalid_project_id(self): + """Test adding tag bindings with invalid project ID""" + import pytest + + options = ProjectAddTagBindingsOptions( + tag_bindings=[TagBinding(key="test", value="value")] + ) + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.add_tag_bindings("", options) + + def test_add_tag_bindings_empty_list(self): + """Test adding tag bindings with empty tag binding list""" + import pytest + + options = ProjectAddTagBindingsOptions(tag_bindings=[]) + + with pytest.raises(ValueError, match="At least one tag binding is required"): + self.projects_service.add_tag_bindings(self.project_id, options) + + def test_delete_tag_bindings_success(self): + """Test successful deletion of tag bindings""" + # Mock successful delete (no response body expected) + self.mock_transport.request.return_value = Mock() + + # Call the method + result = self.projects_service.delete_tag_bindings(self.project_id) + + # Assertions + assert result is None # Delete should return None + + # Verify API call + self.mock_transport.request.assert_called_once_with( + "DELETE", f"/api/v2/projects/{self.project_id}/tag-bindings" + ) + + def test_delete_tag_bindings_invalid_project_id(self): + """Test deleting tag bindings with invalid project ID""" + import pytest + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.delete_tag_bindings(None) + + with pytest.raises( + ValueError, match="Project ID is required and must be valid" + ): + self.projects_service.delete_tag_bindings( + "ab" + ) # Too short (needs at least 3 chars)