diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 3dfd1c6..d6cb332 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,13 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb - **MINOR**: New features that are backward-compatible. - **PATCH**: Bug fixes or minor changes that do not affect backward compatibility. +## [1.15.0] + +_released 06-12-2026 + +### Added + - Added new data extraction commands for cases, suites, sections and plans for AI automation + ## [1.14.3] _released 05-28-2026 @@ -60,7 +67,6 @@ _released 03-18-2026 ### Fixed - Cannot add empty runs via add_run command due to empty test case validation. - ## [1.13.3] _released 03-05-2026 @@ -447,4 +453,4 @@ _released 10-07-2022 ## [1.0] _released 16-04-2022 -- **Initial Public Release:** The first public release of `trcli`. \ No newline at end of file +- **Initial Public Release:** The first public release of `trcli`. diff --git a/README.md b/README.md index 87d825a..5da4166 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** +- **Reading plans, suites, and cases from TestRail as JSON** 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) @@ -33,7 +34,7 @@ trcli ``` You should get something like this: ``` -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Supported and loaded modules: - parse_junit: JUnit XML Files (& Similar) @@ -52,7 +53,7 @@ CLI general reference -------- ```shell $ trcli --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli [OPTIONS] COMMAND [ARGS]... @@ -2180,7 +2181,7 @@ Options: ### Reference ```shell $ trcli add_run --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli add_run [OPTIONS] @@ -2390,7 +2391,7 @@ providing you with a solid base of test cases, which you can further expand on T ### Reference ```shell $ trcli parse_openapi --help -TestRail CLI v1.14.3 +TestRail CLI v1.15.0 Copyright 2025 Gurock Software GmbH - www.gurock.com Usage: trcli parse_openapi [OPTIONS] @@ -2480,6 +2481,86 @@ expand your test cases to cover specific business logic and workflows. |---------------------------------------|---------------------------------------------------| | `VERB /path -> status_code (summary)` | `GET /pet/{petId} -> 200 (Successful operation) ` | +Reading data from TestRail +----------------- + +The TestRail CLI provides read-only commands to retrieve plans, suites, sections, and cases from TestRail as JSON. +These commands output JSON directly to stdout, making them easy to integrate with other tools +via piping (e.g., `jq`). + +### Commands + +| Command | Description | +|---------|-------------| +| `get_plans` | List all test plans for a project | +| `get_plan` | Get a single test plan by ID | +| `get_suites` | List all test suites for a project | +| `get_sections` | List test sections for a project or suite | +| `get_cases` | List test cases for a project and suite | +| `get_case` | Get a single test case by ID | + +### Usage Examples + +**List all test plans for a project:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_plans --project-id 1 +``` + +**Get a single test plan:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_plan --plan-id 42 +``` + +**List all test suites for a project:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_suites --project-id 1 +``` + +**List test sections (with optional suite filter):** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_sections --project-id 1 --suite-id 3 +``` + +**List test cases (with optional section filter):** +```shell +# All cases in a suite +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_cases --project-id 1 --suite-id 3 + +# Cases in a specific section +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_cases --project-id 1 --suite-id 3 --section-id 10 +``` + +**Get a single test case:** +```shell +trcli --host https://example.testrail.io --username user@example.com --key YOUR_API_KEY \ + get_case --case-id 5001 +``` + +**Using environment variables instead of flags:** +```shell +export TR_CLI_HOST=https://example.testrail.io +export TR_CLI_USERNAME=user@example.com +export TR_CLI_KEY=YOUR_API_KEY + +trcli get_plans --project-id 1 +trcli get_sections --project-id 1 --suite-id 3 +trcli get_case --case-id 5001 +``` + +**Piping output to jq:** +```shell +trcli get_cases --project-id 1 --suite-id 3 | jq '.[].title' +``` + +All read commands output a JSON array or object to stdout. Errors and warnings are printed to stderr, +so they will not interfere with piped JSON output. + Parameter sources ----------------- You can choose to set parameters from different sources, like a default config file, diff --git a/tests/test_cmd_get_case.py b/tests/test_cmd_get_case.py new file mode 100644 index 0000000..bb5a4cd --- /dev/null +++ b/tests/test_cmd_get_case.py @@ -0,0 +1,105 @@ +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetCase: + """Tests for the get_case CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_case", + "--case-id", "99", + ] + + @mock.patch("trcli.commands.cmd_get_case.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + case_data = {"id": 99, "title": "My Test Case", "section_id": 5} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=case_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == case_data + mock_client.send_get.assert_called_once_with("get_case/99") + + @mock.patch("trcli.commands.cmd_get_case.create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + @mock.patch("trcli.commands.cmd_get_case.create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=404, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "404" in result.output + + def test_missing_case_id_exits_nonzero(self): + """Missing --case-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_case", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_case", + "--case-id", "99", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "get_case", + "--case-id", "99", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_cases.py b/tests/test_cmd_get_cases.py new file mode 100644 index 0000000..fb47ab5 --- /dev/null +++ b/tests/test_cmd_get_cases.py @@ -0,0 +1,151 @@ +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetCases: + """Tests for the get_cases CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + "--suite-id", "10", + ] + + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + cases_data = [{"id": 100, "title": "Login test"}, {"id": 101, "title": "Logout test"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=cases_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == cases_data + + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") + def test_happy_path_paginated(self, mock_create_client): + """Paginated API response collects all pages.""" + page1 = { + "cases": [{"id": 1, "title": "Case 1"}], + "_links": {"next": "get_cases/1&suite_id=10&offset=1"}, + } + page2 = { + "cases": [{"id": 2, "title": "Case 2"}], + "_links": {"next": None}, + } + mock_client = mock_create_client.return_value + mock_client.send_get.side_effect = [ + APIClientResult(status_code=200, response_text=page1, error_message=""), + APIClientResult(status_code=200, response_text=page2, error_message=""), + ] + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == [ + {"id": 1, "title": "Case 1"}, + {"id": 2, "title": "Case 2"}, + ] + assert mock_client.send_get.call_count == 2 + + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") + def test_with_section_id_filter(self, mock_create_client): + """Optional --section-id is appended to the API URL.""" + cases_data = [{"id": 100, "title": "Filtered case"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=cases_data, error_message="" + ) + + args = self.BASE_ARGS + ["--section-id", "5"] + runner = CliRunner() + result = runner.invoke(trcli_cli, args, catch_exceptions=False) + + assert result.exit_code == 0 + call_args = mock_client.send_get.call_args[0][0] + assert "section_id=5" in call_args + + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Timeout" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Timeout" in result.output + + @mock.patch("trcli.commands.cmd_get_cases.create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=404, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "404" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--suite-id", "10", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_suite_id_exits_nonzero(self): + """Missing --suite-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_cases", + "--project-id", "1", + "--suite-id", "10", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/tests/test_cmd_get_plan.py b/tests/test_cmd_get_plan.py new file mode 100644 index 0000000..803d970 --- /dev/null +++ b/tests/test_cmd_get_plan.py @@ -0,0 +1,91 @@ +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetPlan: + """Tests for the get_plan CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plan", + "--plan-id", "42", + ] + + @mock.patch("trcli.commands.cmd_get_plan.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + plan_data = {"id": 42, "name": "My Plan", "entries": []} + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=plan_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == plan_data + mock_client.send_get.assert_called_once_with("get_plan/42") + + @mock.patch("trcli.commands.cmd_get_plan.create_api_client") + def test_api_error_exits_with_code_1(self, mock_create_client): + """API error is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=400, response_text={"error": "Plan not found"}, error_message="Plan not found" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Plan not found" in result.output + + def test_missing_plan_id_exits_nonzero(self): + """Missing --plan-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plan", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_plan", + "--plan-id", "42", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "get_plan", + "--plan-id", "42", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_plans.py b/tests/test_cmd_get_plans.py new file mode 100644 index 0000000..504991c --- /dev/null +++ b/tests/test_cmd_get_plans.py @@ -0,0 +1,106 @@ +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetPlans: + """Tests for the get_plans CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plans", + "--project-id", "1", + ] + + @mock.patch("trcli.commands.cmd_get_plans.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + plans_data = [{"id": 1, "name": "Plan A"}, {"id": 2, "name": "Plan B"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=plans_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == plans_data + mock_client.send_get.assert_called_once_with("get_plans/1") + + @mock.patch("trcli.commands.cmd_get_plans.create_api_client") + def test_api_error_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Could not connect to TestRail" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Could not connect to TestRail" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 2 + assert "project ID" in result.output or "project-id" in result.output.lower() + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_plans", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output + + def test_missing_username_exits_nonzero(self): + """Missing -u triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-p", "password123", + "get_plans", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "username" in result.output + + def test_missing_password_and_key_exits_nonzero(self): + """Missing both -p and -k triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "get_plans", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--password or --key is required" in result.output diff --git a/tests/test_cmd_get_sections.py b/tests/test_cmd_get_sections.py new file mode 100644 index 0000000..4df1bea --- /dev/null +++ b/tests/test_cmd_get_sections.py @@ -0,0 +1,116 @@ +import json +from unittest import mock + +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetSections: + """Tests for the get_sections CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_sections", + "--project-id", "1", + ] + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + sections_data = [{"id": 10, "name": "Login"}, {"id": 11, "name": "Checkout"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=sections_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == sections_data + mock_client.send_get.assert_called_once_with("get_sections/1") + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_with_suite_id_filter(self, mock_create_client): + sections_data = [{"id": 10, "name": "Suite Section"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=sections_data, error_message="" + ) + + args = self.BASE_ARGS + ["--suite-id", "5"] + runner = CliRunner() + result = runner.invoke(trcli_cli, args, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == sections_data + mock_client.send_get.assert_called_once_with("get_sections/1&suite_id=5") + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_paginated_response_collects_all_pages(self, mock_create_client): + page1 = { + "sections": [{"id": 1, "name": "Section 1"}], + "_links": {"next": "get_sections/1&offset=250"}, + } + page2 = { + "sections": [{"id": 2, "name": "Section 2"}], + "_links": {"next": None}, + } + mock_client = mock_create_client.return_value + mock_client.send_get.side_effect = [ + APIClientResult(status_code=200, response_text=page1, error_message=""), + APIClientResult(status_code=200, response_text=page2, error_message=""), + ] + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == [ + {"id": 1, "name": "Section 1"}, + {"id": 2, "name": "Section 2"}, + ] + assert mock_client.send_get.call_count == 2 + + @mock.patch("trcli.commands.cmd_get_sections.create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + def test_missing_project_id_exits_nonzero(self): + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_sections", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 2 + assert "project-id" in result.output.lower() + + def test_missing_host_exits_nonzero(self): + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_sections", + "--project-id", + "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/tests/test_cmd_get_suites.py b/tests/test_cmd_get_suites.py new file mode 100644 index 0000000..61fffcf --- /dev/null +++ b/tests/test_cmd_get_suites.py @@ -0,0 +1,91 @@ +import json +from unittest import mock + +import pytest +from click.testing import CliRunner + +from trcli.api.api_client import APIClientResult +from trcli.cli import cli as trcli_cli + + +class TestCmdGetSuites: + """Tests for the get_suites CLI command.""" + + BASE_ARGS = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_suites", + "--project-id", "1", + ] + + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") + def test_happy_path_returns_json(self, mock_create_client): + """Successful API response prints JSON to stdout.""" + suites_data = [{"id": 1, "name": "Suite A"}, {"id": 2, "name": "Suite B"}] + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=200, response_text=suites_data, error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert json.loads(result.output) == suites_data + mock_client.send_get.assert_called_once_with("get_suites/1") + + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") + def test_api_error_message_exits_with_code_1(self, mock_create_client): + """API error message is output with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=-1, response_text="", error_message="Connection refused" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "Connection refused" in result.output + + @mock.patch("trcli.commands.cmd_get_suites.create_api_client") + def test_api_non_200_status_exits_with_code_1(self, mock_create_client): + """Non-200 status code prints error with exit code 1.""" + mock_client = mock_create_client.return_value + mock_client.send_get.return_value = APIClientResult( + status_code=403, response_text="", error_message="" + ) + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS) + + assert result.exit_code == 1 + assert "403" in result.output + + def test_missing_project_id_exits_nonzero(self): + """Missing --project-id triggers a Click error.""" + args = [ + "-h", "https://test.testrail.com", + "-u", "user@example.com", + "-p", "password123", + "get_suites", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code != 0 + + def test_missing_host_exits_nonzero(self): + """Missing -h triggers an error.""" + args = [ + "-u", "user@example.com", + "-p", "password123", + "get_suites", + "--project-id", "1", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "--host is required" in result.output diff --git a/tests/test_data/cli_test_data.py b/tests/test_data/cli_test_data.py index 6cb09ea..aabc602 100644 --- a/tests/test_data/cli_test_data.py +++ b/tests/test_data/cli_test_data.py @@ -71,6 +71,12 @@ " - parse_robot: Robot Framework XML Files\n" " - parse_openapi: OpenAPI YML Files\n" " - add_run: Create a new test run\n" + " - get_plans: List test plans as JSON\n" + " - get_plan: Get a test plan as JSON\n" + " - get_suites: List test suites as JSON\n" + " - get_sections: List test sections as JSON\n" + " - get_cases: List test cases as JSON\n" + " - get_case: Get a test case as JSON\n" " - labels: Manage labels (projects, cases, and tests)\n" " - references: Manage references (cases and runs)\n" ) diff --git a/tests/test_read_command_helpers.py b/tests/test_read_command_helpers.py new file mode 100644 index 0000000..53d36f8 --- /dev/null +++ b/tests/test_read_command_helpers.py @@ -0,0 +1,104 @@ +from unittest import mock + +from trcli.api.api_client import APIClientResult +from trcli.cli import Environment +from trcli.commands.read_command_helpers import ( + build_endpoint, + create_api_client, + fetch_all_pages, + fetch_single_resource, +) + + +def test_build_endpoint_omits_none_values(): + assert build_endpoint("get_cases", 1, suite_id=2, section_id=None) == "get_cases/1&suite_id=2" + + +def test_fetch_single_resource_returns_error_for_non_200(): + client = mock.Mock() + client.send_get.return_value = APIClientResult(status_code=404, response_text="", error_message="") + + data, error = fetch_single_resource(client, "get_case/99") + + assert data is None + assert error == "API returned status 404" + + +def test_fetch_all_pages_collects_paginated_response(): + client = mock.Mock() + client.send_get.side_effect = [ + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 1}], "_links": {"next": "get_cases/1&offset=250"}}, + error_message="", + ), + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 2}], "_links": {"next": None}}, + error_message="", + ), + ] + + data, error = fetch_all_pages(client, "cases", "get_cases/1") + + assert error == "" + assert data == [{"id": 1}, {"id": 2}] + assert client.send_get.call_args_list == [mock.call("get_cases/1"), mock.call("get_cases/1&offset=250")] + + +def test_fetch_all_pages_supports_legacy_list_response(): + client = mock.Mock() + client.send_get.return_value = APIClientResult( + status_code=200, + response_text=[{"id": 1}, {"id": 2}], + error_message="", + ) + + data, error = fetch_all_pages(client, "suites", "get_suites/1") + + assert error == "" + assert data == [{"id": 1}, {"id": 2}] + + +def test_fetch_all_pages_returns_error_from_later_page(): + client = mock.Mock() + client.send_get.side_effect = [ + APIClientResult( + status_code=200, + response_text={"cases": [{"id": 1}], "_links": {"next": "get_cases/1&offset=250"}}, + error_message="", + ), + APIClientResult(status_code=-1, response_text="", error_message="Connection refused"), + ] + + data, error = fetch_all_pages(client, "cases", "get_cases/1") + + assert data is None + assert error == "Connection refused" + + +@mock.patch("trcli.commands.read_command_helpers.APIClient") +def test_create_api_client_passes_connection_options(mock_api_client_cls): + environment = Environment() + environment.host = "https://test.testrail.com" + environment.username = "user@example.com" + environment.password = "password" + environment.key = "api-key" + environment.insecure = True + environment.proxy = "http://proxy.example.com:8080" + environment.proxy_user = "proxy-user:proxy-password" + environment.noproxy = "localhost,127.0.0.1" + environment.timeout = 12.5 + + client = create_api_client(environment) + + mock_api_client_cls.assert_called_once() + _, kwargs = mock_api_client_cls.call_args + assert kwargs["verify"] is False + assert kwargs["proxy"] == "http://proxy.example.com:8080" + assert kwargs["proxy_user"] == "proxy-user:proxy-password" + assert kwargs["noproxy"] == "localhost,127.0.0.1" + assert kwargs["timeout"] == 12.5 + assert client.username == "user@example.com" + assert client.password == "password" + assert client.api_key == "api-key" diff --git a/trcli/__init__.py b/trcli/__init__.py index f38fc51..6b0872c 100644 --- a/trcli/__init__.py +++ b/trcli/__init__.py @@ -1 +1 @@ -__version__ = "1.14.3" +__version__ = "1.15.0" diff --git a/trcli/cli.py b/trcli/cli.py index 2433471..a16b79e 100755 --- a/trcli/cli.py +++ b/trcli/cli.py @@ -313,8 +313,6 @@ class TRCLI(click.MultiCommand): def __init__(self, *args, **kwargs): # Use invoke_without_command=True to be able to print # short tool description when starting without parameters - print(TOOL_VERSION) - # Check for updates (non-blocking) try: update_message = check_for_updates(__version__) diff --git a/trcli/commands/cmd_get_case.py b/trcli/commands/cmd_get_case.py new file mode 100644 index 0000000..532cb38 --- /dev/null +++ b/trcli/commands/cmd_get_case.py @@ -0,0 +1,27 @@ +import click + +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_single_resource + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--case-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Test case ID to fetch.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """Fetch a single test case by ID.""" + environment.cmd = "get_case" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + data, error = fetch_single_resource(client, f"get_case/{environment.case_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_cases.py b/trcli/commands/cmd_get_cases.py new file mode 100644 index 0000000..faf7ff9 --- /dev/null +++ b/trcli/commands/cmd_get_cases.py @@ -0,0 +1,56 @@ +import click + +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.commands.read_command_helpers import ( + build_endpoint, + check_auth, + create_api_client, + emit_json, + fail, + fetch_all_pages, +) + + +@click.command("get_cases", context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list cases for.", +) +@click.option( + "--suite-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Suite ID to list cases for.", +) +@click.option( + "--section-id", + type=click.IntRange(min=1), + default=None, + metavar="", + help="Optional section ID to filter cases.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List test cases for a project and suite.""" + environment.cmd = "get_cases" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + link = build_endpoint( + "get_cases", + environment.project_id, + suite_id=environment.suite_id, + section_id=environment.section_id, + ) + + data, error = fetch_all_pages(client, "cases", link) + if error: + fail(error) + + emit_json(data) diff --git a/trcli/commands/cmd_get_plan.py b/trcli/commands/cmd_get_plan.py new file mode 100644 index 0000000..e91fa58 --- /dev/null +++ b/trcli/commands/cmd_get_plan.py @@ -0,0 +1,27 @@ +import click + +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_single_resource + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--plan-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="ID of the test plan to retrieve.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """Retrieve a single test plan by ID.""" + environment.cmd = "get_plan" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + data, error = fetch_single_resource(client, f"get_plan/{environment.plan_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_plans.py b/trcli/commands/cmd_get_plans.py new file mode 100644 index 0000000..10b68ff --- /dev/null +++ b/trcli/commands/cmd_get_plans.py @@ -0,0 +1,27 @@ +import click + +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_all_pages + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list plans for.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, *args, **kwargs): + """List all test plans for a project.""" + environment.cmd = "get_plans" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + data, error = fetch_all_pages(client, "plans", f"get_plans/{environment.project_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_sections.py b/trcli/commands/cmd_get_sections.py new file mode 100644 index 0000000..0e59fc0 --- /dev/null +++ b/trcli/commands/cmd_get_sections.py @@ -0,0 +1,42 @@ +import click + +from trcli.cli import CONTEXT_SETTINGS, Environment, pass_environment +from trcli.commands.read_command_helpers import ( + build_endpoint, + check_auth, + create_api_client, + emit_json, + fail, + fetch_all_pages, +) + + +@click.command("get_sections", context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list sections for.", +) +@click.option( + "--suite-id", + type=click.IntRange(min=1), + default=None, + metavar="", + help="Optional suite ID to list sections for.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List sections for a project.""" + environment.cmd = "get_sections" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + link = build_endpoint("get_sections", environment.project_id, suite_id=environment.suite_id) + data, error = fetch_all_pages(client, "sections", link) + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/cmd_get_suites.py b/trcli/commands/cmd_get_suites.py new file mode 100644 index 0000000..75738c9 --- /dev/null +++ b/trcli/commands/cmd_get_suites.py @@ -0,0 +1,27 @@ +import click + +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.commands.read_command_helpers import check_auth, create_api_client, emit_json, fail, fetch_all_pages + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option( + "--project-id", + type=click.IntRange(min=1), + required=True, + metavar="", + help="Project ID to list suites for.", +) +@click.pass_context +@pass_environment +def cli(environment: Environment, context: click.Context, **kwargs): + """List all test suites for a project.""" + environment.cmd = "get_suites" + environment.set_parameters(context) + check_auth(environment) + + client = create_api_client(environment) + data, error = fetch_all_pages(client, "suites", f"get_suites/{environment.project_id}") + if error: + fail(error) + emit_json(data) diff --git a/trcli/commands/read_command_helpers.py b/trcli/commands/read_command_helpers.py new file mode 100644 index 0000000..ebd1cb4 --- /dev/null +++ b/trcli/commands/read_command_helpers.py @@ -0,0 +1,101 @@ +import json +import sys +from urllib.parse import urlencode + +import click + +import trcli +from trcli.api.api_client import APIClient, APIClientResult +from trcli.cli import Environment + + +def create_api_client(environment: Environment) -> APIClient: + client_kwargs = { + "verbose_logging_function": environment.vlog, + "logging_function": environment.log, + "verify": not environment.insecure, + "proxy": environment.proxy, + "proxy_user": environment.proxy_user, + "noproxy": environment.noproxy, + "uploader_metadata": APIClient.build_uploader_metadata(version=trcli.__version__), + } + if environment.timeout: + client_kwargs["timeout"] = environment.timeout + + api_client = APIClient(environment.host, **client_kwargs) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + return api_client + + +def check_auth(environment: Environment): + if not environment.host: + click.echo("Error: --host is required.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Error: --username is required.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Error: --password or --key is required.", err=True) + sys.exit(1) + + +def build_endpoint(endpoint: str, resource_id: int, **params) -> str: + query = urlencode({key: value for key, value in params.items() if value is not None}) + link = f"{endpoint}/{resource_id}" + if query: + link = f"{link}&{query}" + return link + + +def response_error(response: APIClientResult) -> str: + if response.error_message: + return response.error_message + if response.status_code != 200: + return f"API returned status {response.status_code}" + return "" + + +def fetch_single_resource(client: APIClient, link: str): + response = client.send_get(link) + error = response_error(response) + if error: + return None, error + return response.response_text, "" + + +def fetch_all_pages(client: APIClient, entity_key: str, initial_link: str): + response = client.send_get(initial_link) + error = response_error(response) + if error: + return None, error + + if isinstance(response.response_text, list): + return response.response_text, "" + + entities = response.response_text.get(entity_key, []) + links = response.response_text.get("_links", {}) + while links.get("next") is not None: + response = client.send_get(links["next"]) + error = response_error(response) + if error: + return None, error + + if isinstance(response.response_text, list): + entities.extend(response.response_text) + break + + entities.extend(response.response_text.get(entity_key, [])) + links = response.response_text.get("_links", {}) + + return entities, "" + + +def emit_json(data): + click.echo(json.dumps(data, indent=2)) + + +def fail(error: str): + click.echo(f"Error: {error}", err=True) + sys.exit(1) diff --git a/trcli/constants.py b/trcli/constants.py index c32fa68..39ed320 100644 --- a/trcli/constants.py +++ b/trcli/constants.py @@ -113,6 +113,12 @@ - parse_robot: Robot Framework XML Files - parse_openapi: OpenAPI YML Files - add_run: Create a new test run + - get_plans: List test plans as JSON + - get_plan: Get a test plan as JSON + - get_suites: List test suites as JSON + - get_sections: List test sections as JSON + - get_cases: List test cases as JSON + - get_case: Get a test case as JSON - labels: Manage labels (projects, cases, and tests) - references: Manage references (cases and runs)"""