From 13c2e6c909a820ce275ed2b14b14c313a1789a4a Mon Sep 17 00:00:00 2001 From: "Chris.Weber" Date: Tue, 10 Mar 2026 00:18:04 -0500 Subject: [PATCH] feat: add read commands for plans, suites, and cases (#401) --- CHANGELOG.MD | 5 + README.md | 73 +++++++++++++++ tests/test_api_request_handler.py | 57 ++++++++++++ tests/test_cmd_get_case.py | 105 +++++++++++++++++++++ tests/test_cmd_get_cases.py | 149 ++++++++++++++++++++++++++++++ tests/test_cmd_get_plan.py | 90 ++++++++++++++++++ tests/test_cmd_get_plans.py | 105 +++++++++++++++++++++ tests/test_cmd_get_suites.py | 91 ++++++++++++++++++ trcli/api/api_request_handler.py | 21 +++++ trcli/commands/cmd_get_case.py | 68 ++++++++++++++ trcli/commands/cmd_get_cases.py | 118 +++++++++++++++++++++++ trcli/commands/cmd_get_plan.py | 65 +++++++++++++ trcli/commands/cmd_get_plans.py | 58 ++++++++++++ trcli/commands/cmd_get_suites.py | 68 ++++++++++++++ 14 files changed, 1073 insertions(+) create mode 100644 tests/test_cmd_get_case.py create mode 100644 tests/test_cmd_get_cases.py create mode 100644 tests/test_cmd_get_plan.py create mode 100644 tests/test_cmd_get_plans.py create mode 100644 tests/test_cmd_get_suites.py create mode 100644 trcli/commands/cmd_get_case.py create mode 100644 trcli/commands/cmd_get_cases.py create mode 100644 trcli/commands/cmd_get_plan.py create mode 100644 trcli/commands/cmd_get_plans.py create mode 100644 trcli/commands/cmd_get_suites.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 369c756..e1ab990 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,6 +6,11 @@ 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. +## [Unreleased] + +### Added + - **New Read Commands:** `get_plans`, `get_plan`, `get_suites`, `get_cases`, and `get_case` for retrieving test plans, suites, and cases from TestRail as JSON output. Supports environment variables and all standard authentication flags. (Issue #401) + ## [1.13.3] _released 03-05-2026 diff --git a/README.md b/README.md index a2cb4f7..c8dc841 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) @@ -1837,6 +1838,78 @@ 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, 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_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 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_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_api_request_handler.py b/tests/test_api_request_handler.py index 88877e6..c97be7f 100644 --- a/tests/test_api_request_handler.py +++ b/tests/test_api_request_handler.py @@ -1267,3 +1267,60 @@ def test_cache_stats(self, api_request_handler: ApiRequestHandler, requests_mock assert stats["miss_count"] == 1 assert stats["hit_count"] == 1 assert stats["hit_rate"] == 50.0 # 1 hit out of 2 total requests + + @pytest.mark.api_handler + def test_get_plans_returns_paginated_list(self, api_request_handler: ApiRequestHandler, requests_mock): + project_id = 1 + mocked_response = { + "offset": 0, + "limit": 250, + "size": 2, + "_links": {"next": None, "prev": None}, + "plans": [ + {"id": 10, "name": "Plan A"}, + {"id": 11, "name": "Plan B"}, + ], + } + requests_mock.get(create_url(f"get_plans/{project_id}"), json=mocked_response) + plans, error = api_request_handler.get_plans(project_id) + assert plans == [{"id": 10, "name": "Plan A"}, {"id": 11, "name": "Plan B"}] + assert error == "" + + @pytest.mark.api_handler + def test_get_plans_cache_hit(self, api_request_handler: ApiRequestHandler, requests_mock): + project_id = 1 + mocked_response = { + "offset": 0, + "limit": 250, + "size": 1, + "_links": {"next": None, "prev": None}, + "plans": [{"id": 10, "name": "Plan A"}], + } + requests_mock.get(create_url(f"get_plans/{project_id}"), json=mocked_response) + # First call — cache miss + api_request_handler.get_plans(project_id) + # Second call — should be served from cache + plans, error = api_request_handler.get_plans(project_id) + assert plans == [{"id": 10, "name": "Plan A"}] + assert error == "" + # Verify only one HTTP request was made (second was cached) + assert requests_mock.call_count == 1 + # Verify correct cache key was used + stats = api_request_handler._cache.get_stats() + assert stats["hit_count"] == 1 + + @pytest.mark.api_handler + def test_get_plan_returns_single_plan(self, api_request_handler: ApiRequestHandler, requests_mock): + plan_id = 42 + plan_dict = {"id": 42, "name": "Release Plan", "entries": []} + requests_mock.get(create_url(f"get_plan/{plan_id}"), json=plan_dict) + result, error = api_request_handler.get_plan(plan_id) + assert result == plan_dict + assert error == "" + + @pytest.mark.api_handler + def test_get_plan_returns_error_message(self, api_request_handler: ApiRequestHandler, requests_mock): + plan_id = 999 + requests_mock.get(create_url(f"get_plan/{plan_id}"), status_code=400, json={"error": "Field :plan_id is not a valid test plan."}) + result, error = api_request_handler.get_plan(plan_id) + assert error != "" diff --git a/tests/test_cmd_get_case.py b/tests/test_cmd_get_case.py new file mode 100644 index 0000000..fddc0ee --- /dev/null +++ b/tests/test_cmd_get_case.py @@ -0,0 +1,105 @@ +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 '"My Test Case"' in result.output + assert '"id": 99' in result.output + 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..e950579 --- /dev/null +++ b/tests/test_cmd_get_cases.py @@ -0,0 +1,149 @@ +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 '"Login test"' in result.output + assert '"Logout test"' in result.output + + @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 '"Case 1"' in result.output + assert '"Case 2"' in result.output + 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..3fd6b6a --- /dev/null +++ b/tests/test_cmd_get_plan.py @@ -0,0 +1,90 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +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.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plan.APIClient") + def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + """Successful API response prints JSON to stdout.""" + plan_data = {"id": 42, "name": "My Plan", "entries": []} + mock_handler = mock_handler_cls.return_value + mock_handler.get_plan.return_value = (plan_data, None) + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"My Plan"' in result.output + assert '"id": 42' in result.output + mock_handler.get_plan.assert_called_once() + + @mock.patch("trcli.commands.cmd_get_plan.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plan.APIClient") + def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + """API error is output with exit code 1.""" + mock_handler = mock_handler_cls.return_value + mock_handler.get_plan.return_value = (None, "Plan not found") + mock_api_client_cls.build_uploader_metadata.return_value = {} + + 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 "server address" 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" in result.output or "key" 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..8e0d949 --- /dev/null +++ b/tests/test_cmd_get_plans.py @@ -0,0 +1,105 @@ +from unittest import mock + +import pytest +from click.testing import CliRunner + +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", + "--project-id", "1", + "get_plans", + ] + + @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plans.APIClient") + def test_happy_path_returns_json(self, mock_api_client_cls, mock_handler_cls): + """Successful API response prints JSON to stdout.""" + plans_data = [{"id": 1, "name": "Plan A"}, {"id": 2, "name": "Plan B"}] + mock_handler = mock_handler_cls.return_value + mock_handler.get_plans.return_value = (plans_data, None) + mock_api_client_cls.build_uploader_metadata.return_value = {} + + runner = CliRunner() + result = runner.invoke(trcli_cli, self.BASE_ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert '"Plan A"' in result.output + assert '"Plan B"' in result.output + mock_handler.get_plans.assert_called_once() + + @mock.patch("trcli.commands.cmd_get_plans.ApiRequestHandler") + @mock.patch("trcli.commands.cmd_get_plans.APIClient") + def test_api_error_exits_with_code_1(self, mock_api_client_cls, mock_handler_cls): + """API error message is output with exit code 1.""" + mock_handler = mock_handler_cls.return_value + mock_handler.get_plans.return_value = (None, "Could not connect to TestRail") + mock_api_client_cls.build_uploader_metadata.return_value = {} + + 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 == 1 + 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", + "--project-id", "1", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "server address" in result.output + + def test_missing_username_exits_nonzero(self): + """Missing -u triggers an error.""" + args = [ + "-h", "https://test.testrail.com", + "-p", "password123", + "--project-id", "1", + "get_plans", + ] + 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", + "--project-id", "1", + "get_plans", + ] + runner = CliRunner() + result = runner.invoke(trcli_cli, args) + + assert result.exit_code == 1 + assert "password" in result.output or "key" 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..107ccbc --- /dev/null +++ b/tests/test_cmd_get_suites.py @@ -0,0 +1,91 @@ +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 '"Suite A"' in result.output + assert '"Suite B"' in result.output + 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/trcli/api/api_request_handler.py b/trcli/api/api_request_handler.py index e1337b8..4b5dfcd 100644 --- a/trcli/api/api_request_handler.py +++ b/trcli/api/api_request_handler.py @@ -184,6 +184,15 @@ def resolve_suite_id_using_name(self, project_id: int) -> Tuple[int, str]: def get_suite_ids(self, project_id: int) -> Tuple[List[int], str]: return self.suite_handler.get_suite_ids(project_id) + def get_plans(self, project_id: int) -> Tuple[List[dict], str]: + """Get all plans for a project (paginated).""" + return self.__get_all_plans(project_id) + + def get_plan(self, plan_id: int) -> Tuple[dict, str]: + """Get a single plan by ID.""" + response = self.client.send_get(f"get_plan/{plan_id}") + return response.response_text, response.error_message + def add_suites(self, project_id: int) -> Tuple[List[Dict], str]: return self.suite_handler.add_suites(project_id, verify_callback=self.response_verifier.verify_returned_data) @@ -455,6 +464,18 @@ def fetch(): return self._cache.get_or_fetch(cache_key, fetch, params) + def __get_all_plans(self, project_id) -> Tuple[List[dict], str]: + """ + Get all plans from all pages (with caching) + """ + cache_key = f"get_plans/{project_id}" + params = (project_id,) + + def fetch(): + return self.__get_all_entities("plans", f"get_plans/{project_id}", entities=[]) + + return self._cache.get_or_fetch(cache_key, fetch, params) + def __get_all_entities(self, entity: str, link=None, entities=[]) -> Tuple[List[Dict], str]: """ Get all entities from all pages if number of entities is too big to return in single response. diff --git a/trcli/commands/cmd_get_case.py b/trcli/commands/cmd_get_case.py new file mode 100644 index 0000000..fed3d10 --- /dev/null +++ b/trcli/commands/cmd_get_case.py @@ -0,0 +1,68 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + 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 + + +@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) + + 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) + + client = _create_api_client(environment) + response = client.send_get(f"get_case/{environment.case_id}") + + if response.error_message: + click.echo(f"Error: {response.error_message}", err=True) + sys.exit(1) + + if response.status_code != 200: + click.echo(f"Error: API returned status {response.status_code}", err=True) + sys.exit(1) + + click.echo(json.dumps(response.response_text, indent=2)) diff --git a/trcli/commands/cmd_get_cases.py b/trcli/commands/cmd_get_cases.py new file mode 100644 index 0000000..307abc6 --- /dev/null +++ b/trcli/commands/cmd_get_cases.py @@ -0,0 +1,118 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + 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): + """Validate required auth parameters are present.""" + 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 _get_all_pages(client: APIClient, entity_key: str, initial_link: str): + """Fetch all pages for a paginated endpoint. + + Returns (data_list, error_message). + """ + response = client.send_get(initial_link) + if response.error_message: + return None, response.error_message + if response.status_code != 200: + return None, f"API returned status {response.status_code}" + + # Non-paginated (legacy): response is a plain list + if isinstance(response.response_text, list): + return response.response_text, None + + # Paginated: response is a dict with entity key and _links + entities = response.response_text.get(entity_key, []) + links = response.response_text.get("_links", {}) + while links.get("next") is not None: + next_link = links["next"] + response = client.send_get(next_link) + if response.error_message: + return None, response.error_message + 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, None + + +@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 = f"get_cases/{environment.project_id}&suite_id={environment.suite_id}" + if environment.section_id: + link += f"§ion_id={environment.section_id}" + + data, error = _get_all_pages(client, "cases", link) + if error: + click.echo(f"Error: {error}", err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_plan.py b/trcli/commands/cmd_get_plan.py new file mode 100644 index 0000000..029747f --- /dev/null +++ b/trcli/commands/cmd_get_plan.py @@ -0,0 +1,65 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.api.api_request_handler import ApiRequestHandler +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +@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) + + if not environment.host: + click.echo("Please provide a TestRail server address with the -h argument.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Please provide a valid TestRail username using the -u argument.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) + sys.exit(1) + if not environment.plan_id: + click.echo("Please provide a plan ID using the --plan-id argument.", err=True) + sys.exit(1) + + uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) + api_client = APIClient( + host_name=environment.host, + verify=not environment.insecure, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + uploader_metadata=uploader_metadata, + ) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + minimal_suite = TestRailSuite(name="Plan Query", testsections=[]) + api_request_handler = ApiRequestHandler( + environment=environment, + api_client=api_client, + suites_data=minimal_suite, + ) + + data, error_message = api_request_handler.get_plan(environment.plan_id) + if error_message: + click.echo(error_message, err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_plans.py b/trcli/commands/cmd_get_plans.py new file mode 100644 index 0000000..76d6dd2 --- /dev/null +++ b/trcli/commands/cmd_get_plans.py @@ -0,0 +1,58 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.api.api_request_handler import ApiRequestHandler +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment +from trcli.data_classes.dataclass_testrail import TestRailSuite + + +@click.command(context_settings=CONTEXT_SETTINGS) +@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) + + if not environment.host: + click.echo("Please provide a TestRail server address with the -h argument.", err=True) + sys.exit(1) + if not environment.username: + click.echo("Please provide a valid TestRail username using the -u argument.", err=True) + sys.exit(1) + if not environment.password and not environment.key: + click.echo("Please provide either a password using the -p argument or an API key using the -k argument.", err=True) + sys.exit(1) + if not environment.project_id: + click.echo("Please provide a project ID using the --project-id argument.", err=True) + sys.exit(1) + + uploader_metadata = APIClient.build_uploader_metadata(version=trcli.__version__) + api_client = APIClient( + host_name=environment.host, + verify=not environment.insecure, + verbose_logging_function=environment.vlog, + logging_function=environment.log, + uploader_metadata=uploader_metadata, + ) + api_client.username = environment.username + api_client.password = environment.password + api_client.api_key = environment.key + + minimal_suite = TestRailSuite(name="Plans Query", testsections=[]) + api_request_handler = ApiRequestHandler( + environment=environment, + api_client=api_client, + suites_data=minimal_suite, + ) + + data, error_message = api_request_handler.get_plans(environment.project_id) + if error_message: + click.echo(error_message, err=True) + sys.exit(1) + + click.echo(json.dumps(data, indent=2)) diff --git a/trcli/commands/cmd_get_suites.py b/trcli/commands/cmd_get_suites.py new file mode 100644 index 0000000..90ed303 --- /dev/null +++ b/trcli/commands/cmd_get_suites.py @@ -0,0 +1,68 @@ +import json +import sys + +import click + +import trcli +from trcli.api.api_client import APIClient +from trcli.cli import pass_environment, CONTEXT_SETTINGS, Environment + + +def _create_api_client(environment: Environment) -> APIClient: + """Create an APIClient from the environment settings.""" + 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 + + +@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) + + 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) + + client = _create_api_client(environment) + response = client.send_get(f"get_suites/{environment.project_id}") + + if response.error_message: + click.echo(f"Error: {response.error_message}", err=True) + sys.exit(1) + + if response.status_code != 200: + click.echo(f"Error: API returned status {response.status_code}", err=True) + sys.exit(1) + + click.echo(json.dumps(response.response_text, indent=2))