diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 95b5717..6517f4a 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb - **MINOR**: New features that are backward-compatible. - **PATCH**: Bug fixes or minor changes that do not affect backward compatibility. +## [1.12.6] + +_released 12-12-2025 + +### Added + - Allow parse_junit to update custom case fields in the same test run when using --update-existing-cases + ## [1.12.5] _released 12-09-2025 diff --git a/README.md b/README.md index aff70be..91e1093 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.12.5 +TestRail CLI v1.12.6 Copyright 2025 Gurock Software GmbH - www.gurock.com Supported and loaded modules: - parse_junit: JUnit XML Files (& Similar) @@ -47,7 +47,7 @@ CLI general reference -------- ```shell $ trcli --help -TestRail CLI v1.12.5 +TestRail CLI v1.12.6 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -148,7 +148,7 @@ Options: JUnit properties (default: no). --update-strategy Strategy for combining incoming values with existing case field values, whether to append or - replace (default: append). + replace (Note: only applies to references default: append). --help Show this message and exit. ``` @@ -1096,7 +1096,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.12.5 +TestRail CLI v1.12.6 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -1220,7 +1220,7 @@ providing you with a solid base of test cases, which you can further expand on T ### Reference ```shell $ trcli parse_openapi --help -TestRail CLI v1.12.5 +TestRail CLI v1.12.6 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] diff --git a/tests/test_api_request_handler_case_fields_update.py b/tests/test_api_request_handler_case_fields_update.py new file mode 100644 index 0000000..9033fd3 --- /dev/null +++ b/tests/test_api_request_handler_case_fields_update.py @@ -0,0 +1,270 @@ +""" +Unit tests for update_existing_case_references with case fields support. +Tests the fix for the bug where custom case fields were not being updated. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from trcli.api.api_request_handler import ApiRequestHandler +from trcli.api.api_client import APIClientResult +from trcli.cli import Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +class TestUpdateExistingCaseReferencesWithFields: + """Test class for update_existing_case_references with custom fields""" + + @pytest.fixture + def handler(self): + """Create an ApiRequestHandler instance for testing""" + environment = Environment() + environment.host = "https://test.testrail.com" + environment.username = "test@example.com" + environment.password = "password" + + mock_client = MagicMock() + suite = TestRailSuite(name="Test Suite") + + handler = ApiRequestHandler(environment=environment, api_client=mock_client, suites_data=suite, verify=False) + return handler + + def test_update_case_with_refs_and_custom_fields(self, handler): + """Test updating case with both references and custom fields""" + # Mock get_case response + mock_get_case_response = APIClientResult( + status_code=200, response_text={"id": 1, "title": "Test Case 1", "refs": "REQ-1"}, error_message=None + ) + + # Mock update_case response + mock_update_response = APIClientResult( + status_code=200, + response_text={ + "id": 1, + "refs": "REQ-1,REQ-2", + "custom_preconds": "Updated precondition", + "custom_automation_type": 2, + }, + error_message=None, + ) + + case_fields = {"custom_preconds": "Updated precondition", "custom_automation_type": 2} + + with patch.object(handler.client, "send_get", return_value=mock_get_case_response), patch.object( + handler.client, "send_post", return_value=mock_update_response + ): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="REQ-2", case_fields=case_fields, strategy="append" + ) + + assert success is True + assert error is None + assert added_refs == ["REQ-2"] + assert skipped_refs == [] + assert set(updated_fields) == {"custom_preconds", "custom_automation_type"} + + # Verify the API call included both refs and custom fields + handler.client.send_post.assert_called_once() + call_args = handler.client.send_post.call_args + assert call_args[0][0] == "update_case/1" + update_data = call_args[0][1] + assert update_data["refs"] == "REQ-1,REQ-2" + assert update_data["custom_preconds"] == "Updated precondition" + assert update_data["custom_automation_type"] == 2 + + def test_update_case_with_only_custom_fields(self, handler): + """Test updating case with only custom fields (no refs)""" + mock_update_response = APIClientResult( + status_code=200, response_text={"id": 1, "custom_automation_ids": "AUTO-123"}, error_message=None + ) + + case_fields = {"custom_automation_ids": "AUTO-123", "template_id": 1} + + with patch.object(handler.client, "send_post", return_value=mock_update_response): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="", case_fields=case_fields, strategy="append" # No refs + ) + + assert success is True + assert error is None + assert added_refs == [] + assert skipped_refs == [] + assert set(updated_fields) == {"custom_automation_ids", "template_id"} + + # Verify the API call included only custom fields + handler.client.send_post.assert_called_once() + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert "refs" not in update_data # No refs in update + assert update_data["custom_automation_ids"] == "AUTO-123" + assert update_data["template_id"] == 1 + + def test_update_case_with_only_refs_no_fields(self, handler): + """Test updating case with only refs (backwards compatibility)""" + mock_get_case_response = APIClientResult( + status_code=200, response_text={"id": 1, "title": "Test Case 1", "refs": ""}, error_message=None + ) + + mock_update_response = APIClientResult( + status_code=200, response_text={"id": 1, "refs": "REQ-1"}, error_message=None + ) + + with patch.object(handler.client, "send_get", return_value=mock_get_case_response), patch.object( + handler.client, "send_post", return_value=mock_update_response + ): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="REQ-1", case_fields=None, strategy="append" # No custom fields + ) + + assert success is True + assert error is None + assert added_refs == ["REQ-1"] + assert skipped_refs == [] + assert updated_fields == [] + + # Verify the API call included only refs + handler.client.send_post.assert_called_once() + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert update_data == {"refs": "REQ-1"} + + def test_update_case_filters_internal_fields(self, handler): + """Test that internal fields are filtered out from updates""" + mock_update_response = APIClientResult(status_code=200, response_text={"id": 1}, error_message=None) + + case_fields = { + "custom_preconds": "Test", + "case_id": 999, # Should be filtered + "section_id": 888, # Should be filtered + "result": {"status": "passed"}, # Should be filtered + "custom_automation_type": 1, + } + + with patch.object(handler.client, "send_post", return_value=mock_update_response): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="", case_fields=case_fields, strategy="append" + ) + + assert success is True + # Verify internal fields were filtered out + assert set(updated_fields) == {"custom_preconds", "custom_automation_type"} + + # Verify the API call excluded internal fields + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert "case_id" not in update_data + assert "section_id" not in update_data + assert "result" not in update_data + assert update_data["custom_preconds"] == "Test" + assert update_data["custom_automation_type"] == 1 + + def test_update_case_no_changes(self, handler): + """Test when there are no refs and no custom fields to update""" + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="", case_fields=None, strategy="append" + ) + + assert success is True + assert error is None + assert added_refs == [] + assert skipped_refs == [] + assert updated_fields == [] + + # Verify no API call was made + handler.client.send_post.assert_not_called() + + def test_update_case_refs_append_with_fields(self, handler): + """Test append strategy for refs with custom fields""" + mock_get_case_response = APIClientResult( + status_code=200, response_text={"id": 1, "title": "Test Case 1", "refs": "REQ-1,REQ-2"}, error_message=None + ) + + mock_update_response = APIClientResult( + status_code=200, response_text={"id": 1, "refs": "REQ-1,REQ-2,REQ-3"}, error_message=None + ) + + case_fields = {"custom_preconds": "New precondition"} + + with patch.object(handler.client, "send_get", return_value=mock_get_case_response), patch.object( + handler.client, "send_post", return_value=mock_update_response + ): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="REQ-2,REQ-3", case_fields=case_fields, strategy="append" # REQ-2 already exists + ) + + assert success is True + assert added_refs == ["REQ-3"] + assert skipped_refs == ["REQ-2"] + assert updated_fields == ["custom_preconds"] + + # Verify refs were appended and field was added + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert update_data["refs"] == "REQ-1,REQ-2,REQ-3" + assert update_data["custom_preconds"] == "New precondition" + + def test_update_case_refs_replace_with_fields(self, handler): + """Test replace strategy for refs with custom fields""" + mock_get_case_response = APIClientResult( + status_code=200, response_text={"id": 1, "title": "Test Case 1", "refs": "REQ-1,REQ-2"}, error_message=None + ) + + mock_update_response = APIClientResult( + status_code=200, response_text={"id": 1, "refs": "REQ-3,REQ-4"}, error_message=None + ) + + case_fields = {"custom_automation_type": 2} + + with patch.object(handler.client, "send_get", return_value=mock_get_case_response), patch.object( + handler.client, "send_post", return_value=mock_update_response + ): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="REQ-3,REQ-4", case_fields=case_fields, strategy="replace" + ) + + assert success is True + assert added_refs == ["REQ-3", "REQ-4"] + assert skipped_refs == [] + assert updated_fields == ["custom_automation_type"] + + # Verify refs were replaced and field was added + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert update_data["refs"] == "REQ-3,REQ-4" + assert update_data["custom_automation_type"] == 2 + + def test_update_case_no_new_refs_but_has_fields(self, handler): + """Test when all refs are duplicates but custom fields need updating""" + mock_get_case_response = APIClientResult( + status_code=200, response_text={"id": 1, "title": "Test Case 1", "refs": "REQ-1,REQ-2"}, error_message=None + ) + + mock_update_response = APIClientResult(status_code=200, response_text={"id": 1}, error_message=None) + + case_fields = {"custom_preconds": "Updated"} + + with patch.object(handler.client, "send_get", return_value=mock_get_case_response), patch.object( + handler.client, "send_post", return_value=mock_update_response + ): + + success, error, added_refs, skipped_refs, updated_fields = handler.update_existing_case_references( + case_id=1, junit_refs="REQ-1,REQ-2", case_fields=case_fields, strategy="append" # All duplicates + ) + + assert success is True + assert added_refs == [] + assert skipped_refs == ["REQ-1", "REQ-2"] + assert updated_fields == ["custom_preconds"] + + # Verify update was still made for custom fields + handler.client.send_post.assert_called_once() + call_args = handler.client.send_post.call_args + update_data = call_args[0][1] + assert update_data["refs"] == "REQ-1,REQ-2" + assert update_data["custom_preconds"] == "Updated" diff --git a/tests/test_data/cli_test_data.py b/tests/test_data/cli_test_data.py index 756feae..44ae503 100644 --- a/tests/test_data/cli_test_data.py +++ b/tests/test_data/cli_test_data.py @@ -62,11 +62,14 @@ "key": "key_from_custom_config", } -trcli_description = ('Supported and loaded modules:\n' - ' - parse_junit: JUnit XML Files (& Similar)\n' - ' - parse_robot: Robot Framework XML Files\n' - ' - parse_openapi: OpenAPI YML Files\n' - ' - add_run: Create a new test run\n' - ' - labels: Manage labels (projects, cases, and tests)\n') +trcli_description = ( + "Supported and loaded modules:\n" + " - parse_junit: JUnit XML Files (& Similar)\n" + " - parse_robot: Robot Framework XML Files\n" + " - parse_openapi: OpenAPI YML Files\n" + " - add_run: Create a new test run\n" + " - labels: Manage labels (projects, cases, and tests)\n" + " - references: Manage references\n" +) trcli_help_description = "TestRail CLI" diff --git a/trcli/__init__.py b/trcli/__init__.py index dd8aa62..080dc67 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.12.5" +__version__ = "1.12.6" diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 77d6b3a..d1cd4e3 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -696,77 +696,109 @@ def append_run_references(self, run_id: int, references: List[str]) -> Tuple[Dic return updated_run_response.response_text, added_refs, skipped_refs, updated_run_response.error_message def update_existing_case_references( - self, case_id: int, junit_refs: str, strategy: str = "append" - ) -> Tuple[bool, str, List[str], List[str]]: + self, case_id: int, junit_refs: str, case_fields: dict = None, strategy: str = "append" + ) -> Tuple[bool, str, List[str], List[str], List[str]]: """ - Update existing case references with values from JUnit properties. + Update existing case references and custom fields with values from JUnit properties. :param case_id: ID of the test case :param junit_refs: References from JUnit testrail_case_field property - :param strategy: 'append' or 'replace' - :returns: Tuple with (success, error_message, added_refs, skipped_refs) + :param case_fields: Dictionary of custom case fields to update (e.g., {'custom_preconds': 'value'}) + :param strategy: 'append' or 'replace' (applies to refs field only) + :returns: Tuple with (success, error_message, added_refs, skipped_refs, updated_fields) """ - if not junit_refs or not junit_refs.strip(): - return True, None, [], [] # No references to process - - # Parse and validate JUnit references, deduplicating input - junit_ref_list = [] - seen = set() - for ref in junit_refs.split(","): - ref_clean = ref.strip() - if ref_clean and ref_clean not in seen: - junit_ref_list.append(ref_clean) - seen.add(ref_clean) - - if not junit_ref_list: - return False, "No valid references found in JUnit property", [], [] + updated_fields = [] - # Get current case data - case_response = self.client.send_get(f"get_case/{case_id}") - if case_response.error_message: - return False, case_response.error_message, [], [] + # Handle case where there are no refs but there are case fields to update + if (not junit_refs or not junit_refs.strip()) and not case_fields: + return True, None, [], [], [] # Nothing to process - existing_refs = case_response.response_text.get("refs", "") or "" - - if strategy == "replace": - # Replace strategy: use JUnit refs as-is - new_refs = ",".join(junit_ref_list) - added_refs = junit_ref_list + if not junit_refs or not junit_refs.strip(): + # No refs to process, but we have case fields to update + new_refs = None + added_refs = [] skipped_refs = [] else: - # Append strategy: combine with existing refs, avoiding duplicates - existing_ref_list = ( - [ref.strip() for ref in existing_refs.split(",") if ref.strip()] if existing_refs else [] - ) + # Parse and validate JUnit references, deduplicating input + junit_ref_list = [] + seen = set() + for ref in junit_refs.split(","): + ref_clean = ref.strip() + if ref_clean and ref_clean not in seen: + junit_ref_list.append(ref_clean) + seen.add(ref_clean) + + if not junit_ref_list: + # If we have case fields, continue; otherwise return error + if not case_fields: + return False, "No valid references found in JUnit property", [], [], [] + new_refs = None + added_refs = [] + skipped_refs = [] + else: + # Get current case data + case_response = self.client.send_get(f"get_case/{case_id}") + if case_response.error_message: + return False, case_response.error_message, [], [], [] + + existing_refs = case_response.response_text.get("refs", "") or "" + + if strategy == "replace": + # Replace strategy: use JUnit refs as-is + new_refs = ",".join(junit_ref_list) + added_refs = junit_ref_list + skipped_refs = [] + else: + # Append strategy: combine with existing refs, avoiding duplicates + existing_ref_list = ( + [ref.strip() for ref in existing_refs.split(",") if ref.strip()] if existing_refs else [] + ) - # Determine which references are new vs duplicates - added_refs = [ref for ref in junit_ref_list if ref not in existing_ref_list] - skipped_refs = [ref for ref in junit_ref_list if ref in existing_ref_list] + # Determine which references are new vs duplicates + added_refs = [ref for ref in junit_ref_list if ref not in existing_ref_list] + skipped_refs = [ref for ref in junit_ref_list if ref in existing_ref_list] + + # If no new references to add and no case fields, return current state + if not added_refs and not case_fields: + return True, None, added_refs, skipped_refs, [] + + # Combine references + combined_list = existing_ref_list + added_refs + new_refs = ",".join(combined_list) + + # Validate 2000 character limit for test case references + if len(new_refs) > 2000: + return ( + False, + f"Combined references length ({len(new_refs)} characters) exceeds 2000 character limit", + [], + [], + [], + ) - # If no new references to add, return current state - if not added_refs: - return True, None, added_refs, skipped_refs + # Build update data with refs and custom case fields + update_data = {} + if new_refs is not None: + update_data["refs"] = new_refs - # Combine references - combined_list = existing_ref_list + added_refs - new_refs = ",".join(combined_list) + # Add custom case fields to the update + if case_fields: + for field_name, field_value in case_fields.items(): + # Skip special internal fields that shouldn't be updated + if field_name not in ["case_id", "section_id", "result"]: + update_data[field_name] = field_value + updated_fields.append(field_name) - # Validate 2000 character limit for test case references - if len(new_refs) > 2000: - return ( - False, - f"Combined references length ({len(new_refs)} characters) exceeds 2000 character limit", - [], - [], - ) + # Only update if we have data to send + if not update_data: + return True, None, added_refs, skipped_refs, updated_fields # Update the case - update_data = {"refs": new_refs} update_response = self.client.send_post(f"update_case/{case_id}", update_data) if update_response.error_message: - return False, update_response.error_message, [], [] + return False, update_response.error_message, [], [], [] - return True, None, added_refs, skipped_refs + return True, None, added_refs, skipped_refs, updated_fields def upload_attachments(self, report_results: [Dict], results: List[Dict], run_id: int): """Getting test result id and upload attachments for it.""" diff --git a/trcli/api/results_uploader.py b/trcli/api/results_uploader.py index de99f3a..50713f9 100644 --- a/trcli/api/results_uploader.py +++ b/trcli/api/results_uploader.py @@ -18,7 +18,7 @@ def __init__(self, environment: Environment, suite: TestRailSuite, skip_run: boo super().__init__(environment, suite) self.skip_run = skip_run self.last_run_id = None - if hasattr(self.environment, 'special_parser') and self.environment.special_parser == "saucectl": + if hasattr(self.environment, "special_parser") and self.environment.special_parser == "saucectl": self.run_name += f" ({suite.name})" def upload_results(self): @@ -33,7 +33,7 @@ def upload_results(self): # Validate user emails early if --assign is specified try: - assign_value = getattr(self.environment, 'assign_failed_to', None) + assign_value = getattr(self.environment, "assign_failed_to", None) if assign_value is not None and str(assign_value).strip(): self._validate_and_store_user_ids() except (AttributeError, TypeError): @@ -56,9 +56,7 @@ def upload_results(self): added_sections = None added_test_cases = None if self.environment.auto_creation_response: - added_sections, result_code = self.add_missing_sections( - self.project.project_id - ) + added_sections, result_code = self.add_missing_sections(self.project.project_id) if result_code == -1: revert_logs = self.rollback_changes( suite_id=suite_id, suite_added=suite_added, added_sections=added_sections @@ -85,7 +83,7 @@ def upload_results(self): if added_test_cases: self.environment.log(f"Submitted {len(added_test_cases)} test cases in {stop - start:.1f} secs.") return - + # remove empty, unused sections created earlier, based on the sections actually used by the new test cases # - iterate on added_sections and remove those that are not used by the new test cases empty_sections = None @@ -93,9 +91,15 @@ def upload_results(self): if not added_test_cases: empty_sections = added_sections else: - empty_sections = [section for section in added_sections if section['section_id'] not in [case['section_id'] for case in added_test_cases]] + empty_sections = [ + section + for section in added_sections + if section["section_id"] not in [case["section_id"] for case in added_test_cases] + ] if len(empty_sections) > 0: - self.environment.log("Removing unnecessary empty sections that may have been created earlier. ", new_line=False) + self.environment.log( + "Removing unnecessary empty sections that may have been created earlier. ", new_line=False + ) _, error = self.api_request_handler.delete_sections(empty_sections) if error: self.environment.elog("\n" + error) @@ -103,15 +107,27 @@ def upload_results(self): else: self.environment.log(f"Removed {len(empty_sections)} unused/empty section(s).") - # Update existing cases with JUnit references if enabled + # Update existing cases with JUnit references and custom fields if enabled case_update_results = None case_update_failed = [] - if hasattr(self.environment, 'update_existing_cases') and self.environment.update_existing_cases == "yes": - self.environment.log("Updating existing cases with JUnit references...") + if hasattr(self.environment, "update_existing_cases") and self.environment.update_existing_cases == "yes": + self.environment.log("Updating existing cases with references and custom fields...") case_update_results, case_update_failed = self.update_existing_cases_with_junit_refs(added_test_cases) - + if case_update_results.get("updated_cases"): - self.environment.log(f"Updated {len(case_update_results['updated_cases'])} existing case(s) with references.") + updated_count = len(case_update_results["updated_cases"]) + # Count how many had refs vs fields updated + refs_updated = sum(1 for case in case_update_results["updated_cases"] if case.get("added_refs")) + fields_updated = sum(1 for case in case_update_results["updated_cases"] if case.get("updated_fields")) + + msg_parts = [] + if refs_updated: + msg_parts.append(f"{refs_updated} with references") + if fields_updated: + msg_parts.append(f"{fields_updated} with custom fields") + + detail = f" ({', '.join(msg_parts)})" if msg_parts else "" + self.environment.log(f"Updated {updated_count} existing case(s){detail}.") if case_update_results.get("failed_cases"): self.environment.elog(f"Failed to update {len(case_update_results['failed_cases'])} case(s).") @@ -154,16 +170,16 @@ def upload_results(self): stop = time.time() if results_amount: self.environment.log(f"Submitted {results_amount} test results in {stop - start:.1f} secs.") - + # Exit with error if there were invalid users (after processing valid ones) try: - has_invalid = getattr(self.environment, '_has_invalid_users', False) + has_invalid = getattr(self.environment, "_has_invalid_users", False) if has_invalid is True: # Explicitly check for True to avoid mock object issues exit(1) except (AttributeError, TypeError): # Skip exit if there are any issues with the attribute pass - + # Note: Error exit for case update failures is handled in cmd_parse_junit.py after reporting def _validate_and_store_user_ids(self): @@ -173,27 +189,27 @@ def _validate_and_store_user_ids(self): Exits only if NO valid users are found. """ try: - assign_value = getattr(self.environment, 'assign_failed_to', None) + assign_value = getattr(self.environment, "assign_failed_to", None) if assign_value is None or not str(assign_value).strip(): return except (AttributeError, TypeError): return - - # Check for empty or whitespace-only values + + # Check for empty or whitespace-only values assign_str = str(assign_value) if not assign_str.strip(): self.environment.elog("Error: --assign option requires at least one user email") exit(1) - - emails = [email.strip() for email in assign_str.split(',') if email.strip()] - + + emails = [email.strip() for email in assign_str.split(",") if email.strip()] + if not emails: self.environment.elog("Error: --assign option requires at least one user email") exit(1) - + valid_user_ids = [] invalid_users = [] - + for email in emails: user_id, error_msg = self.api_request_handler.get_user_by_email(email) if user_id is None: @@ -204,109 +220,118 @@ def _validate_and_store_user_ids(self): exit(1) else: valid_user_ids.append(user_id) - + # Handle invalid users if invalid_users: for invalid_user in invalid_users: self.environment.elog(f"Error: User not found: {invalid_user}") - + # Store valid user IDs for processing, but mark that we should exit with error later self.environment._has_invalid_users = True - + # If ALL users are invalid, exit immediately if not valid_user_ids: exit(1) - + # Store valid user IDs for later use self.environment._validated_user_ids = valid_user_ids def update_existing_cases_with_junit_refs(self, added_test_cases: List[Dict] = None) -> Tuple[Dict, List]: """ - Update existing test cases with references from JUnit properties. + Update existing test cases with references and custom fields from JUnit properties. Excludes newly created cases to avoid unnecessary API calls. - + :param added_test_cases: List of cases that were just created (to be excluded) :returns: Tuple of (update_results, failed_cases) """ - if not hasattr(self.environment, 'update_existing_cases') or self.environment.update_existing_cases != "yes": + if not hasattr(self.environment, "update_existing_cases") or self.environment.update_existing_cases != "yes": return {}, [] # Feature not enabled - + # Create a set of newly created case IDs to exclude newly_created_case_ids = set() if added_test_cases: # Ensure all case IDs are integers for consistent comparison - newly_created_case_ids = {int(case.get('case_id')) for case in added_test_cases if case.get('case_id')} - - update_results = { - "updated_cases": [], - "skipped_cases": [], - "failed_cases": [] - } + newly_created_case_ids = {int(case.get("case_id")) for case in added_test_cases if case.get("case_id")} + + update_results = {"updated_cases": [], "skipped_cases": [], "failed_cases": []} failed_cases = [] - - strategy = getattr(self.environment, 'update_strategy', 'append') - + + strategy = getattr(self.environment, "update_strategy", "append") + # Process all test cases in all sections for section in self.api_request_handler.suites_data_from_provider.testsections: for test_case in section.testcases: - # Only process cases that have a case_id (existing cases) and JUnit refs + # Get refs and case fields for this test case + junit_refs = getattr(test_case, "_junit_case_refs", None) + case_fields = getattr(test_case, "case_fields", {}) + + # Only process cases that have a case_id (existing cases) and either JUnit refs or case fields # AND exclude newly created cases - if (test_case.case_id and - hasattr(test_case, '_junit_case_refs') and test_case._junit_case_refs and - int(test_case.case_id) not in newly_created_case_ids): + if ( + test_case.case_id + and (junit_refs or case_fields) + and int(test_case.case_id) not in newly_created_case_ids + ): try: - success, error_msg, added_refs, skipped_refs = self.api_request_handler.update_existing_case_references( - test_case.case_id, test_case._junit_case_refs, strategy + success, error_msg, added_refs, skipped_refs, updated_fields = ( + self.api_request_handler.update_existing_case_references( + test_case.case_id, junit_refs or "", case_fields, strategy + ) ) - + if success: - if added_refs: - # Only count as "updated" if references were actually added - update_results["updated_cases"].append({ - "case_id": test_case.case_id, - "case_title": test_case.title, - "added_refs": added_refs, - "skipped_refs": skipped_refs - }) + if added_refs or updated_fields: + # Count as "updated" if references were added or fields were updated + update_results["updated_cases"].append( + { + "case_id": test_case.case_id, + "case_title": test_case.title, + "added_refs": added_refs, + "skipped_refs": skipped_refs, + "updated_fields": updated_fields, + } + ) else: - # If no refs were added (all were duplicates or no valid refs), count as skipped - reason = "All references already present" if skipped_refs else "No valid references to process" - update_results["skipped_cases"].append({ - "case_id": test_case.case_id, - "case_title": test_case.title, - "reason": reason, - "skipped_refs": skipped_refs - }) + # If nothing was updated (all refs were duplicates and no fields), count as skipped + reason = "All references already present" if skipped_refs else "No changes to apply" + update_results["skipped_cases"].append( + { + "case_id": test_case.case_id, + "case_title": test_case.title, + "reason": reason, + "skipped_refs": skipped_refs, + } + ) else: error_info = { "case_id": test_case.case_id, "case_title": test_case.title, - "error": error_msg + "error": error_msg, } update_results["failed_cases"].append(error_info) failed_cases.append(error_info) self.environment.elog(f"Failed to update case C{test_case.case_id}: {error_msg}") - + except Exception as e: - error_info = { - "case_id": test_case.case_id, - "case_title": test_case.title, - "error": str(e) - } + error_info = {"case_id": test_case.case_id, "case_title": test_case.title, "error": str(e)} update_results["failed_cases"].append(error_info) failed_cases.append(error_info) self.environment.elog(f"Exception updating case C{test_case.case_id}: {str(e)}") - - elif (test_case.case_id and - hasattr(test_case, '_junit_case_refs') and test_case._junit_case_refs and - int(test_case.case_id) in newly_created_case_ids): - # Skip newly created cases - they already have their references set - update_results["skipped_cases"].append({ - "case_id": test_case.case_id, - "case_title": test_case.title, - "reason": "Newly created case - references already set during creation" - }) - + + elif ( + test_case.case_id + and (junit_refs or case_fields) + and int(test_case.case_id) in newly_created_case_ids + ): + # Skip newly created cases - they already have their fields set during creation + update_results["skipped_cases"].append( + { + "case_id": test_case.case_id, + "case_title": test_case.title, + "reason": "Newly created case - fields already set during creation", + } + ) + return update_results, failed_cases def add_missing_sections(self, project_id: int) -> Tuple[List, int]: @@ -328,9 +353,7 @@ def add_missing_sections(self, project_id: int) -> Tuple[List, int]: f"This will result to failure to upload all cases." ) return added_sections, result_code - prompt_message = PROMPT_MESSAGES["create_missing_sections"].format( - project_name=self.environment.project - ) + prompt_message = PROMPT_MESSAGES["create_missing_sections"].format(project_name=self.environment.project) adding_message = "Adding missing sections to the suite." fault_message = FAULT_MAPPING["no_user_agreement"].format(type="sections") added_sections, result_code = self.prompt_user_and_add_items( @@ -357,9 +380,7 @@ def add_missing_test_cases(self) -> Tuple[list, int]: do so. Returns list of added test case IDs if succeeds or empty list with result_code set to -1. """ - prompt_message = PROMPT_MESSAGES["create_missing_test_cases"].format( - project_name=self.environment.project - ) + prompt_message = PROMPT_MESSAGES["create_missing_test_cases"].format(project_name=self.environment.project) adding_message = "Adding missing test cases to the suite." fault_message = FAULT_MAPPING["no_user_agreement"].format(type="test cases") added_cases, result_code = self.prompt_user_and_add_items( @@ -392,29 +413,21 @@ def rollback_changes( else: returned_log.append(RevertMessages.run_deleted) if len(added_test_cases) > 0: - _, error = self.api_request_handler.delete_cases( - suite_id, added_test_cases - ) + _, error = self.api_request_handler.delete_cases(suite_id, added_test_cases) if error: - returned_log.append( - RevertMessages.test_cases_not_deleted.format(error=error) - ) + returned_log.append(RevertMessages.test_cases_not_deleted.format(error=error)) else: returned_log.append(RevertMessages.test_cases_deleted) if len(added_sections) > 0: _, error = self.api_request_handler.delete_sections(added_sections) if error: - returned_log.append( - RevertMessages.section_not_deleted.format(error=error) - ) + returned_log.append(RevertMessages.section_not_deleted.format(error=error)) else: returned_log.append(RevertMessages.section_deleted) if self.project.suite_mode != SuiteModes.single_suite and suite_added > 0: _, error = self.api_request_handler.delete_suite(suite_id) if error: - returned_log.append( - RevertMessages.suite_not_deleted.format(error=error) - ) + returned_log.append(RevertMessages.suite_not_deleted.format(error=error)) else: returned_log.append(RevertMessages.suite_deleted) return returned_log diff --git a/trcli/commands/cmd_parse_junit.py b/trcli/commands/cmd_parse_junit.py index e24fcea..cf09ecf 100644 --- a/trcli/commands/cmd_parse_junit.py +++ b/trcli/commands/cmd_parse_junit.py @@ -19,37 +19,34 @@ metavar="", default="junit", type=click.Choice(["junit", "saucectl"], case_sensitive=False), - help="Optional special parser option for specialized JUnit reports." + help="Optional special parser option for specialized JUnit reports.", ) @click.option( - "-a", "--assign", + "-a", + "--assign", "assign_failed_to", metavar="", - help="Comma-separated list of user emails to assign failed test results to." + help="Comma-separated list of user emails to assign failed test results to.", ) @click.option( "--test-run-ref", metavar="", - help="Comma-separated list of reference IDs to append to the test run (up to 250 characters total)." -) -@click.option( - "--json-output", - is_flag=True, - help="Output reference operation results in JSON format." + help="Comma-separated list of reference IDs to append to the test run (up to 250 characters total).", ) +@click.option("--json-output", is_flag=True, help="Output reference operation results in JSON format.") @click.option( "--update-existing-cases", type=click.Choice(["yes", "no"], case_sensitive=False), default="no", metavar="", - help="Update existing TestRail cases with values from JUnit properties (default: no)." + help="Update existing TestRail cases with values from JUnit properties (default: no).", ) @click.option( "--update-strategy", type=click.Choice(["append", "replace"], case_sensitive=False), default="append", metavar="", - help="Strategy for combining incoming values with existing case field values, whether to append or replace (default: append)." + help="Strategy for combining incoming values with existing case field values, whether to append or replace (Note: only applies to references default: append).", ) @click.pass_context @pass_environment @@ -58,13 +55,13 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): environment.cmd = "parse_junit" environment.set_parameters(context) environment.check_for_required_parameters() - + if environment.test_run_ref is not None: validation_error = _validate_test_run_ref(environment.test_run_ref) if validation_error: environment.elog(validation_error) exit(1) - + settings.ALLOW_ELAPSED_MS = environment.allow_ms print_config(environment) try: @@ -75,20 +72,20 @@ def cli(environment: Environment, context: click.Context, *args, **kwargs): result_uploader = ResultsUploader(environment=environment, suite=suite) result_uploader.upload_results() - if run_id is None and hasattr(result_uploader, 'last_run_id'): + if run_id is None and hasattr(result_uploader, "last_run_id"): run_id = result_uploader.last_run_id - + # Collect case update results - if hasattr(result_uploader, 'case_update_results'): + if hasattr(result_uploader, "case_update_results"): case_update_results = result_uploader.case_update_results - + if environment.test_run_ref and run_id: _handle_test_run_references(environment, run_id) - + # Handle case update reporting if enabled if environment.update_existing_cases == "yes" and case_update_results is not None: _handle_case_update_reporting(environment, case_update_results) - + # Exit with error if there were case update failures (after reporting) if case_update_results.get("failed_cases"): exit(1) @@ -116,14 +113,14 @@ def _validate_test_run_ref(test_run_ref: str) -> str: """ if not test_run_ref or not test_run_ref.strip(): return "Error: --test-run-ref cannot be empty or whitespace-only" - - refs = [ref.strip() for ref in test_run_ref.split(',') if ref.strip()] + + refs = [ref.strip() for ref in test_run_ref.split(",") if ref.strip()] if not refs: return "Error: --test-run-ref contains no valid references (malformed input)" - + if len(test_run_ref) > 250: return f"Error: --test-run-ref exceeds 250 character limit ({len(test_run_ref)} characters)" - + return None @@ -135,40 +132,34 @@ def _handle_test_run_references(environment: Environment, run_id: int): from trcli.data_classes.dataclass_testrail import TestRailSuite import json - refs = [ref.strip() for ref in environment.test_run_ref.split(',') if ref.strip()] - - project_client = ProjectBasedClient( - environment=environment, - suite=TestRailSuite(name="temp", suite_id=1) - ) + refs = [ref.strip() for ref in environment.test_run_ref.split(",") if ref.strip()] + + project_client = ProjectBasedClient(environment=environment, suite=TestRailSuite(name="temp", suite_id=1)) project_client.resolve_project() - + environment.log(f"Appending references to test run {run_id}...") run_data, added_refs, skipped_refs, error_message = project_client.api_request_handler.append_run_references( run_id, refs ) - + if error_message: environment.elog(f"Error: Failed to append references: {error_message}") exit(1) - + final_refs = run_data.get("refs", "") if run_data else "" - + if environment.json_output: # JSON output - result = { - "run_id": run_id, - "added": added_refs, - "skipped": skipped_refs, - "total_references": final_refs - } + result = {"run_id": run_id, "added": added_refs, "skipped": skipped_refs, "total_references": final_refs} print(json.dumps(result, indent=2)) else: environment.log(f"References appended successfully:") environment.log(f" Run ID: {run_id}") environment.log(f" Total references: {len(final_refs.split(',')) if final_refs else 0}") environment.log(f" Newly added: {len(added_refs)} ({', '.join(added_refs) if added_refs else 'none'})") - environment.log(f" Skipped (duplicates): {len(skipped_refs)} ({', '.join(skipped_refs) if skipped_refs else 'none'})") + environment.log( + f" Skipped (duplicates): {len(skipped_refs)} ({', '.join(skipped_refs) if skipped_refs else 'none'})" + ) if final_refs: environment.log(f" All references: {final_refs}") @@ -178,24 +169,24 @@ def _handle_case_update_reporting(environment: Environment, case_update_results: Handle reporting of case update results. """ import json - + # Handle None input gracefully if case_update_results is None: return - + if environment.json_output: # JSON output for case updates result = { "summary": { "updated_cases": len(case_update_results.get("updated_cases", [])), "skipped_cases": len(case_update_results.get("skipped_cases", [])), - "failed_cases": len(case_update_results.get("failed_cases", [])) + "failed_cases": len(case_update_results.get("failed_cases", [])), }, "details": { "updated_cases": case_update_results.get("updated_cases", []), "skipped_cases": case_update_results.get("skipped_cases", []), - "failed_cases": case_update_results.get("failed_cases", []) - } + "failed_cases": case_update_results.get("failed_cases", []), + }, } print(json.dumps(result, indent=2)) else: @@ -203,13 +194,13 @@ def _handle_case_update_reporting(environment: Environment, case_update_results: updated_cases = case_update_results.get("updated_cases", []) skipped_cases = case_update_results.get("skipped_cases", []) failed_cases = case_update_results.get("failed_cases", []) - + if updated_cases or skipped_cases or failed_cases: environment.log("Case Reference Updates Summary:") environment.log(f" Updated cases: {len(updated_cases)}") environment.log(f" Skipped cases: {len(skipped_cases)}") environment.log(f" Failed cases: {len(failed_cases)}") - + if updated_cases: environment.log(" Updated case details:") for case_info in updated_cases: @@ -217,14 +208,14 @@ def _handle_case_update_reporting(environment: Environment, case_update_results: added = case_info.get("added_refs", []) skipped = case_info.get("skipped_refs", []) environment.log(f" C{case_id}: added {len(added)} refs, skipped {len(skipped)} duplicates") - + if skipped_cases: environment.log(" Skipped case details:") for case_info in skipped_cases: case_id = case_info["case_id"] reason = case_info.get("reason", "Unknown reason") environment.log(f" C{case_id}: {reason}") - + if failed_cases: environment.log(" Failed case details:") for case_info in failed_cases: diff --git a/trcli/constants.py b/trcli/constants.py index 6106be7..a44c949 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -101,7 +101,8 @@ - parse_robot: Robot Framework XML Files - parse_openapi: OpenAPI YML Files - add_run: Create a new test run - - labels: Manage labels (projects, cases, and tests)""" + - labels: Manage labels (projects, cases, and tests) + - references: Manage references""" MISSING_COMMAND_SLOGAN = """Usage: trcli [OPTIONS] COMMAND [ARGS]...\nTry 'trcli --help' for help. \nError: Missing command."""