diff --git a/README.md b/README.md index 485f2b1..78bd10c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The TestRail CLI currently supports: - **Auto-generating test cases from OpenAPI specifications** - **Creating new test runs for results to be uploaded to** - **Managing project labels for better organization and categorization** +- **Retrieving and listing test cases with advanced filtering** To see further documentation about the TestRail CLI, please refer to the [TestRail CLI documentation pages](https://support.gurock.com/hc/en-us/articles/7146548750868-TestRail-CLI) @@ -46,6 +47,7 @@ Supported and loaded modules: - labels: Manage labels (add, update, delete, list) - results: Manage test results (list, update) - references: Manage references (cases and runs) + - cases: Manage test cases (get, list with filters) ``` CLI general reference @@ -90,6 +92,7 @@ Options: Commands: add_run Add a new test run in TestRail + cases Manage test cases in TestRail export_gherkin Export BDD test case from TestRail as .feature file import_gherkin Upload Gherkin .feature file to TestRail labels Manage labels in TestRail @@ -1428,6 +1431,600 @@ trcli results list --help trcli results update --help ``` +#### Managing Test Cases + +The TestRail CLI provides the `cases` command for retrieving and listing test cases from TestRail. This command is useful for discovering test cases, exporting case data, and integrating with external tools or CI/CD pipelines. + +##### Cases Command Overview + +The `cases` command supports two subcommands: + +| Subcommand | Purpose | Use Case | +|------------|---------|----------| +| `cases get` | Retrieve a single test case by ID | Get detailed information about a specific test case | +| `cases list` | List test cases with filters | Discover cases, export case data, filter by suite/priority | + +##### Reference + +```shell +$ trcli cases --help +Usage: trcli cases [OPTIONS] COMMAND [ARGS]... + + Manage test cases in TestRail + +Options: + --help Show this message and exit. + +Commands: + get Get a single test case by ID + list List test cases from TestRail +``` + +##### Retrieving a Single Test Case + +Get detailed information about a specific test case by its ID: + +```shell +# Get a test case (using config file) +$ trcli cases get -c config.yml --case-id 123 + +# Get a test case with all parameters +$ trcli cases get \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" \ + --case-id 123 + +# Get case with all fields displayed +$ trcli cases get -c config.yml --case-id 123 --show-all-fields + +# Get case as JSON (for piping to jq or other tools) +$ trcli cases get -c config.yml --case-id 123 --json-output +``` + +**Output example:** +``` +Cases Get Execution Parameters +> TestRail instance: https://yourinstance.testrail.io (user: user@example.com) +> Project: Your Project + +Retrieving case ID 123... + +Case ID: 123 + Title: Login functionality test + Section ID: 1 + Suite ID: 2 + Template ID: 1 + Type ID: 1 + Priority ID: 2 + References: JIRA-123, JIRA-456 + Created By: 1 + Created On: 1646317844 + Updated By: 1 + Updated On: 1646317844 + Labels: automated, regression + Custom Fields: 3 field(s) +``` + +**JSON output example:** +```json +{ + "id": 123, + "title": "Login functionality test", + "section_id": 1, + "suite_id": 2, + "template_id": 1, + "type_id": 1, + "priority_id": 2, + "refs": "JIRA-123, JIRA-456", + "created_by": 1, + "created_on": 1646317844, + "updated_by": 1, + "updated_on": 1646317844, + "labels": [ + { + "id": 1, + "title": "automated" + }, + { + "id": 2, + "title": "regression" + } + ], + "custom_steps": "Step 1\nStep 2\nStep 3" +} +``` + +##### Listing Test Cases with Filters + +List test cases from a project with optional filtering by suite, priority, or text search: + +```shell +# List all cases in a project (using config file) +$ trcli cases list -c config.yml + +# List cases with suite filter +$ trcli cases list -c config.yml --suite-id 2 + +# List cases with priority filter (high priority only) +$ trcli cases list -c config.yml --priority-id 4 + +# List cases with multiple priorities +$ trcli cases list -c config.yml --priority-id "3,4" + +# List cases with text search +$ trcli cases list -c config.yml --filter "login" + +# Combine multiple filters +$ trcli cases list -c config.yml --suite-id 2 --priority-id 4 --filter "authentication" + +# Pagination support +$ trcli cases list -c config.yml --offset 250 --limit 100 + +# JSON output for integration with other tools +$ trcli cases list -c config.yml --suite-id 2 --json-output | jq '.cases[].title' +``` + +**Output example:** +``` +Cases List Execution Parameters +> TestRail instance: https://yourinstance.testrail.io (user: user@example.com) +> Project: Your Project + +Retrieving cases for project ID 1 (suite_id=2)... +Found 3 case(s) (showing 1-3): + + Case ID: 123 + Title: Login functionality test + Section ID: 1 + Suite ID: 2 + Priority ID: 2 + Type ID: 1 + Labels: automated, regression + Custom Fields: 3 field(s) + + Case ID: 124 + Title: Password validation test + Section ID: 1 + Suite ID: 2 + Priority ID: 3 + Type ID: 1 + Labels: automated + Custom Fields: 2 field(s) + + Case ID: 125 + Title: User registration test + Section ID: 1 + Suite ID: 2 + Priority ID: 2 + Type ID: 1 + References: JIRA-789 + Custom Fields: 3 field(s) +``` + +##### Configuration File Support + +The `cases` command supports configuration files, allowing you to specify connection details and project information once: + +**config.yml:** +```yaml +host: https://yourinstance.testrail.io +username: user@example.com +password: your_password +project: Your Project # Project name (required) +``` + +Then use with the `-c` flag: +```shell +$ trcli cases list -c config.yml +$ trcli cases get -c config.yml --case-id 123 +``` + +##### Command Options Reference + +**Get Command:** +```shell +$ trcli cases get --help +Options: + --case-id Case ID to retrieve. [x>=1; required] + --json-output Output case as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +**List Command:** +```shell +$ trcli cases list --help +Options: + --suite-id Filter by suite ID. [x>=1] + --priority-id Filter by priority ID (comma-separated for multiple, e.g., '3,4'). + --filter Filter by text search (case title). + --offset Offset for pagination (default: 0). + --limit Limit for pagination (default: 250). + --json-output Output cases as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +##### Use Cases + +**1. Export case data for documentation:** +```shell +$ trcli cases list -c config.yml --json-output > cases_export.json +``` + +**2. Find all high-priority cases:** +```shell +$ trcli cases list -c config.yml --priority-id 4 +``` + +**3. Integration with CI/CD pipelines:** +```shell +# Get test cases and pipe to analysis tool +$ trcli cases list -c config.yml --suite-id 2 --json-output | jq -r '.cases[] | select(.labels[].title == "automated") | .title' +``` + +**4. Discover cases by keyword:** +```shell +$ trcli cases list -c config.yml --filter "API" +``` + +**5. Verify case details before test execution:** +```shell +$ trcli cases get -c config.yml --case-id 123 --show-all-fields +``` + +#### Managing Test Suites + +The TestRail CLI provides the `suites` command for retrieving and listing test suites from TestRail. This command is useful for discovering suites in multi-suite projects, exporting suite data, and integrating with external tools or CI/CD pipelines. + +**Note on Suite Modes:** +- **Single-suite projects** : Have one suite per project. +- **Multi-suite projects or Single Suite with Baseline Support** : Can contain multiple test suites with different IDs. +- The `suites` command works transparently for both modes + +##### Suites Command Overview + +The `suites` command supports two subcommands: + +| Subcommand | Purpose | Use Case | +|------------|---------|----------| +| `suites get` | Retrieve a single test suite by ID | Get detailed information about a specific suite | +| `suites list` | List test suites in a project | Discover suites, export suite data, pagination support | + +##### Reference + +```shell +$ trcli suites --help +Usage: trcli suites [OPTIONS] COMMAND [ARGS]... + + Manage test suites in TestRail + +Options: + --help Show this message and exit. + +Commands: + get Get a single test suite by ID + list List test suites from TestRail +``` + +##### Retrieving a Single Test Suite + +Get detailed information about a specific test suite by its ID : + +**Note:** This gets the suite's information regardless of the project selected + +```shell +# Get suite with all fields displayed +$ trcli suites get -c config.yml --suite-id 1 --show-all-fields + +# Get suite as JSON (for piping to jq or other tools) +$ trcli suites get -c config.yml --suite-id 1 --json-output +``` + +##### Listing Test Suites + +List test suites from a project with pagination support: + +```shell +# List all suites in a project (using config file) +$ trcli suites list -c config.yml + +# List all suites with all parameters +$ trcli suites list \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" + +# Pagination support +$ trcli suites list -c config.yml --offset 250 --limit 100 + +# JSON output for integration with other tools +$ trcli suites list -c config.yml --json-output | jq '.suites[].name' + +# Show all fields for each suite +$ trcli suites list -c config.yml --show-all-fields +``` + +##### Configuration File Support + +The `suites` command supports configuration files, allowing you to specify connection details and project information once: + +**config.yml:** +```yaml +host: https://yourinstance.testrail.io +username: user@example.com +password: your_password +project: Your Project # Project name (required) +``` + +Then use with the `-c` flag: +```shell +$ trcli suites list -c config.yml +$ trcli suites get -c config.yml --suite-id 1 +``` + +##### Command Options Reference + +**Get Command:** +```shell +$ trcli suites get --help +Options: + --suite-id Suite ID to retrieve. [x>=1; required] + --json-output Output suite as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +**List Command:** +```shell +$ trcli suites list --help +Options: + --offset Offset for pagination (default: 0). + --limit Limit for pagination (default: 250). + --json-output Output suites as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +##### Use Cases + +**1. Export suite data for documentation:** +```shell +$ trcli suites list -c config.yml --json-output > suites_export.json +``` + +**2. Discover suites in a multi-suite project:** +```shell +$ trcli suites list -c config.yml +``` + +**3. Integration with CI/CD pipelines:** +```shell +# Get all suite names and process them +$ trcli suites list -c config.yml --json-output | jq -r '.suites[] | .name' +``` + +**4. Verify suite details before test execution:** +```shell +$ trcli suites get -c config.yml --suite-id 1 --show-all-fields +``` + +**5. Identify suite IDs for multi-suite workflows:** +```shell +# Find suite ID by name +$ trcli suites list -c config.yml --json-output | jq '.suites[] | select(.name=="API Tests") | .id' +``` + +#### Managing Test Plans + +The TestRail CLI provides the `plans` command for retrieving and listing test plans from TestRail. Test plans are containers for organizing multiple test runs, often used for release testing across different configurations or environments. This command is useful for monitoring test execution progress, exporting plan data, and integrating with CI/CD pipelines. + +##### Plans Command Overview + +The `plans` command supports two subcommands: + +| Subcommand | Purpose | Use Case | +|------------|---------|----------| +| `plans get` | Retrieve a single test plan by ID | Get detailed information about a plan including all entries and runs | +| `plans list` | List test plans in a project | Discover plans, monitor progress, export plan data with pagination | + +##### Reference + +```shell +$ trcli plans --help +Usage: trcli plans [OPTIONS] COMMAND [ARGS]... + + Manage test plans in TestRail + +Options: + --help Show this message and exit. + +Commands: + get Get a single test plan by ID + list List test plans from TestRail +``` + +##### Retrieving a Single Test Plan + +Get detailed information about a specific test plan by its ID, including all entries and runs: + +```shell +# Get a test plan (using config file) +$ trcli plans get -c config.yml --plan-id 10 + +# Get a test plan with all parameters +$ trcli plans get \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" \ + --plan-id 10 + +# Get plan with all fields displayed +$ trcli plans get -c config.yml --plan-id 10 --show-all-fields + +# Get plan as JSON (for piping to jq or other tools) +$ trcli plans get -c config.yml --plan-id 10 --json-output +``` + +##### Listing Test Plans + +List test plans from a project with pagination support: + +```shell +# List all plans in a project (using config file) +$ trcli plans list -c config.yml + +# List all plans with all parameters +$ trcli plans list \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" + +# Pagination support +$ trcli plans list -c config.yml --offset 250 --limit 100 + +# JSON output for integration with other tools +$ trcli plans list -c config.yml --json-output | jq '.plans[].name' + +# Show all fields for each plan +$ trcli plans list -c config.yml --show-all-fields +``` + +##### Configuration File Support + +The `plans` command supports configuration files, allowing you to specify connection details and project information once: + +**config.yml:** +```yaml +host: https://yourinstance.testrail.io +username: user@example.com +password: your_password +project: Your Project # Project name (required) +``` + +Then use with the `-c` flag: +```shell +$ trcli plans list -c config.yml +$ trcli plans get -c config.yml --plan-id 10 +``` + +##### Command Options Reference + +**Get Command:** +```shell +$ trcli plans get --help +Options: + --plan-id Plan ID to retrieve. [x>=1; required] + --json-output Output plan as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +**List Command:** +```shell +$ trcli plans list --help +Options: + --offset Offset for pagination (default: 0). + --limit Limit for pagination (default: 250). + --json-output Output plans as raw JSON from API. + --show-all-fields Show all fields including custom fields in detail. + --help Show this message and exit. +``` + +##### Use Cases + +**1. Monitor release testing progress:** +```shell +$ trcli plans get -c config.yml --plan-id 10 +``` + +**2. Export plan data for reporting:** +```shell +$ trcli plans list -c config.yml --json-output > plans_report.json +``` + +### Managing Sections + +The TestRail CLI provides the `sections` command for retrieving and listing sections from TestRail. Sections are used to organize test cases within a project and create a hierarchical structure for managing test repositories. This command is useful for exploring project organization, auditing test structures, exporting section data, and integrating section information into automation workflows. + +### Sections Command Overview + +The `sections` command supports two subcommands: + +| Subcommand | Purpose | +|------------|---------| +| `sections get` | Retrieve a single section by ID | Get detailed information about a section including its hierarchy and metadata | +| `sections list` | List sections in a suite | Discover project structure, export section data, and navigate test repositories | + +### Reference + +```shell + +$ trcli sections --help + +Usage: trcli sections [OPTIONS] COMMAND [ARGS]... + Manage sections in TestRail + +Options: + --help Show this message and exit. + +Commands: + get Get a single section by ID + list List sections from TestRail +``` + +##### Retreiving a Single Section + +Get detailed information about a specific section by its ID: + +```shell +# Get a section (using config file) +$ trcli sections get -c config.yml --section-id 10 + +# Get a section with all parameters +$ trcli sections get \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" \ + --section-id 10 + +# Get section with all fields displayed +$ trcli sections get -c config.yml --section-id 10 --show-all-fields + +# Get section as JSON (for piping to jq or other tools) +$ trcli sections get -c config.yml --section-id 10 --json-output +``` +##### Listing Sections + +List sections from a project with pagination support: + +```shell +# List all sections in a project (using config file) +$ trcli sections list -c config.yml + +# List all sections with all parameters +$ trcli sections list \ + --host https://yourinstance.testrail.io \ + --username \ + --password \ + --project "Your Project" + +# Pagination support +$ trcli sections list -c config.yml --offset 250 --limit 100 + +# JSON output for integration with other tools +$ trcli sections list -c config.yml --json-output | jq '.sections[].name' + +# Show all fields for each section +$ trcli sections list -c config.yml --show-all-fields +``` + #### Labels Management The TestRail CLI provides comprehensive label management capabilities using the `labels` command. Labels help categorize and organize your test management assets efficiently, making it easier to filter and manage test cases, runs, and projects. diff --git a/tests/test_cmd_cases.py b/tests/test_cmd_cases.py new file mode 100644 index 0000000..636a19c --- /dev/null +++ b/tests/test_cmd_cases.py @@ -0,0 +1,431 @@ +import pytest +from unittest import mock +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from trcli.cli import Environment +from trcli.commands import cmd_cases + + +class TestCmdCases: + """Test class for cases command functionality""" + + def setup_method(self): + """Set up test environment""" + self.runner = CliRunner() + self.environment = Environment(cmd="cases") + self.environment.host = "https://test.testrail.com" + self.environment.username = "test@example.com" + self.environment.password = "password" + self.environment.project = "Test Project" + self.environment.project_id = 1 + + def _setup_project_client_mock(self, mock_project_client, project_id=1): + """Helper to setup ProjectBasedClient mock""" + mock_client_instance = MagicMock() + mock_project_client.return_value = mock_client_instance + mock_client_instance.project.project_id = project_id + return mock_client_instance + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_success(self, mock_project_client): + """Test successful case retrieval""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_case.return_value = ( + { + "id": 123, + "title": "Test Case Title", + "section_id": 1, + "suite_id": 2, + "template_id": 1, + "type_id": 1, + "priority_id": 2, + "refs": "JIRA-123", + "created_by": 1, + "created_on": 1234567890, + "updated_by": 1, + "updated_on": 1234567890, + "labels": [{"id": 1, "title": "label1"}], + "custom_steps": "Step 1\nStep 2", + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.get, ["--case-id", "123"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_case.assert_called_once_with(123) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_json_output(self, mock_project_client): + """Test case retrieval with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + case_data = {"id": 123, "title": "Test Case", "section_id": 1} + mock_client.api_request_handler.case_handler.get_case.return_value = (case_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_cases.get, ["--case-id", "123", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"id": 123' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_show_all_fields(self, mock_project_client): + """Test case retrieval with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_case.return_value = ( + { + "id": 123, + "title": "Test Case", + "custom_field1": "value1", + "custom_field2": "value2", + "labels": [{"id": 1, "title": "label1"}, {"id": 2, "title": "label2"}], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_cases.get, + ["--case-id", "123", "--show-all-fields"], + obj=self.environment, + ) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_api_error(self, mock_project_client): + """Test case retrieval with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_case.return_value = ({}, "Case not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.get, ["--case-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve case: Case not found") + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_success(self, mock_project_client): + """Test successful cases listing""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "cases": [ + { + "id": 1, + "title": "Case 1", + "section_id": 1, + "suite_id": 1, + "priority_id": 2, + "type_id": 1, + "labels": [], + }, + { + "id": 2, + "title": "Case 2", + "section_id": 1, + "suite_id": 1, + "priority_id": 3, + "type_id": 1, + "labels": [{"id": 1, "title": "automated"}], + }, + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_cases.assert_called_once_with( + project_id=1, suite_id=None, priority_id=None, filter_text=None, limit=250, offset=0 + ) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_filters(self, mock_project_client): + """Test cases listing with filters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "_links": {}, "cases": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_cases.list, + ["--suite-id", "2", "--priority-id", "3,4", "--filter", "login"], + obj=self.environment, + ) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_cases.assert_called_once_with( + project_id=1, suite_id=2, priority_id="3,4", filter_text="login", limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_pagination(self, mock_project_client): + """Test cases listing with pagination parameters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + { + "offset": 100, + "limit": 50, + "size": 50, + "_links": {"next": "/api/v2/get_cases/1&offset=150", "prev": "/api/v2/get_cases/1&offset=50"}, + "cases": [{"id": i, "title": f"Case {i}"} for i in range(100, 150)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, ["--offset", "100", "--limit", "50"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_cases.assert_called_once_with( + project_id=1, suite_id=None, priority_id=None, filter_text=None, limit=50, offset=100 + ) + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_json_output(self, mock_project_client): + """Test cases listing with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + response_data = {"offset": 0, "limit": 250, "size": 1, "cases": [{"id": 1, "title": "Test"}]} + mock_client.api_request_handler.case_handler.get_cases.return_value = (response_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_cases.list, ["--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"offset": 0' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_show_all_fields(self, mock_project_client): + """Test cases listing with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "cases": [ + { + "id": 1, + "title": "Test Case", + "custom_field1": "value1", + "custom_field2": "value2", + "labels": [{"id": 1, "title": "label1"}], + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, ["--show-all-fields"], obj=self.environment) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_empty_result(self, mock_project_client): + """Test cases listing with empty result""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "cases": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_log.assert_any_call("No cases found.") + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_api_error(self, mock_project_client): + """Test cases listing with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ({}, "Project not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve cases: Project not found") + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_next_link(self, mock_project_client): + """Test cases listing shows pagination hint when next link is present""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 250, + "_links": {"next": "/api/v2/get_cases/1&offset=250", "prev": None}, + "cases": [{"id": i, "title": f"Case {i}"} for i in range(1, 251)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + log_calls = [str(call) for call in mock_log.call_args_list] + pagination_hint_found = any("More results available" in str(call) for call in log_calls) + assert pagination_hint_found + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_labels_display(self, mock_project_client): + """Test cases listing displays labels correctly""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "cases": [ + { + "id": 1, + "title": "Test Case", + "section_id": 1, + "priority_id": 2, + "type_id": 1, + "labels": [{"id": 1, "title": "automated"}, {"id": 2, "title": "regression"}], + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + log_calls_str = " ".join([str(call) for call in mock_log.call_args_list]) + assert "automated" in log_calls_str or "Labels" in log_calls_str + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_with_project_id_from_config(self, mock_project_client): + """Test case retrieval uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + case_data = {"id": 123, "title": "Test Case", "section_id": 1} + mock_client.api_request_handler.case_handler.get_case.return_value = (case_data, "") + + self.environment.project_id = 42 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.get, ["--case-id", "123"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_case.assert_called_once_with(123) + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_project_id_from_config(self, mock_project_client): + """Test cases listing uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "cases": [{"id": 1, "title": "Test"}]}, + "", + ) + + self.environment.project_id = 99 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.case_handler.get_cases.assert_called_once_with( + project_id=99, suite_id=None, priority_id=None, filter_text=None, limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_get_case_with_project_name(self, mock_project_client): + """Test case retrieval with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + case_data = {"id": 123, "title": "Test Case", "section_id": 1} + mock_client.api_request_handler.case_handler.get_case.return_value = (case_data, "") + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI AI Eval Single" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.get, ["--case-id", "123"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.case_handler.get_case.assert_called_once_with(123) + + @mock.patch("trcli.commands.cmd_cases.ProjectBasedClient") + def test_list_cases_with_project_name(self, mock_project_client): + """Test cases listing with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.case_handler.get_cases.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "cases": [{"id": 1, "title": "Test"}]}, + "", + ) + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI AI Eval Single" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_cases.list, [], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.case_handler.get_cases.assert_called_once_with( + project_id=99, suite_id=None, priority_id=None, filter_text=None, limit=250, offset=0 + ) diff --git a/tests/test_cmd_plans.py b/tests/test_cmd_plans.py new file mode 100644 index 0000000..fbe6166 --- /dev/null +++ b/tests/test_cmd_plans.py @@ -0,0 +1,424 @@ +import pytest +from unittest import mock +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from trcli.cli import Environment +from trcli.commands import cmd_plans + + +class TestCmdPlans: + """Test class for plans command functionality""" + + def setup_method(self): + """Set up test environment""" + self.runner = CliRunner() + self.environment = Environment(cmd="plans") + self.environment.host = "https://test.testrail.com" + self.environment.username = "test@example.com" + self.environment.password = "password" + self.environment.project = "Test Project" + self.environment.project_id = 1 + + def _setup_project_client_mock(self, mock_project_client, project_id=1): + """Helper to setup ProjectBasedClient mock""" + mock_client_instance = MagicMock() + mock_project_client.return_value = mock_client_instance + mock_client_instance.project.project_id = project_id + return mock_client_instance + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_success(self, mock_project_client): + """Test successful plan retrieval""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plan.return_value = ( + { + "id": 10, + "name": "Release 1.0: Final (all browsers)", + "description": "Comprehensive release testing", + "milestone_id": 3, + "assignedto_id": None, + "is_completed": False, + "completed_on": None, + "passed_count": 445, + "blocked_count": 99, + "untested_count": 473, + "retest_count": 107, + "failed_count": 56, + "project_id": 1, + "created_on": 1646058671, + "created_by": 1, + "url": "https://testrail.io/index.php?/plans/view/10", + "entries": [ + { + "id": "75698796-61d5-46e8-9c14-d334351f12d0", + "suite_id": 1, + "name": "Browser test", + "runs": [ + { + "id": 13, + "name": "Browser test", + "config": "Chrome", + "passed_count": 88, + "failed_count": 12, + } + ], + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.get, ["--plan-id", "10"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.plan_handler.get_plan.assert_called_once_with(10) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_json_output(self, mock_project_client): + """Test plan retrieval with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + plan_data = { + "id": 10, + "name": "Test Plan", + "description": "Description", + "project_id": 1, + "is_completed": False, + "passed_count": 100, + } + mock_client.api_request_handler.plan_handler.get_plan.return_value = (plan_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_plans.get, ["--plan-id", "10", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"id": 10' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_show_all_fields(self, mock_project_client): + """Test plan retrieval with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plan.return_value = ( + { + "id": 10, + "name": "Test Plan", + "description": "Plan description", + "project_id": 1, + "milestone_id": 2, + "is_completed": False, + "passed_count": 100, + "failed_count": 10, + "custom_status1_count": 5, + "custom_status2_count": 0, + "entries": [], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_plans.get, + ["--plan-id", "10", "--show-all-fields"], + obj=self.environment, + ) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_api_error(self, mock_project_client): + """Test plan retrieval with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plan.return_value = ({}, "Plan not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.get, ["--plan-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve plan: Plan not found") + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_success(self, mock_project_client): + """Test successful plans listing""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "plans": [ + { + "id": 1, + "name": "System test 1", + "description": "First system test", + "project_id": 1, + "is_completed": False, + "passed_count": 50, + "failed_count": 5, + "blocked_count": 2, + "untested_count": 10, + }, + { + "id": 2, + "name": "System test 2", + "description": "Second system test", + "project_id": 1, + "is_completed": True, + "passed_count": 100, + "failed_count": 0, + "blocked_count": 0, + "untested_count": 0, + }, + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.plan_handler.get_plans.assert_called_once_with( + project_id=1, limit=250, offset=0 + ) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_with_pagination(self, mock_project_client): + """Test plans listing with pagination parameters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + { + "offset": 100, + "limit": 50, + "size": 50, + "_links": {"next": "/api/v2/get_plans/1&offset=150", "prev": "/api/v2/get_plans/1&offset=50"}, + "plans": [ + {"id": i, "name": f"Plan {i}", "project_id": 1, "is_completed": False, "passed_count": 10} + for i in range(100, 150) + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, ["--offset", "100", "--limit", "50"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.plan_handler.get_plans.assert_called_once_with( + project_id=1, limit=50, offset=100 + ) + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_json_output(self, mock_project_client): + """Test plans listing with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + response_data = { + "offset": 0, + "limit": 250, + "size": 1, + "plans": [{"id": 1, "name": "Test", "project_id": 1, "is_completed": False}], + } + mock_client.api_request_handler.plan_handler.get_plans.return_value = (response_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_plans.list, ["--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"offset": 0' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_show_all_fields(self, mock_project_client): + """Test plans listing with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "plans": [ + { + "id": 1, + "name": "Test Plan", + "description": "Description", + "project_id": 1, + "milestone_id": 2, + "is_completed": False, + "passed_count": 50, + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, ["--show-all-fields"], obj=self.environment) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_empty_result(self, mock_project_client): + """Test plans listing with empty result""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "plans": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_log.assert_any_call("No plans found.") + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_api_error(self, mock_project_client): + """Test plans listing with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ({}, "Project not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve plans: Project not found") + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_with_next_link(self, mock_project_client): + """Test plans listing shows pagination hint when next link is present""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 250, + "_links": {"next": "/api/v2/get_plans/1&offset=250", "prev": None}, + "plans": [ + {"id": i, "name": f"Plan {i}", "project_id": 1, "is_completed": False, "passed_count": 10} + for i in range(1, 251) + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 0 + log_calls = [str(call) for call in mock_log.call_args_list] + pagination_hint_found = any("More results available" in str(call) for call in log_calls) + assert pagination_hint_found + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_with_project_id_from_config(self, mock_project_client): + """Test plan retrieval uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + plan_data = {"id": 10, "name": "Test Plan", "description": "Desc", "project_id": 42, "is_completed": False} + mock_client.api_request_handler.plan_handler.get_plan.return_value = (plan_data, "") + + self.environment.project_id = 42 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.get, ["--plan-id", "10"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.plan_handler.get_plan.assert_called_once_with(10) + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_with_project_id_from_config(self, mock_project_client): + """Test plans listing uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "plans": [{"id": 1, "name": "Test", "project_id": 99}]}, + "", + ) + + self.environment.project_id = 99 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.plan_handler.get_plans.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_get_plan_with_project_name(self, mock_project_client): + """Test plan retrieval with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + plan_data = {"id": 10, "name": "Test Plan", "description": "Desc", "project_id": 42, "is_completed": False} + mock_client.api_request_handler.plan_handler.get_plan.return_value = (plan_data, "") + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.get, ["--plan-id", "10"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.plan_handler.get_plan.assert_called_once_with(10) + + @mock.patch("trcli.commands.cmd_plans.ProjectBasedClient") + def test_list_plans_with_project_name(self, mock_project_client): + """Test plans listing with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.plan_handler.get_plans.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "plans": [{"id": 1, "name": "Test", "project_id": 99}]}, + "", + ) + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_plans.list, [], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.plan_handler.get_plans.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) diff --git a/tests/test_cmd_sections.py b/tests/test_cmd_sections.py new file mode 100644 index 0000000..d8c0cab --- /dev/null +++ b/tests/test_cmd_sections.py @@ -0,0 +1,397 @@ +import pytest +from unittest import mock +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from trcli.cli import Environment +from trcli.commands import cmd_sections + + +class TestCmdSections: + """Test class for sections command functionality""" + + def setup_method(self): + """Set up test environment""" + self.runner = CliRunner() + self.environment = Environment(cmd="sections") + self.environment.host = "https://test.testrail.com" + self.environment.username = "test@example.com" + self.environment.password = "password" + self.environment.project = "Test Project" + self.environment.project_id = 1 + + def _setup_project_client_mock(self, mock_project_client, project_id=1): + """Helper to setup ProjectBasedClient mock""" + mock_client_instance = MagicMock() + mock_project_client.return_value = mock_client_instance + mock_client_instance.project.project_id = project_id + return mock_client_instance + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_success(self, mock_project_client): + """Test successful section retrieval""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_section.return_value = ( + { + "depth": 0, + "description": "Section for prerequisites", + "display_order": 1, + "id": 1, + "name": "Prerequisites", + "parent_id": None, + "suite_id": 1, + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.get, ["--section-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.section_handler.get_section.assert_called_once_with(1) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_json_output(self, mock_project_client): + """Test section retrieval with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + section_data = {"id": 1, "name": "Test Section", "suite_id": 1, "depth": 0, "display_order": 1} + mock_client.api_request_handler.section_handler.get_section.return_value = (section_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_sections.get, ["--section-id", "1", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"id": 1' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_show_all_fields(self, mock_project_client): + """Test section retrieval with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_section.return_value = ( + { + "id": 1, + "name": "Test Section", + "description": "Section description", + "suite_id": 1, + "depth": 0, + "display_order": 1, + "parent_id": None, + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_sections.get, + ["--section-id", "1", "--show-all-fields"], + obj=self.environment, + ) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_api_error(self, mock_project_client): + """Test section retrieval with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_section.return_value = ({}, "Section not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.get, ["--section-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve section: Section not found") + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_success(self, mock_project_client): + """Test successful sections listing""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 3, + "_links": {"next": None, "prev": None}, + "sections": [ + { + "depth": 0, + "display_order": 1, + "id": 1, + "name": "Prerequisites", + "parent_id": None, + "suite_id": 1, + }, + { + "depth": 0, + "display_order": 2, + "id": 2, + "name": "Documentation & Help", + "parent_id": None, + "suite_id": 1, + }, + { + "depth": 1, + "display_order": 3, + "id": 3, + "name": "Licensing & Terms", + "parent_id": 2, + "suite_id": 1, + }, + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.section_handler.get_sections.assert_called_once_with( + project_id=1, suite_id=1, limit=250, offset=0 + ) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_with_pagination(self, mock_project_client): + """Test sections listing with pagination parameters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + { + "offset": 100, + "limit": 50, + "size": 50, + "_links": { + "next": "/api/v2/get_sections/1&suite_id=1&offset=150", + "prev": "/api/v2/get_sections/1&suite_id=1&offset=50", + }, + "sections": [ + {"id": i, "name": f"Section {i}", "suite_id": 1, "depth": 0, "display_order": i} + for i in range(100, 150) + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_sections.list, ["--suite-id", "1", "--offset", "100", "--limit", "50"], obj=self.environment + ) + + assert result.exit_code == 0 + mock_client.api_request_handler.section_handler.get_sections.assert_called_once_with( + project_id=1, suite_id=1, limit=50, offset=100 + ) + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_json_output(self, mock_project_client): + """Test sections listing with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + response_data = { + "offset": 0, + "limit": 250, + "size": 1, + "sections": [{"id": 1, "name": "Test", "suite_id": 1, "depth": 0}], + } + mock_client.api_request_handler.section_handler.get_sections.return_value = (response_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"offset": 0' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_show_all_fields(self, mock_project_client): + """Test sections listing with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "sections": [ + { + "id": 1, + "name": "Test Section", + "description": "Description", + "suite_id": 1, + "depth": 0, + "display_order": 1, + "parent_id": None, + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_sections.list, ["--suite-id", "1", "--show-all-fields"], obj=self.environment + ) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_empty_result(self, mock_project_client): + """Test sections listing with empty result""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "sections": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_log.assert_any_call("No sections found.") + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_api_error(self, mock_project_client): + """Test sections listing with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ({}, "Suite not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve sections: Suite not found") + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_with_next_link(self, mock_project_client): + """Test sections listing shows pagination hint when next link is present""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 250, + "_links": {"next": "/api/v2/get_sections/1&suite_id=1&offset=250", "prev": None}, + "sections": [ + {"id": i, "name": f"Section {i}", "suite_id": 1, "depth": 0, "display_order": i} + for i in range(1, 251) + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + log_calls = [str(call) for call in mock_log.call_args_list] + pagination_hint_found = any("More results available" in str(call) for call in log_calls) + assert pagination_hint_found + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_with_project_id_from_config(self, mock_project_client): + """Test section retrieval uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + section_data = {"id": 1, "name": "Test Section", "suite_id": 1, "depth": 0, "display_order": 1} + mock_client.api_request_handler.section_handler.get_section.return_value = (section_data, "") + + self.environment.project_id = 42 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.get, ["--section-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.section_handler.get_section.assert_called_once_with(1) + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_with_project_id_from_config(self, mock_project_client): + """Test sections listing uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "sections": [{"id": 1, "name": "Test", "suite_id": 1, "depth": 0}]}, + "", + ) + + self.environment.project_id = 99 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.section_handler.get_sections.assert_called_once_with( + project_id=99, suite_id=1, limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_get_section_with_project_name(self, mock_project_client): + """Test section retrieval with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + section_data = {"id": 1, "name": "Test Section", "suite_id": 1, "depth": 0, "display_order": 1} + mock_client.api_request_handler.section_handler.get_section.return_value = (section_data, "") + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.get, ["--section-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.section_handler.get_section.assert_called_once_with(1) + + @mock.patch("trcli.commands.cmd_sections.ProjectBasedClient") + def test_list_sections_with_project_name(self, mock_project_client): + """Test sections listing with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.section_handler.get_sections.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "sections": [{"id": 1, "name": "Test", "suite_id": 1, "depth": 0}]}, + "", + ) + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_sections.list, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.section_handler.get_sections.assert_called_once_with( + project_id=99, suite_id=1, limit=250, offset=0 + ) diff --git a/tests/test_cmd_suites.py b/tests/test_cmd_suites.py new file mode 100644 index 0000000..0e94f1c --- /dev/null +++ b/tests/test_cmd_suites.py @@ -0,0 +1,363 @@ +import pytest +from unittest import mock +from unittest.mock import MagicMock, patch +from click.testing import CliRunner + +from trcli.cli import Environment +from trcli.commands import cmd_suites + + +class TestCmdSuites: + """Test class for suites command functionality""" + + def setup_method(self): + """Set up test environment""" + self.runner = CliRunner() + self.environment = Environment(cmd="suites") + self.environment.host = "https://test.testrail.com" + self.environment.username = "test@example.com" + self.environment.password = "password" + self.environment.project = "Test Project" + self.environment.project_id = 1 + + def _setup_project_client_mock(self, mock_project_client, project_id=1): + """Helper to setup ProjectBasedClient mock""" + mock_client_instance = MagicMock() + mock_project_client.return_value = mock_client_instance + mock_client_instance.project.project_id = project_id + return mock_client_instance + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_success(self, mock_project_client): + """Test successful suite retrieval""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suite.return_value = ( + { + "id": 1, + "name": "Setup & Installation", + "description": "Test suite for setup and installation", + "project_id": 1, + "url": "http://testrail/index.php?/suites/view/1", + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.get, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.suite_handler.get_suite.assert_called_once_with(1) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_json_output(self, mock_project_client): + """Test suite retrieval with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + suite_data = {"id": 1, "name": "Test Suite", "description": "Description", "project_id": 1} + mock_client.api_request_handler.suite_handler.get_suite.return_value = (suite_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_suites.get, ["--suite-id", "1", "--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"id": 1' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_show_all_fields(self, mock_project_client): + """Test suite retrieval with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suite.return_value = ( + { + "id": 1, + "name": "Test Suite", + "description": "Suite description", + "project_id": 1, + "url": "http://testrail/suites/view/1", + "custom_field1": "value1", + "is_completed": False, + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke( + cmd_suites.get, + ["--suite-id", "1", "--show-all-fields"], + obj=self.environment, + ) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_api_error(self, mock_project_client): + """Test suite retrieval with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suite.return_value = ({}, "Suite not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.get, ["--suite-id", "999"], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve suite: Suite not found") + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_success(self, mock_project_client): + """Test successful suites listing""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "suites": [ + { + "id": 1, + "name": "Setup & Installation", + "description": "Setup tests", + "project_id": 1, + }, + { + "id": 2, + "name": "Document Editing", + "description": "Document editing tests", + "project_id": 1, + }, + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.suite_handler.get_suites.assert_called_once_with( + project_id=1, limit=250, offset=0 + ) + assert mock_log.called + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_with_pagination(self, mock_project_client): + """Test suites listing with pagination parameters""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + { + "offset": 100, + "limit": 50, + "size": 50, + "_links": {"next": "/api/v2/get_suites/1&offset=150", "prev": "/api/v2/get_suites/1&offset=50"}, + "suites": [{"id": i, "name": f"Suite {i}", "project_id": 1} for i in range(100, 150)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, ["--offset", "100", "--limit", "50"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.suite_handler.get_suites.assert_called_once_with( + project_id=1, limit=50, offset=100 + ) + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_json_output(self, mock_project_client): + """Test suites listing with JSON output""" + mock_client = self._setup_project_client_mock(mock_project_client) + response_data = {"offset": 0, "limit": 250, "size": 1, "suites": [{"id": 1, "name": "Test", "project_id": 1}]} + mock_client.api_request_handler.suite_handler.get_suites.return_value = (response_data, "") + + with patch.object(self.environment, "set_parameters"), patch.object( + self.environment, "check_for_required_parameters" + ): + result = self.runner.invoke(cmd_suites.list, ["--json-output"], obj=self.environment) + + assert result.exit_code == 0 + # Check for prettified JSON (with newlines and indentation) + assert '"offset": 0' in result.output + assert "\n" in result.output # Prettified has newlines + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_show_all_fields(self, mock_project_client): + """Test suites listing with show all fields""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 1, + "suites": [ + { + "id": 1, + "name": "Test Suite", + "description": "Description", + "project_id": 1, + "url": "http://testrail/suites/view/1", + } + ], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, ["--show-all-fields"], obj=self.environment) + + assert result.exit_code == 0 + assert mock_log.called + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_empty_result(self, mock_project_client): + """Test suites listing with empty result""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + {"offset": 0, "limit": 250, "size": 0, "suites": []}, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_log.assert_any_call("No suites found.") + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_api_error(self, mock_project_client): + """Test suites listing with API error""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ({}, "Project not found") + + with patch.object(self.environment, "elog") as mock_elog, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 1 + mock_elog.assert_called_with("Error: Failed to retrieve suites: Project not found") + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_with_next_link(self, mock_project_client): + """Test suites listing shows pagination hint when next link is present""" + mock_client = self._setup_project_client_mock(mock_project_client) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + { + "offset": 0, + "limit": 250, + "size": 250, + "_links": {"next": "/api/v2/get_suites/1&offset=250", "prev": None}, + "suites": [{"id": i, "name": f"Suite {i}", "project_id": 1} for i in range(1, 251)], + }, + "", + ) + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 0 + log_calls = [str(call) for call in mock_log.call_args_list] + pagination_hint_found = any("More results available" in str(call) for call in log_calls) + assert pagination_hint_found + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_with_project_id_from_config(self, mock_project_client): + """Test suite retrieval uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + suite_data = {"id": 1, "name": "Test Suite", "description": "Desc", "project_id": 42} + mock_client.api_request_handler.suite_handler.get_suite.return_value = (suite_data, "") + + self.environment.project_id = 42 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.get, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.suite_handler.get_suite.assert_called_once_with(1) + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_with_project_id_from_config(self, mock_project_client): + """Test suites listing uses project_id from environment when not provided""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "suites": [{"id": 1, "name": "Test", "project_id": 99}]}, + "", + ) + + self.environment.project_id = 99 + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 0 + mock_client.api_request_handler.suite_handler.get_suites.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_get_suite_with_project_name(self, mock_project_client): + """Test suite retrieval with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=42) + suite_data = {"id": 1, "name": "Test Suite", "description": "Desc", "project_id": 42} + mock_client.api_request_handler.suite_handler.get_suite.return_value = (suite_data, "") + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.get, ["--suite-id", "1"], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.suite_handler.get_suite.assert_called_once_with(1) + + @mock.patch("trcli.commands.cmd_suites.ProjectBasedClient") + def test_list_suites_with_project_name(self, mock_project_client): + """Test suites listing with project name from config""" + mock_client = self._setup_project_client_mock(mock_project_client, project_id=99) + mock_client.api_request_handler.suite_handler.get_suites.return_value = ( + {"offset": 0, "limit": 250, "size": 1, "suites": [{"id": 1, "name": "Test", "project_id": 99}]}, + "", + ) + + # Set project name in environment (as if from config file) + self.environment.project = "TRCLI Test Project" + self.environment.project_id = None # No project_id, only name + + with patch.object(self.environment, "log") as mock_log, patch.object( + self.environment, "set_parameters" + ), patch.object(self.environment, "check_for_required_parameters"): + result = self.runner.invoke(cmd_suites.list, [], obj=self.environment) + + assert result.exit_code == 0 + # Verify resolve_project was called to convert name to ID + mock_client.resolve_project.assert_called_once() + mock_client.api_request_handler.suite_handler.get_suites.assert_called_once_with( + project_id=99, limit=250, offset=0 + ) diff --git a/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index 7af9b2f..ce85e30 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -15,6 +15,7 @@ from trcli.api.run_handler import RunHandler from trcli.api.bdd_handler import BddHandler from trcli.api.case_handler import CaseHandler +from trcli.api.plan_handler import PlanHandler from trcli.cli import Environment from trcli.constants import ( ProjectErrors, @@ -84,6 +85,7 @@ def __init__( handle_futures_callback=self.handle_futures, retrieve_results_callback=ApiRequestHandler.retrieve_results_after_cancelling, ) + self.plan_handler = PlanHandler(api_client, environment) # BDD case cache for feature name matching (shared by CucumberParser and JunitParser) # Structure: {"{project_id}_{suite_id}": {normalized_name: [case_dict, case_dict, ...]}} diff --git a/trcli/api/case_handler.py b/trcli/api/case_handler.py index 75c0761..e7f9681 100644 --- a/trcli/api/case_handler.py +++ b/trcli/api/case_handler.py @@ -261,3 +261,60 @@ def update_case_automation_id(self, case_id: int, automation_id: str) -> Tuple[b update_response.error_message or f"Failed to update automation_id (HTTP {update_response.status_code})" ) return False, error_msg + + def get_case(self, case_id: int) -> Tuple[dict, str]: + """ + Retrieve a single test case by ID + + :param case_id: TestRail case ID + :returns: Tuple with (case_data_dict, error_message) + """ + response = self.client.send_get(f"get_case/{case_id}") + if response.error_message: + return {}, response.error_message + return response.response_text, "" + + def get_cases( + self, + project_id: int, + suite_id: int = None, + priority_id: str = None, + filter_text: str = None, + limit: int = 250, + offset: int = 0, + ) -> Tuple[dict, str]: + """ + Retrieve test cases for a project with optional filters + + :param project_id: TestRail project ID + :param suite_id: Optional suite ID filter + :param priority_id: Optional priority ID filter (comma-separated for multiple) + :param filter_text: Optional text search filter + :param limit: Maximum number of cases to return (default: 250) + :param offset: Offset for pagination (default: 0) + :returns: Tuple with (paginated_response_dict, error_message) + Response dict contains: cases, offset, limit, size, _links + """ + # Build query parameters + params = [] + if suite_id is not None: + params.append(f"suite_id={suite_id}") + if priority_id is not None: + params.append(f"priority_id={priority_id}") + if filter_text is not None: + params.append(f"filter={filter_text}") + if limit != 250: + params.append(f"limit={limit}") + if offset > 0: + params.append(f"offset={offset}") + + # Build URL + query_string = "&".join(params) if params else "" + url = f"get_cases/{project_id}" + if query_string: + url = f"{url}&{query_string}" + + response = self.client.send_get(url) + if response.error_message: + return {}, response.error_message + return response.response_text, "" diff --git a/trcli/api/plan_handler.py b/trcli/api/plan_handler.py new file mode 100644 index 0000000..bc2974e --- /dev/null +++ b/trcli/api/plan_handler.py @@ -0,0 +1,75 @@ +""" +PlanHandler - Handles all plan-related operations for TestRail + +It manages all plan operations including: +- Retrieving individual plans +- Listing plans with pagination +""" + +from beartype.typing import Tuple + +from trcli.api.api_client import APIClient +from trcli.cli import Environment + + +class PlanHandler: + """Handles all plan-related operations for TestRail""" + + def __init__( + self, + client: APIClient, + environment: Environment, + ): + """ + Initialize the PlanHandler + + :param client: APIClient instance for making API calls + :param environment: Environment configuration + """ + self.client = client + self.environment = environment + + def get_plan(self, plan_id: int) -> Tuple[dict, str]: + """ + Retrieve a single test plan by ID + + :param plan_id: TestRail plan ID + :returns: Tuple with (plan_data_dict, error_message) + """ + response = self.client.send_get(f"get_plan/{plan_id}") + if response.error_message: + return {}, response.error_message + return response.response_text, "" + + def get_plans( + self, + project_id: int, + limit: int = 250, + offset: int = 0, + ) -> Tuple[dict, str]: + """ + Retrieve test plans for a project with pagination + + :param project_id: TestRail project ID + :param limit: Maximum number of plans to return (default: 250) + :param offset: Offset for pagination (default: 0) + :returns: Tuple with (paginated_response_dict, error_message) + Response dict contains: plans, offset, limit, size, _links + """ + # Build query parameters + params = [] + if limit != 250: + params.append(f"limit={limit}") + if offset > 0: + params.append(f"offset={offset}") + + # Build URL + query_string = "&".join(params) if params else "" + url = f"get_plans/{project_id}" + if query_string: + url = f"{url}&{query_string}" + + response = self.client.send_get(url) + if response.error_message: + return {}, response.error_message + return response.response_text, "" diff --git a/trcli/api/section_handler.py b/trcli/api/section_handler.py index b47c5f1..58a545b 100644 --- a/trcli/api/section_handler.py +++ b/trcli/api/section_handler.py @@ -138,3 +138,48 @@ def delete_sections(self, added_sections: List[Dict]) -> Tuple[List, str]: error_message = response.error_message break return responses, error_message + + def get_section(self, section_id: int) -> Tuple[dict, str]: + """ + Retrieve a single section by ID + + :param section_id: TestRail section ID + :returns: Tuple with (section_data_dict, error_message) + """ + response = self.client.send_get(f"get_section/{section_id}") + if response.error_message: + return {}, response.error_message + return response.response_text, "" + + def get_sections( + self, + project_id: int, + suite_id: int, + limit: int = 250, + offset: int = 0, + ) -> Tuple[dict, str]: + """ + Retrieve sections for a project and suite with pagination + + :param project_id: TestRail project ID + :param suite_id: TestRail suite ID (required) + :param limit: Maximum number of sections to return (default: 250) + :param offset: Offset for pagination (default: 0) + :returns: Tuple with (paginated_response_dict, error_message) + Response dict contains: sections, offset, limit, size, _links + """ + # Build query parameters + params = [f"suite_id={suite_id}"] # suite_id is required + if limit != 250: + params.append(f"limit={limit}") + if offset > 0: + params.append(f"offset={offset}") + + # Build URL + query_string = "&".join(params) + url = f"get_sections/{project_id}&{query_string}" + + response = self.client.send_get(url) + if response.error_message: + return {}, response.error_message + return response.response_text, "" diff --git a/trcli/api/suite_handler.py b/trcli/api/suite_handler.py index 40beaa5..f55d308 100644 --- a/trcli/api/suite_handler.py +++ b/trcli/api/suite_handler.py @@ -161,3 +161,48 @@ def delete_suite(self, suite_id: int) -> Tuple[dict, str]: """ response = self.client.send_post(f"delete_suite/{suite_id}", payload={}) return response.response_text, response.error_message + + def get_suite(self, suite_id: int) -> Tuple[dict, str]: + """ + Retrieve a single test suite by ID + + :param suite_id: TestRail suite ID + :returns: Tuple with (suite_data_dict, error_message) + """ + response = self.client.send_get(f"get_suite/{suite_id}") + if response.error_message: + return {}, response.error_message + return response.response_text, "" + + def get_suites( + self, + project_id: int, + limit: int = 250, + offset: int = 0, + ) -> Tuple[dict, str]: + """ + Retrieve test suites for a project with pagination + + :param project_id: TestRail project ID + :param limit: Maximum number of suites to return (default: 250) + :param offset: Offset for pagination (default: 0) + :returns: Tuple with (paginated_response_dict, error_message) + Response dict contains: suites, offset, limit, size, _links + """ + # Build query parameters + params = [] + if limit != 250: + params.append(f"limit={limit}") + if offset > 0: + params.append(f"offset={offset}") + + # Build URL + query_string = "&".join(params) if params else "" + url = f"get_suites/{project_id}" + if query_string: + url = f"{url}&{query_string}" + + response = self.client.send_get(url) + if response.error_message: + return {}, response.error_message + return response.response_text, "" diff --git a/trcli/commands/cmd_cases.py b/trcli/commands/cmd_cases.py new file mode 100644 index 0000000..584860a --- /dev/null +++ b/trcli/commands/cmd_cases.py @@ -0,0 +1,286 @@ +import builtins +import click +import json + +from trcli.api.project_based_client import ProjectBasedClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +def print_config(env: Environment, action: str): + env.log( + f"Cases {action} Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + ) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test cases in TestRail""" + environment.cmd = "cases" + environment.set_parameters(context) + + +@cli.command() +@click.option("--case-id", type=click.IntRange(min=1), required=True, metavar="", help="Case ID to retrieve.") +@click.option("--json-output", is_flag=True, help="Output case as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def get( + environment: Environment, + context: click.Context, + case_id: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """Get a single test case by ID""" + environment.check_for_required_parameters() + + print_config(environment, "Get") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving case ID {case_id}...") + + # Retrieve the case using CaseHandler from ProjectBasedClient + case_data, error_message = project_client.api_request_handler.case_handler.get_case(case_id) + + if error_message: + environment.elog(f"Error: Failed to retrieve case: {error_message}") + raise SystemExit(1) + + # Verify case belongs to the specified project + if case_data.get("suite_id"): + # Cases with suite_id belong to multi-suite projects + # We should verify project_id, but API doesn't return it directly + # For now, we trust the user provided correct project_id + pass + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(case_data, indent=2)) + else: + # Display case details + environment.log("") + environment.log(f"Case ID: {case_data.get('id', 'N/A')}") + environment.log(f" Title: {case_data.get('title', 'N/A')}") + environment.log(f" Section ID: {case_data.get('section_id', 'N/A')}") + environment.log(f" Suite ID: {case_data.get('suite_id', 'N/A')}") + environment.log(f" Template ID: {case_data.get('template_id', 'N/A')}") + environment.log(f" Type ID: {case_data.get('type_id', 'N/A')}") + environment.log(f" Priority ID: {case_data.get('priority_id', 'N/A')}") + + if case_data.get("milestone_id"): + environment.log(f" Milestone ID: {case_data.get('milestone_id')}") + + if case_data.get("refs"): + environment.log(f" References: {case_data.get('refs')}") + + environment.log(f" Created By: {case_data.get('created_by', 'N/A')}") + environment.log(f" Created On: {case_data.get('created_on', 'N/A')}") + environment.log(f" Updated By: {case_data.get('updated_by', 'N/A')}") + environment.log(f" Updated On: {case_data.get('updated_on', 'N/A')}") + + if case_data.get("estimate"): + environment.log(f" Estimate: {case_data.get('estimate')}") + + if case_data.get("estimate_forecast"): + environment.log(f" Estimate Forecast: {case_data.get('estimate_forecast')}") + + # Display labels + labels = case_data.get("labels", []) + if labels: + if show_all_fields: + environment.log(f" Labels ({len(labels)}):") + for label in labels: + environment.log(f" - ID: {label.get('id')}, Title: {label.get('title')}") + else: + label_titles = ", ".join([label.get("title", "") for label in labels]) + environment.log(f" Labels: {label_titles}") + else: + environment.log(" Labels: (none)") + + if show_all_fields: + # Show all custom fields + custom_fields = {k: v for k, v in case_data.items() if k.startswith("custom_")} + if custom_fields: + environment.log(f" Custom Fields ({len(custom_fields)}):") + for key, value in custom_fields.items(): + display_name = key.replace("_", " ").title() + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + environment.log(f" {display_name}: {display_value}") + else: + # Show count of custom fields + custom_fields = {k: v for k, v in case_data.items() if k.startswith("custom_")} + if custom_fields: + environment.log(f" Custom Fields: {len(custom_fields)} field(s)") + + +@cli.command() +@click.option("--suite-id", type=click.IntRange(min=1), metavar="", help="Filter by suite ID.") +@click.option("--priority-id", metavar="", help="Filter by priority ID (comma-separated for multiple, e.g., '3,4').") +@click.option("--filter", "filter_text", metavar="", help="Filter by text search (case title).") +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output cases as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + suite_id: int, + priority_id: str, + filter_text: str, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test cases from TestRail""" + environment.check_for_required_parameters() + + print_config(environment, "List") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + # Build filter description + filters = [] + if suite_id: + filters.append(f"suite_id={suite_id}") + if priority_id: + filters.append(f"priority_id={priority_id}") + if filter_text: + filters.append(f"filter='{filter_text}'") + + filter_desc = ", ".join(filters) if filters else "no filters" + environment.log(f"Retrieving cases for project ID {project_client.project.project_id} ({filter_desc})...") + + # Retrieve cases using CaseHandler from ProjectBasedClient + response_data, error_message = project_client.api_request_handler.case_handler.get_cases( + project_id=project_client.project.project_id, + suite_id=suite_id, + priority_id=priority_id, + filter_text=filter_text, + limit=limit, + offset=offset, + ) + + if error_message: + environment.elog(f"Error: Failed to retrieve cases: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(response_data, indent=2)) + else: + # Display cases line by line + cases = response_data.get("cases", []) + response_offset = response_data.get("offset", 0) + response_limit = response_data.get("limit", 250) + response_size = response_data.get("size", 0) + next_link = response_data.get("_links", {}).get("next") + + if not cases: + environment.log("No cases found.") + else: + environment.log( + f"Found {response_size} case(s) (showing {response_offset + 1}-{response_offset + len(cases)}):" + ) + if next_link: + environment.log(" (More results available - use --offset and --limit for pagination)") + environment.log("") + + for case in cases: + if show_all_fields: + # Show all fields from API response + environment.log(f" Case ID: {case.get('id', 'N/A')}") + + # Iterate through all fields in the case + for key, value in case.items(): + if key == "id": + continue # Already displayed as Case ID + + # Format field name for display + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + # Handle list fields (like labels) + if key == "labels" and value: + label_titles = ", ".join([label.get("title", "") for label in value]) + display_value = f"{len(value)} label(s): {label_titles}" + elif value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display compact format + environment.log(f" Case ID: {case.get('id', 'N/A')}") + environment.log(f" Title: {case.get('title', 'N/A')}") + environment.log(f" Section ID: {case.get('section_id', 'N/A')}") + + if case.get("suite_id"): + environment.log(f" Suite ID: {case.get('suite_id')}") + + environment.log(f" Priority ID: {case.get('priority_id', 'N/A')}") + environment.log(f" Type ID: {case.get('type_id', 'N/A')}") + + if case.get("refs"): + refs = case.get("refs", "") + if len(refs) > 50: + refs = refs[:50] + "..." + environment.log(f" References: {refs}") + + # Display labels + labels = case.get("labels", []) + if labels: + label_titles = ", ".join([label.get("title", "") for label in labels]) + environment.log(f" Labels: {label_titles}") + + # Show custom fields count + custom_fields = {k: v for k, v in case.items() if k.startswith("custom_")} + if custom_fields: + environment.log(f" Custom Fields: {len(custom_fields)} field(s)") + + environment.log("") diff --git a/trcli/commands/cmd_plans.py b/trcli/commands/cmd_plans.py new file mode 100644 index 0000000..6117d43 --- /dev/null +++ b/trcli/commands/cmd_plans.py @@ -0,0 +1,323 @@ +import builtins +import click +import json + +from trcli.api.project_based_client import ProjectBasedClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +def print_config(env: Environment, action: str): + env.log( + f"Plans {action} Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + ) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test plans in TestRail""" + environment.cmd = "plans" + environment.set_parameters(context) + + +@cli.command() +@click.option("--plan-id", type=click.IntRange(min=1), required=True, metavar="", help="Plan ID to retrieve.") +@click.option("--json-output", is_flag=True, help="Output plan as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def get( + environment: Environment, + context: click.Context, + plan_id: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """Get a single test plan by ID""" + environment.check_for_required_parameters() + + print_config(environment, "Get") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving plan ID {plan_id}...") + + # Retrieve the plan using PlanHandler from ProjectBasedClient + plan_data, error_message = project_client.api_request_handler.plan_handler.get_plan(plan_id) + + if error_message: + environment.elog(f"Error: Failed to retrieve plan: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(plan_data, indent=2)) + else: + # Display plan details + environment.log("") + environment.log(f"Plan ID: {plan_data.get('id', 'N/A')}") + environment.log(f" Name: {plan_data.get('name', 'N/A')}") + + if plan_data.get("description"): + description = plan_data.get("description") + # Truncate long descriptions in non-show-all-fields mode + if not show_all_fields and len(description) > 100: + description = description[:100] + "..." + environment.log(f" Description: {description}") + else: + environment.log(" Description: (none)") + + environment.log(f" Project ID: {plan_data.get('project_id', 'N/A')}") + + if plan_data.get("milestone_id"): + environment.log(f" Milestone ID: {plan_data.get('milestone_id')}") + + if plan_data.get("assignedto_id"): + environment.log(f" Assigned To ID: {plan_data.get('assignedto_id')}") + + # Status information + is_completed = plan_data.get("is_completed", False) + environment.log(f" Status: {'Completed' if is_completed else 'Active'}") + + if is_completed and plan_data.get("completed_on"): + environment.log(f" Completed On: {plan_data.get('completed_on')}") + + # Test counts + environment.log(" Test Status Counts:") + environment.log(f" Passed: {plan_data.get('passed_count', 0)}") + environment.log(f" Failed: {plan_data.get('failed_count', 0)}") + environment.log(f" Blocked: {plan_data.get('blocked_count', 0)}") + environment.log(f" Retest: {plan_data.get('retest_count', 0)}") + environment.log(f" Untested: {plan_data.get('untested_count', 0)}") + + if show_all_fields: + # Show custom status counts + custom_counts = { + k: v for k, v in plan_data.items() if k.startswith("custom_status") and k.endswith("_count") + } + if any(v > 0 for v in custom_counts.values()): + environment.log(" Custom Statuses:") + for key, value in custom_counts.items(): + if value > 0: + status_num = key.replace("custom_status", "").replace("_count", "") + environment.log(f" Custom Status {status_num}: {value}") + + # Created and updated info + environment.log(f" Created By: {plan_data.get('created_by', 'N/A')}") + environment.log(f" Created On: {plan_data.get('created_on', 'N/A')}") + + if plan_data.get("url"): + environment.log(f" URL: {plan_data.get('url')}") + + # Entries (runs grouped by suite/config) + entries = plan_data.get("entries", []) + if entries: + environment.log(f"\n Entries ({len(entries)}):") + for idx, entry in enumerate(entries, 1): + environment.log(f" Entry {idx}:") + environment.log(f" ID: {entry.get('id', 'N/A')}") + environment.log(f" Name: {entry.get('name', 'N/A')}") + environment.log(f" Suite ID: {entry.get('suite_id', 'N/A')}") + + if entry.get("description"): + desc = entry.get("description") + if not show_all_fields and len(desc) > 60: + desc = desc[:60] + "..." + environment.log(f" Description: {desc}") + + runs = entry.get("runs", []) + if runs: + environment.log(f" Runs ({len(runs)}):") + for run in runs: + environment.log(f" - Run ID {run.get('id')}: {run.get('name', 'N/A')}") + if run.get("config"): + environment.log(f" Config: {run.get('config')}") + if show_all_fields: + environment.log( + f" Passed: {run.get('passed_count', 0)}, Failed: {run.get('failed_count', 0)}, Blocked: {run.get('blocked_count', 0)}, Untested: {run.get('untested_count', 0)}" + ) + else: + environment.log("\n Entries: (none)") + + if show_all_fields: + # Show all other fields + standard_fields = [ + "id", + "name", + "description", + "project_id", + "milestone_id", + "assignedto_id", + "is_completed", + "completed_on", + "passed_count", + "failed_count", + "blocked_count", + "retest_count", + "untested_count", + "created_by", + "created_on", + "url", + "entries", + "custom_status1_count", + "custom_status2_count", + "custom_status3_count", + "custom_status4_count", + "custom_status5_count", + "custom_status6_count", + "custom_status7_count", + ] + other_fields = {k: v for k, v in plan_data.items() if k not in standard_fields} + if other_fields: + environment.log(f"\n Additional Fields ({len(other_fields)}):") + for key, value in other_fields.items(): + display_name = key.replace("_", " ").title() + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + environment.log(f" {display_name}: {display_value}") + + +@cli.command() +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output plans as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test plans from TestRail""" + environment.check_for_required_parameters() + + print_config(environment, "List") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving plans for project ID {project_client.project.project_id}...") + + # Retrieve plans using PlanHandler from ProjectBasedClient + response_data, error_message = project_client.api_request_handler.plan_handler.get_plans( + project_id=project_client.project.project_id, + limit=limit, + offset=offset, + ) + + if error_message: + environment.elog(f"Error: Failed to retrieve plans: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(response_data, indent=2)) + else: + # Display plans line by line + plans = response_data.get("plans", []) + response_offset = response_data.get("offset", 0) + response_limit = response_data.get("limit", 250) + response_size = response_data.get("size", 0) + next_link = response_data.get("_links", {}).get("next") + + if not plans: + environment.log("No plans found.") + else: + environment.log( + f"Found {response_size} plan(s) (showing {response_offset + 1}-{response_offset + len(plans)}):" + ) + if next_link: + environment.log(" (More results available - use --offset and --limit for pagination)") + environment.log("") + + for plan in plans: + if show_all_fields: + # Show all fields from API response + environment.log(f" Plan ID: {plan.get('id', 'N/A')}") + + # Iterate through all fields in the plan + for key, value in plan.items(): + if key == "id": + continue # Already displayed as Plan ID + + # Format field name for display + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, bool): + display_value = "Yes" if value else "No" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display compact format + environment.log(f" Plan ID: {plan.get('id', 'N/A')}") + environment.log(f" Name: {plan.get('name', 'N/A')}") + + if plan.get("description"): + description = plan.get("description") + # Truncate long descriptions in compact mode + if len(description) > 80: + description = description[:80] + "..." + environment.log(f" Description: {description}") + + is_completed = plan.get("is_completed", False) + environment.log(f" Status: {'Completed' if is_completed else 'Active'}") + + # Show test counts + passed = plan.get("passed_count", 0) + failed = plan.get("failed_count", 0) + blocked = plan.get("blocked_count", 0) + untested = plan.get("untested_count", 0) + environment.log( + f" Tests: Passed={passed}, Failed={failed}, Blocked={blocked}, Untested={untested}" + ) + + if plan.get("milestone_id"): + environment.log(f" Milestone ID: {plan.get('milestone_id')}") + + environment.log("") diff --git a/trcli/commands/cmd_sections.py b/trcli/commands/cmd_sections.py new file mode 100644 index 0000000..690a18e --- /dev/null +++ b/trcli/commands/cmd_sections.py @@ -0,0 +1,230 @@ +import builtins +import click +import json + +from trcli.api.project_based_client import ProjectBasedClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +def print_config(env: Environment, action: str): + env.log( + f"Sections {action} Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + ) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test sections in TestRail""" + environment.cmd = "sections" + environment.set_parameters(context) + + +@cli.command() +@click.option("--section-id", type=click.IntRange(min=1), required=True, metavar="", help="Section ID to retrieve.") +@click.option("--json-output", is_flag=True, help="Output section as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def get( + environment: Environment, + context: click.Context, + section_id: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """Get a single test section by ID""" + environment.check_for_required_parameters() + + print_config(environment, "Get") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving section ID {section_id}...") + + # Retrieve the section using SectionHandler from ProjectBasedClient + section_data, error_message = project_client.api_request_handler.section_handler.get_section(section_id) + + if error_message: + environment.elog(f"Error: Failed to retrieve section: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(section_data, indent=2)) + else: + # Display section details + environment.log("") + environment.log(f"Section ID: {section_data.get('id', 'N/A')}") + environment.log(f" Name: {section_data.get('name', 'N/A')}") + + if section_data.get("description"): + description = section_data.get("description") + # Truncate long descriptions in non-show-all-fields mode + if not show_all_fields and len(description) > 100: + description = description[:100] + "..." + environment.log(f" Description: {description}") + else: + environment.log(" Description: (none)") + + environment.log(f" Suite ID: {section_data.get('suite_id', 'N/A')}") + environment.log(f" Depth: {section_data.get('depth', 0)}") + + if show_all_fields: + environment.log(f" Display Order: {section_data.get('display_order', 'N/A')}") + + parent_id = section_data.get("parent_id") + if parent_id: + environment.log(f" Parent Section ID: {parent_id}") + else: + environment.log(" Parent Section ID: (root level)") + + standard_fields = ["id", "name", "description", "suite_id", "depth", "display_order", "parent_id"] + other_fields = {k: v for k, v in section_data.items() if k not in standard_fields} + if other_fields: + environment.log(f"\n Additional Fields ({len(other_fields)}):") + for key, value in other_fields.items(): + display_name = key.replace("_", " ").title() + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + environment.log(f" {display_name}: {display_value}") + + +@cli.command() +@click.option( + "--suite-id", type=click.IntRange(min=1), required=True, metavar="", help="Suite ID to list sections from." +) +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output sections as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + suite_id: int, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test sections from TestRail""" + environment.check_for_required_parameters() + + print_config(environment, "List") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving sections for project ID {project_client.project.project_id}, suite ID {suite_id}...") + + # Retrieve sections using SectionHandler from ProjectBasedClient + response_data, error_message = project_client.api_request_handler.section_handler.get_sections( + project_id=project_client.project.project_id, + suite_id=suite_id, + limit=limit, + offset=offset, + ) + + if error_message: + environment.elog(f"Error: Failed to retrieve sections: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(response_data, indent=2)) + else: + # Display sections line by line + sections = response_data.get("sections", []) + response_offset = response_data.get("offset", 0) + response_limit = response_data.get("limit", 250) + response_size = response_data.get("size", 0) + next_link = response_data.get("_links", {}).get("next") + + if not sections: + environment.log("No sections found.") + else: + environment.log( + f"Found {response_size} section(s) (showing {response_offset + 1}-{response_offset + len(sections)}):" + ) + if next_link: + environment.log(" (More results available - use --offset and --limit for pagination)") + environment.log("") + + for section in sections: + if show_all_fields: + # Show all fields from API response + environment.log(f" Section ID: {section.get('id', 'N/A')}") + + # Iterate through all fields in the section + for key, value in section.items(): + if key == "id": + continue # Already displayed as Section ID + + # Format field name for display + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display compact format with hierarchy indication + depth = section.get("depth", 0) + indent = " " + (" " * depth) # Extra indentation for child sections + + environment.log(f"{indent}Section ID: {section.get('id', 'N/A')}") + environment.log(f"{indent} Name: {section.get('name', 'N/A')}") + + if section.get("description"): + description = section.get("description") + # Truncate long descriptions in compact mode + if len(description) > 60: + description = description[:60] + "..." + environment.log(f"{indent} Description: {description}") + + environment.log(f"{indent} Suite ID: {section.get('suite_id', 'N/A')}") + environment.log(f"{indent} Depth: {depth}") + + environment.log("") diff --git a/trcli/commands/cmd_suites.py b/trcli/commands/cmd_suites.py new file mode 100644 index 0000000..068c0bd --- /dev/null +++ b/trcli/commands/cmd_suites.py @@ -0,0 +1,217 @@ +import builtins +import click +import json + +from trcli.api.project_based_client import ProjectBasedClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +def print_config(env: Environment, action: str): + env.log( + f"Suites {action} Execution Parameters" + f"\n> TestRail instance: {env.host} (user: {env.username})" + f"\n> Project: {env.project if env.project else env.project_id}" + ) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Manage test suites in TestRail""" + environment.cmd = "suites" + environment.set_parameters(context) + + +@cli.command() +@click.option("--suite-id", type=click.IntRange(min=1), required=True, metavar="", help="Suite ID to retrieve.") +@click.option("--json-output", is_flag=True, help="Output suite as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def get( + environment: Environment, + context: click.Context, + suite_id: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """Get a single test suite by ID""" + environment.check_for_required_parameters() + + print_config(environment, "Get") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving suite ID {suite_id}...") + + # Retrieve the suite using SuiteHandler from ProjectBasedClient + suite_data, error_message = project_client.api_request_handler.suite_handler.get_suite(suite_id) + + if error_message: + environment.elog(f"Error: Failed to retrieve suite: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(suite_data, indent=2)) + else: + # Display suite details + environment.log("") + environment.log(f"Suite ID: {suite_data.get('id', 'N/A')}") + environment.log(f" Name: {suite_data.get('name', 'N/A')}") + + if suite_data.get("description"): + description = suite_data.get("description") + # Truncate long descriptions in non-show-all-fields mode + if not show_all_fields and len(description) > 100: + description = description[:100] + "..." + environment.log(f" Description: {description}") + else: + environment.log(" Description: (none)") + + environment.log(f" Project ID: {suite_data.get('project_id', 'N/A')}") + + if suite_data.get("url"): + environment.log(f" URL: {suite_data.get('url')}") + + if show_all_fields: + # Show all custom fields if any + custom_fields = { + k: v for k, v in suite_data.items() if k not in ["id", "name", "description", "project_id", "url"] + } + if custom_fields: + environment.log(f" Additional Fields ({len(custom_fields)}):") + for key, value in custom_fields.items(): + display_name = key.replace("_", " ").title() + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + environment.log(f" {display_name}: {display_value}") + + +@cli.command() +@click.option("--offset", type=int, default=0, metavar="", help="Offset for pagination (default: 0).") +@click.option("--limit", type=int, default=250, metavar="", help="Limit for pagination (default: 250).") +@click.option("--json-output", is_flag=True, help="Output suites as raw JSON from API.") +@click.option("--show-all-fields", is_flag=True, help="Show all fields including custom fields in detail.") +@click.pass_context +@pass_environment +def list( + environment: Environment, + context: click.Context, + offset: int, + limit: int, + json_output: bool, + show_all_fields: bool, + *args, + **kwargs, +): + """List test suites from TestRail""" + environment.check_for_required_parameters() + + print_config(environment, "List") + + # Create ProjectBasedClient to resolve project + project_client = ProjectBasedClient( + environment=environment, + suite=TestRailSuite(name=environment.suite_name, suite_id=environment.suite_id), + ) + + # Resolve project (converts name to ID if needed) + project_client.resolve_project() + + environment.log(f"Retrieving suites for project ID {project_client.project.project_id}...") + + # Retrieve suites using SuiteHandler from ProjectBasedClient + response_data, error_message = project_client.api_request_handler.suite_handler.get_suites( + project_id=project_client.project.project_id, + limit=limit, + offset=offset, + ) + + if error_message: + environment.elog(f"Error: Failed to retrieve suites: {error_message}") + raise SystemExit(1) + + # Handle output format + if json_output: + # Output prettified JSON response + print(json.dumps(response_data, indent=2)) + else: + # Display suites line by line + suites = response_data.get("suites", []) + response_offset = response_data.get("offset", 0) + response_limit = response_data.get("limit", 250) + response_size = response_data.get("size", 0) + next_link = response_data.get("_links", {}).get("next") + + if not suites: + environment.log("No suites found.") + else: + environment.log( + f"Found {response_size} suite(s) (showing {response_offset + 1}-{response_offset + len(suites)}):" + ) + if next_link: + environment.log(" (More results available - use --offset and --limit for pagination)") + environment.log("") + + for suite in suites: + if show_all_fields: + # Show all fields from API response + environment.log(f" Suite ID: {suite.get('id', 'N/A')}") + + # Iterate through all fields in the suite + for key, value in suite.items(): + if key == "id": + continue # Already displayed as Suite ID + + # Format field name for display + display_name = key.replace("_", " ").title() + + # Handle None values + if value is None: + display_value = "N/A" + elif isinstance(value, builtins.list): + if value: + display_value = f"{len(value)} item(s): {value}" + else: + display_value = "[]" + else: + display_value = value + + environment.log(f" {display_name}: {display_value}") + + environment.log("") + else: + # Display compact format + environment.log(f" Suite ID: {suite.get('id', 'N/A')}") + environment.log(f" Name: {suite.get('name', 'N/A')}") + + if suite.get("description"): + description = suite.get("description") + # Truncate long descriptions in compact mode + if len(description) > 80: + description = description[:80] + "..." + environment.log(f" Description: {description}") + + environment.log(f" Project ID: {suite.get('project_id', 'N/A')}") + + environment.log("") diff --git a/trcli/constants.py b/trcli/constants.py index c32fa68..60ef92a 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -89,6 +89,11 @@ parse_robot=dict(**FAULT_MAPPING, **PARSE_COMMON_FAULT_MAPPING, **PARSE_JUNIT_OR_ROBOT_FAULT_MAPPING), labels=dict(**FAULT_MAPPING), references=dict(**FAULT_MAPPING), + results=dict(**FAULT_MAPPING), + cases=dict(**FAULT_MAPPING), + suites=dict(**FAULT_MAPPING), + plans=dict(**FAULT_MAPPING), + sections=dict(**FAULT_MAPPING), ) PROMPT_MESSAGES = dict(