"""Step definitions for project_cli_coverage_boost.feature. Exercises uncovered error-handling branches in ``cleveragents.cli.commands.project`` using ``typer.testing.CliRunner`` with ``unittest.mock.patch`` to mock DI container helpers. Targets lines: 81, 84, 89, 91-92, 565-566, 574-575, 643-645, 722, 725-726, 730-732, 772-774, 908-910, 914-916, 919-920. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from unittest.mock import MagicMock, patch from behave import given, then, when from typer.testing import CliRunner from cleveragents.cli.commands.project import app from cleveragents.core.exceptions import DatabaseError, NotFoundError # mix_stderr defaults to True in typer CliRunner, which merges stderr into # result.output so we can assert on err_console output. runner = CliRunner() # Patch targets - these replace the module-level helper functions _PATCH_PROJECT_REPO = "cleveragents.cli.commands.project._get_namespaced_project_repo" _PATCH_LINK_REPO = "cleveragents.cli.commands.project._get_resource_link_repo" _PATCH_REGISTRY_SVC = "cleveragents.cli.commands.project._get_resource_registry_service" _PATCH_STORE_EXTRAS = "cleveragents.cli.commands.project._store_project_extras" # For patching get_container inside the lazy-import helpers _PATCH_CONTAINER = "cleveragents.application.container.get_container" # --------------------------------------------------------------------------- # Mock factory helpers # --------------------------------------------------------------------------- def _make_mock_project( namespaced_name: str = "local/test-proj", namespace: str = "local", name: str = "test-proj", description: str | None = "A test project", linked_resources: list | None = None, ) -> MagicMock: """Create a mock project with the required attributes.""" proj = MagicMock() proj.namespaced_name = namespaced_name proj.namespace = namespace proj.name = name proj.description = description proj.linked_resources = linked_resources or [] proj.created_at = datetime(2025, 1, 1, tzinfo=UTC) proj.updated_at = datetime(2025, 1, 2, tzinfo=UTC) return proj def _make_mock_resource( resource_id: str = "res-001", name: str = "local/some-res" ) -> MagicMock: """Create a mock resource with required attributes.""" res = MagicMock() res.resource_id = resource_id res.name = name return res def _make_mock_link( link_id: str = "link-001", resource_id: str = "res-001" ) -> MagicMock: """Create a mock link with required attributes.""" link = MagicMock() link.link_id = link_id link.resource_id = resource_id link.project_read_only = False link.alias = None link.linked_at = datetime(2025, 1, 3, tzinfo=UTC) return link # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("the project CLI coverage mocks are prepared") def step_prepare_coverage_mocks(context: Any) -> None: """Initialize mock holders on context for each scenario.""" context.cov_project_repo = MagicMock() context.cov_link_repo = MagicMock() context.cov_registry_svc = MagicMock() context.cov_result = None # --------------------------------------------------------------------------- # Helper function coverage steps (lines 79-84, 89, 91-92) # # These call the actual _get_resource_link_repo / _get_resource_registry_service # functions which do a lazy ``from cleveragents.application.container import # get_container`` inside their body, so we patch get_container at its source. # --------------------------------------------------------------------------- @when("I call the real _get_resource_link_repo helper") def step_call_real_get_link_repo(context: Any) -> None: """Call _get_resource_link_repo with the container patched.""" mock_container = MagicMock() mock_container.project_resource_link_repo.return_value = MagicMock() with patch(_PATCH_CONTAINER, return_value=mock_container): from cleveragents.cli.commands.project import _get_resource_link_repo context.cov_helper_result = _get_resource_link_repo() @then("the coverage link repo helper should return successfully") def step_check_link_repo_result(context: Any) -> None: assert context.cov_helper_result is not None @when("I call the real _get_resource_registry_service helper") def step_call_real_get_registry_service(context: Any) -> None: """Call _get_resource_registry_service with the container patched.""" mock_container = MagicMock() mock_container.resource_registry_service.return_value = MagicMock() with patch(_PATCH_CONTAINER, return_value=mock_container): from cleveragents.cli.commands.project import _get_resource_registry_service context.cov_helper_result = _get_resource_registry_service() @then("the coverage registry service helper should return successfully") def step_check_registry_service_result(context: Any) -> None: assert context.cov_helper_result is not None # --------------------------------------------------------------------------- # create command: resource linking raises NotFoundError (lines 565-566) # --------------------------------------------------------------------------- @given("the project repo mock is configured for successful create") def step_project_repo_successful_create(context: Any) -> None: """Configure project repo so create succeeds and get returns a project.""" mock_proj = _make_mock_project() context.cov_project_repo.create.return_value = None context.cov_project_repo.get.return_value = mock_proj @given("the link repo mock raises NotFoundError on create_link") def step_link_repo_raises_not_found(context: Any) -> None: context.cov_registry_svc.show_resource.side_effect = NotFoundError( "Resource not found" ) @when('I invoke the CLI create command with name "{name}" and resource "{res}"') def step_invoke_create_with_resource(context: Any, name: str, res: str) -> None: with ( patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo), patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo), patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc), patch(_PATCH_STORE_EXTRAS), ): context.cov_result = runner.invoke(app, ["create", name, "--resource", res]) # --------------------------------------------------------------------------- # create command: resource linking raises DatabaseError (lines 565-566) # --------------------------------------------------------------------------- @given("the link repo mock raises DatabaseError on create_link") def step_link_repo_raises_db_error_on_create_link(context: Any) -> None: mock_res = _make_mock_resource() context.cov_registry_svc.show_resource.return_value = mock_res context.cov_link_repo.create_link.side_effect = DatabaseError("DB link failure") # --------------------------------------------------------------------------- # create command: re-fetch raises generic Exception (lines 574-575) # --------------------------------------------------------------------------- @given("the project repo mock is configured for create but get raises Exception") def step_project_repo_create_ok_get_fails(context: Any) -> None: context.cov_project_repo.create.return_value = None context.cov_project_repo.get.side_effect = Exception("re-fetch failed") @when('I invoke the CLI create command with name "{name}" without resources') def step_invoke_create_no_resources(context: Any, name: str) -> None: with ( patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo), patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo), patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc), patch(_PATCH_STORE_EXTRAS), ): context.cov_result = runner.invoke(app, ["create", name]) # --------------------------------------------------------------------------- # link-resource command: create_link raises DatabaseError (lines 643-645) # --------------------------------------------------------------------------- @given("the project repo mock returns a valid project for get") def step_project_repo_get_returns_project(context: Any) -> None: mock_proj = _make_mock_project() context.cov_project_repo.get.return_value = mock_proj @given("the registry mock returns a valid resource") def step_registry_returns_valid_resource(context: Any) -> None: mock_res = _make_mock_resource() context.cov_registry_svc.show_resource.return_value = mock_res @given("the link repo mock raises DatabaseError on create_link for link-resource") def step_link_repo_raises_db_error_for_link_cmd(context: Any) -> None: context.cov_link_repo.create_link.side_effect = DatabaseError( "DB create_link failure" ) @when('I invoke the CLI link-resource command for project "{proj}" resource "{res}"') def step_invoke_link_resource_cmd(context: Any, proj: str, res: str) -> None: with ( patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo), patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo), patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc), ): context.cov_result = runner.invoke(app, ["link-resource", proj, res]) # --------------------------------------------------------------------------- # unlink-resource command: user declines (lines 722, 725-726) # --------------------------------------------------------------------------- @given("the link repo mock returns a matching link for unlink") def step_link_repo_returns_matching_link(context: Any) -> None: mock_link = _make_mock_link(resource_id="res-001") context.cov_link_repo.list_links.return_value = [mock_link] @when("I invoke the CLI unlink-resource command without yes and user declines") def step_invoke_unlink_user_declines(context: Any) -> None: with ( patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo), patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo), patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc), ): context.cov_result = runner.invoke( app, ["unlink-resource", "local/test-proj", "local/some-res"], input="n\n", ) # --------------------------------------------------------------------------- # unlink-resource command: remove_link raises DatabaseError (lines 730-732) # --------------------------------------------------------------------------- @given("the link repo mock raises DatabaseError on remove_link") def step_link_repo_raises_db_error_on_remove(context: Any) -> None: context.cov_link_repo.remove_link.side_effect = DatabaseError( "DB remove_link failure" ) @when("I invoke the CLI unlink-resource command with yes") def step_invoke_unlink_with_yes(context: Any) -> None: with ( patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo), patch(_PATCH_LINK_REPO, return_value=context.cov_link_repo), patch(_PATCH_REGISTRY_SVC, return_value=context.cov_registry_svc), ): context.cov_result = runner.invoke( app, ["unlink-resource", "local/test-proj", "local/some-res", "--yes"], ) # --------------------------------------------------------------------------- # list command: list_projects raises DatabaseError (lines 772-774) # --------------------------------------------------------------------------- @given("the project repo mock raises DatabaseError on list_projects") def step_project_repo_raises_db_error_on_list(context: Any) -> None: context.cov_project_repo.list_projects.side_effect = DatabaseError( "DB list failure" ) @when("I invoke the CLI list command") def step_invoke_list_cmd(context: Any) -> None: with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo): context.cov_result = runner.invoke(app, ["list"]) # --------------------------------------------------------------------------- # delete command: user declines confirmation (lines 908-910) # --------------------------------------------------------------------------- @given("the project repo mock returns a project with no linked resources") def step_project_repo_returns_no_linked(context: Any) -> None: mock_proj = _make_mock_project(linked_resources=[]) context.cov_project_repo.get.return_value = mock_proj @when("I invoke the CLI delete command without yes and user declines") def step_invoke_delete_user_declines(context: Any) -> None: with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo): context.cov_result = runner.invoke( app, ["delete", "local/test-proj"], input="n\n", ) # --------------------------------------------------------------------------- # delete command: repo.delete raises DatabaseError (lines 914-916) # --------------------------------------------------------------------------- @given("the project repo mock raises DatabaseError on delete") def step_project_repo_raises_db_error_on_delete(context: Any) -> None: context.cov_project_repo.delete.side_effect = DatabaseError("DB delete failure") @when("I invoke the CLI delete command with yes") def step_invoke_delete_with_yes(context: Any) -> None: with patch(_PATCH_PROJECT_REPO, return_value=context.cov_project_repo): context.cov_result = runner.invoke( app, ["delete", "local/test-proj", "--yes"], ) # --------------------------------------------------------------------------- # delete command: repo.delete returns False (lines 919-920) # --------------------------------------------------------------------------- @given("the project repo mock returns False on delete") def step_project_repo_delete_returns_false(context: Any) -> None: context.cov_project_repo.delete.return_value = False # --------------------------------------------------------------------------- # Common then assertions # --------------------------------------------------------------------------- @then('the coverage CLI result should contain "{text}"') def step_cov_cli_result_contains(context: Any, text: str) -> None: output = context.cov_result.output assert text.lower() in output.lower(), ( f"Expected CLI output to contain '{text}', got:\n{output}" ) @then("the coverage CLI exit code should be {code:d}") def step_cov_cli_exit_code(context: Any, code: int) -> None: actual = context.cov_result.exit_code assert actual == code, ( f"Expected exit code {code}, got {actual}. Output:\n{context.cov_result.output}" )