diff --git a/tests/conftest.py b/tests/conftest.py index b91980e..c985c21 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,6 @@ import pytest from click.testing import CliRunner from faker import Faker -from httpx import Headers from th_cli.api_lib_autogen import models as api_models from th_cli.api_lib_autogen.api_client import ApiClient, AsyncApis, SyncApis @@ -447,7 +446,7 @@ def sample_test_runner_status() -> api_models.TestRunnerStatus: @pytest.fixture def mock_unexpected_response() -> UnexpectedResponse: """Create a mock UnexpectedResponse exception.""" - return UnexpectedResponse(status_code=404, content=b"Not Found", headers=Headers()) + return UnexpectedResponse(status_code=404, content=b"Not Found") @pytest.fixture diff --git a/tests/test_test_run_execution.py b/tests/test_test_run_execution.py index 3b6141b..5401923 100644 --- a/tests/test_test_run_execution.py +++ b/tests/test_test_run_execution.py @@ -61,7 +61,7 @@ def test_test_run_execution_success_all( assert "Test Run 1" in result.output assert "Test Run 2" in result.output assert "PASSED" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_success_specific_id( @@ -107,7 +107,7 @@ def test_test_run_execution_success_with_pagination( # Assert assert result.exit_code == 0 assert "Test Run 3" in result.output - api.assert_called_once_with(skip=10, limit=5, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=10, limit=5, sort_order="desc", project_id=None, archived=False) def test_test_run_execution_success_json_output( self, @@ -316,7 +316,7 @@ def test_test_run_execution_pagination_parameters( # Assert assert result.exit_code == 0 - api.assert_called_once_with(skip=skip, limit=limit, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=skip, limit=limit, sort_order="desc", project_id=None, archived=False) def test_test_run_execution_error_display( self, @@ -742,7 +742,7 @@ def test_test_run_execution_sort_parameter_asc( # Assert assert result.exit_code == 0 assert "Old Test Run" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="asc", project_id=None) + api.assert_called_once_with(skip=None, limit=None, sort_order="asc", project_id=None, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_sort_parameter_desc_default( @@ -770,7 +770,7 @@ def test_test_run_execution_sort_parameter_desc_default( assert result.exit_code == 0 assert "New Test Run" in result.output assert "Old Test Run" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_sort_parameter_explicit_desc( @@ -798,7 +798,7 @@ def test_test_run_execution_sort_parameter_explicit_desc( assert result.exit_code == 0 assert "New Test Run" in result.output assert "Old Test Run" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_all_flag( @@ -826,7 +826,7 @@ def test_test_run_execution_all_flag( # Assert assert result.exit_code == 0 # When --all is used, limit should be set to 0 - api.assert_called_once_with(skip=None, limit=0, sort_order="desc", project_id=None) + api.assert_called_once_with(skip=None, limit=0, sort_order="desc", project_id=None, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_all_with_limit_fails( @@ -885,7 +885,7 @@ def test_test_run_execution_with_project_id( # Assert assert result.exit_code == 0 assert "Project 5 Test Run" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=5) + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=5, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_with_project_id_short_form( @@ -909,7 +909,7 @@ def test_test_run_execution_with_project_id_short_form( # Assert assert result.exit_code == 0 assert "Project 10 Test Run" in result.output - api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=10) + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=10, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_with_project_id_and_pagination( @@ -933,7 +933,7 @@ def test_test_run_execution_with_project_id_and_pagination( # Assert assert result.exit_code == 0 assert "Filtered Paginated Test Run" in result.output - api.assert_called_once_with(skip=5, limit=10, sort_order="desc", project_id=7) + api.assert_called_once_with(skip=5, limit=10, sort_order="desc", project_id=7, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_with_project_id_and_sort( @@ -954,7 +954,7 @@ def test_test_run_execution_with_project_id_and_sort( # Assert assert result.exit_code == 0 - api.assert_called_once_with(skip=None, limit=None, sort_order="asc", project_id=3) + api.assert_called_once_with(skip=None, limit=None, sort_order="asc", project_id=3, archived=False) mock_api_client.close.assert_called_once() def test_test_run_execution_project_id_with_log_fails( @@ -969,3 +969,67 @@ def test_test_run_execution_project_id_with_log_fails( assert result.exit_code != 0 assert "--project-id" in result.output assert "not applicable" in result.output or "Error" in result.output + + def test_test_run_execution_archived_flag( + self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock + ) -> None: + """Test that --archived filters for archived test run executions only.""" + # Arrange + test_executions = [ + api_models.TestRunExecution( + id=1, title="Archived Run", state=api_models.TestStateEnum.passed, project_id=1 + ), + ] + api = mock_sync_apis.test_run_executions_api.read_test_run_executions_api_v1_test_run_executions__get + api.return_value = test_executions + + with patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client): + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + # Act + result = cli_runner.invoke(test_run_execution, ["--archived"]) + + # Assert + assert result.exit_code == 0 + assert "Archived Run" in result.output + assert "Archived: yes" in result.output + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=None, archived=True) + mock_api_client.close.assert_called_once() + + def test_test_run_execution_archived_flag_with_project_id( + self, cli_runner: CliRunner, mock_sync_apis: Mock, mock_api_client: Mock + ) -> None: + """Test that --archived can be combined with --project-id.""" + # Arrange + api = mock_sync_apis.test_run_executions_api.read_test_run_executions_api_v1_test_run_executions__get + api.return_value = [] + + with patch("th_cli.commands.test_run_execution.get_client", return_value=mock_api_client): + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + # Act + result = cli_runner.invoke(test_run_execution, ["--archived", "--project-id", "5"]) + + # Assert + assert result.exit_code == 0 + api.assert_called_once_with(skip=None, limit=None, sort_order="desc", project_id=5, archived=True) + + def test_test_run_execution_archived_with_id_fails( + self, + cli_runner: CliRunner, + ) -> None: + """Test that --archived cannot be used with --id.""" + # Act + result = cli_runner.invoke(test_run_execution, ["--id", "123", "--archived"]) + + # Assert + assert result.exit_code != 0 + assert "--archived" in result.output + assert "not applicable" in result.output or "Error" in result.output + + def test_test_run_execution_help_shows_archived_option(self, cli_runner: CliRunner) -> None: + """Test that the help message includes the --archived option.""" + # Act + result = cli_runner.invoke(test_run_execution, ["--help"]) + + # Assert + assert result.exit_code == 0 + assert "--archived" in result.output diff --git a/tests/test_test_run_execution_archive_unarchive.py b/tests/test_test_run_execution_archive_unarchive.py new file mode 100644 index 0000000..7265b0e --- /dev/null +++ b/tests/test_test_run_execution_archive_unarchive.py @@ -0,0 +1,118 @@ +# +# Copyright (c) 2026 Project CHIP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Tests for the `test-run-execution archive` and `test-run-execution unarchive` commands.""" + +from unittest.mock import Mock, patch + +import pytest +from click.testing import CliRunner + +from th_cli.api_lib_autogen.exceptions import UnexpectedResponse +from th_cli.commands.test_run_execution import test_run_execution + + +@pytest.mark.unit +@pytest.mark.cli +class TestArchiveTestRunExecutionCommand: + """Test cases for the `test-run-execution archive` command.""" + + def test_archive_success(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """Test successful archiving.""" + api = mock_sync_apis.test_run_executions_api + api.archive_api_v1_test_run_executions__id__archive_post.return_value = None + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["archive", "--id", "1"]) + + assert result.exit_code == 0 + assert "Test run execution 1 was archived." in result.output + api.archive_api_v1_test_run_executions__id__archive_post.assert_called_once_with(id=1) + + def test_archive_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """Test that an API error is surfaced to the user.""" + api = mock_sync_apis.test_run_executions_api + api.archive_api_v1_test_run_executions__id__archive_post.side_effect = UnexpectedResponse( + status_code=404, + content=b"Not Found", + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["archive", "--id", "1"]) + + assert result.exit_code == 1 + assert "Error: Failed to archive test run execution ID '1' (Status: 404) - Not Found" in result.output + + def test_archive_requires_id(self, cli_runner: CliRunner) -> None: + """The --id parameter is required.""" + result = cli_runner.invoke(test_run_execution, ["archive"]) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "--id" in result.output + + def test_archive_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the archive command.""" + result = cli_runner.invoke(test_run_execution, ["archive", "--help"]) + + assert result.exit_code == 0 + assert "archive" in result.output + assert "--id" in result.output + + +@pytest.mark.unit +@pytest.mark.cli +class TestUnarchiveTestRunExecutionCommand: + """Test cases for the `test-run-execution unarchive` command.""" + + def test_unarchive_success(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """Test successful unarchiving.""" + api = mock_sync_apis.test_run_executions_api + api.unarchive_api_v1_test_run_executions__id__unarchive_post.return_value = None + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["unarchive", "--id", "1"]) + + assert result.exit_code == 0 + assert "Test run execution 1 was unarchived." in result.output + api.unarchive_api_v1_test_run_executions__id__unarchive_post.assert_called_once_with(id=1) + + def test_unarchive_api_error(self, cli_runner: CliRunner, mock_sync_apis: Mock) -> None: + """Test that an API error is surfaced to the user.""" + api = mock_sync_apis.test_run_executions_api + api.unarchive_api_v1_test_run_executions__id__unarchive_post.side_effect = UnexpectedResponse( + status_code=404, + content=b"Not Found", + ) + + with patch("th_cli.commands.test_run_execution.SyncApis", return_value=mock_sync_apis): + result = cli_runner.invoke(test_run_execution, ["unarchive", "--id", "1"]) + + assert result.exit_code == 1 + assert "Error: Failed to unarchive test run execution ID '1' (Status: 404) - Not Found" in result.output + + def test_unarchive_requires_id(self, cli_runner: CliRunner) -> None: + """The --id parameter is required.""" + result = cli_runner.invoke(test_run_execution, ["unarchive"]) + + assert result.exit_code != 0 + assert "Missing option" in result.output or "--id" in result.output + + def test_unarchive_help_message(self, cli_runner: CliRunner) -> None: + """Test the help message for the unarchive command.""" + result = cli_runner.invoke(test_run_execution, ["unarchive", "--help"]) + + assert result.exit_code == 0 + assert "unarchive" in result.output + assert "--id" in result.output diff --git a/th_cli/commands/test_run_execution.py b/th_cli/commands/test_run_execution.py index 8e7e06e..2cb710b 100644 --- a/th_cli/commands/test_run_execution.py +++ b/th_cli/commands/test_run_execution.py @@ -116,6 +116,12 @@ default=False, help=colorize_help("Fetch all test run executions with screen pagination (cannot be used with --limit)"), ), + click.option( + "--archived", + is_flag=True, + default=False, + help=colorize_help("List only archived test run executions (not applicable with --id)"), + ), ] @@ -149,6 +155,7 @@ def test_run_execution( log: bool, json: bool, all: bool, + archived: bool, ) -> None: """Manage test run executions - list history or fetch logs""" if ctx.invoked_subcommand is None: @@ -170,6 +177,7 @@ def list_executions( log: bool, json: bool, all: bool, + archived: bool, ) -> None: """Manage test run executions - list history or fetch logs""" @@ -194,6 +202,9 @@ def list_executions( if log and all: raise click.ClickException("--all option is not applicable when fetching logs (--log)") + if archived and id is not None: + raise click.ClickException("--archived is not applicable when fetching a specific execution via --id") + try: with closing(get_client()) as client: sync_apis = SyncApis(client) @@ -203,7 +214,7 @@ def list_executions( elif id is not None: __test_run_execution_by_id(sync_apis, id, json) else: - __test_run_execution_batch(sync_apis, json, skip, limit, sort, all, project_id) + __test_run_execution_batch(sync_apis, json, skip, limit, sort, all, project_id, archived) except CLIError: raise # Re-raise CLI Errors as-is @@ -360,6 +371,70 @@ def __rename_test_run_execution(sync_apis: SyncApis, id: int, name: str) -> None handle_api_error(e, f"rename test run execution ID '{id}'") +@test_run_execution.command( + name="archive", + short_help=colorize_help("Archive a test run execution"), + help=colorize_cmd_help("archive", "Archive a test run execution"), +) +@click.option( + "--id", + "-i", + required=True, + type=int, + help=colorize_help("Test Run Execution ID to archive"), +) +def archive(id: int) -> None: + """Archive a test run execution""" + try: + with closing(get_client()) as client: + sync_apis = SyncApis(client) + __archive_test_run_execution(sync_apis, id) + + except CLIError: + raise # Re-raise CLI Errors as-is + + +def __archive_test_run_execution(sync_apis: SyncApis, id: int) -> None: + try: + test_run_execution_api = sync_apis.test_run_executions_api + test_run_execution_api.archive_api_v1_test_run_executions__id__archive_post(id=id) + click.echo(colorize_success(f"Test run execution {id} was archived.")) + except UnexpectedResponse as e: + handle_api_error(e, f"archive test run execution ID '{id}'") + + +@test_run_execution.command( + name="unarchive", + short_help=colorize_help("Unarchive a test run execution"), + help=colorize_cmd_help("unarchive", "Unarchive a test run execution"), +) +@click.option( + "--id", + "-i", + required=True, + type=int, + help=colorize_help("Test Run Execution ID to unarchive"), +) +def unarchive(id: int) -> None: + """Unarchive a test run execution""" + try: + with closing(get_client()) as client: + sync_apis = SyncApis(client) + __unarchive_test_run_execution(sync_apis, id) + + except CLIError: + raise # Re-raise CLI Errors as-is + + +def __unarchive_test_run_execution(sync_apis: SyncApis, id: int) -> None: + try: + test_run_execution_api = sync_apis.test_run_executions_api + test_run_execution_api.unarchive_api_v1_test_run_executions__id__unarchive_post(id=id) + click.echo(colorize_success(f"Test run execution {id} was unarchived.")) + except UnexpectedResponse as e: + handle_api_error(e, f"unarchive test run execution ID '{id}'") + + @test_run_execution.command( name="repeat", short_help=colorize_help("Repeat a test run execution"), @@ -483,7 +558,12 @@ def __test_run_execution_by_id(sync_apis: SyncApis, id: int, json: bool) -> None def __print_filters_info( - skip: int | None, limit: int | None, sort_order: str, show_all: bool = False, project_id: int | None = None + skip: int | None, + limit: int | None, + sort_order: str, + show_all: bool = False, + project_id: int | None = None, + archived: bool = False, ) -> str: """Generate comprehensive filter and pagination information text.""" filters = [] @@ -492,6 +572,10 @@ def __print_filters_info( if project_id is not None: filters.append(f"Project ID: {project_id}") + # Archived filter + if archived: + filters.append("Archived: yes") + # Order information (more descriptive than just "Sort: DESC") if sort_order == "desc": filters.append("Order: newest first") @@ -525,6 +609,7 @@ def __test_run_execution_batch( sort_order: str = "desc", show_all: bool = False, project_id: int | None = None, + archived: bool = False, ) -> None: try: test_run_execution_api = sync_apis.test_run_executions_api @@ -533,7 +618,7 @@ def __test_run_execution_batch( effective_limit = 0 if show_all else limit test_run_executions = test_run_execution_api.read_test_run_executions_api_v1_test_run_executions__get( - skip=skip, limit=effective_limit, sort_order=sort_order, project_id=project_id + skip=skip, limit=effective_limit, sort_order=sort_order, project_id=project_id, archived=archived ) if json: @@ -544,7 +629,9 @@ def __test_run_execution_batch( output_lines = [] output_lines.append( click.style( - __print_filters_info(skip, limit, sort_order, show_all, project_id), fg="cyan", bold=True + __print_filters_info(skip, limit, sort_order, show_all, project_id, archived), + fg="cyan", + bold=True, ) ) output_lines.append("") # Empty line @@ -579,7 +666,9 @@ def __test_run_execution_batch( # Regular output with filter info click.echo( click.style( - __print_filters_info(skip, limit, sort_order, show_all, project_id), fg="cyan", bold=True + __print_filters_info(skip, limit, sort_order, show_all, project_id, archived), + fg="cyan", + bold=True, ) ) click.echo() # Add empty line for readability