diff --git a/src/workato_platform_cli/cli/commands/profiles.py b/src/workato_platform_cli/cli/commands/profiles.py index 5da767f..5a9dcb4 100644 --- a/src/workato_platform_cli/cli/commands/profiles.py +++ b/src/workato_platform_cli/cli/commands/profiles.py @@ -3,6 +3,7 @@ import json import os +from pathlib import Path from typing import Any import asyncclick as click @@ -345,6 +346,164 @@ async def status( click.echo(" 💡 Or set WORKATO_API_TOKEN environment variable") +def _format_file_path(file_path: Path) -> str: + """Format file path for display, showing relative to current directory.""" + try: + rel_path = file_path.relative_to(Path.cwd()) + return f"./{rel_path}" + except ValueError: + # File is outside current directory (shouldn't happen with cwd search) + return str(file_path) + + +def _update_workatoenv_files(old_name: str, new_name: str) -> list[Path]: + """Find and update all .workatoenv files that reference the old profile name. + + Searches recursively from current directory. + Returns list of updated file paths. + """ + updated_files = [] + current_dir = Path.cwd() + + # Search from current directory for .workatoenv files + for workatoenv_file in current_dir.rglob(".workatoenv"): + try: + # Open for reading and writing + with open(workatoenv_file, "r+") as f: + data = json.load(f) + + # Check if profile field matches old name + if data.get("profile") == old_name: + # Update to new name + data["profile"] = new_name + + # Write back (truncate and write from beginning) + f.seek(0) + f.truncate() + json.dump(data, f, indent=2) + f.write("\n") # Add trailing newline + + updated_files.append(workatoenv_file) + except (OSError, json.JSONDecodeError): + # Skip files we can't read or parse + continue + + return updated_files + + +@profiles.command() +@click.argument("old_name") +@click.argument("new_name") +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@click.option( + "--yes", + is_flag=True, + help="Skip confirmation prompt", +) +@handle_cli_exceptions +@inject +async def rename( + old_name: str, + new_name: str, + output_mode: str = "table", + yes: bool = False, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Rename a profile""" + # Check if old profile exists + old_profile = config_manager.profile_manager.get_profile(old_name) + if not old_profile: + if output_mode == "json": + error_msg = f"Profile '{old_name}' not found" + output_data: dict[str, Any] = {"status": "error", "error": error_msg} + click.echo(json.dumps(output_data)) + else: + click.echo(f"❌ Profile '{old_name}' not found") + click.echo("💡 Use 'workato profiles list' to see available profiles") + return + + # Check if new name already exists + if config_manager.profile_manager.get_profile(new_name): + if output_mode == "json": + error_msg = f"Profile '{new_name}' already exists" + output_data = {"status": "error", "error": error_msg} + click.echo(json.dumps(output_data)) + else: + click.echo(f"❌ Profile '{new_name}' already exists") + click.echo( + "💡 Choose a different name or delete the existing profile first" + ) + return + + # Show confirmation prompt (skip in JSON mode or if --yes flag) + if ( + not yes + and output_mode != "json" + and not click.confirm(f"Rename profile '{old_name}' to '{new_name}'?") + ): + click.echo("❌ Rename cancelled") + return + + # Get the token from keyring + old_token = config_manager.profile_manager._get_token_from_keyring(old_name) + + # Create new profile with same data and token + try: + config_manager.profile_manager.set_profile(new_name, old_profile, old_token) + except ValueError as e: + if output_mode == "json": + output_data = {"status": "error", "error": str(e)} + click.echo(json.dumps(output_data)) + else: + click.echo(f"❌ Failed to create new profile: {e}") + return + + # If old profile was current, set new profile as current + current_profile = config_manager.profile_manager.get_current_profile_name() + was_current = current_profile == old_name + if was_current: + config_manager.profile_manager.set_current_profile(new_name) + + # Delete old profile + config_manager.profile_manager.delete_profile(old_name) + + # Update all .workatoenv files that reference the old profile + if output_mode == "table": + click.echo("🔄 Updating project configurations...") + updated_files = _update_workatoenv_files(old_name, new_name) + + # JSON output mode + if output_mode == "json": + output_data = { + "status": "success", + "old_name": old_name, + "new_name": new_name, + "was_current_profile": was_current, + "updated_files": [str(f) for f in updated_files], + "updated_files_count": len(updated_files), + } + click.echo(json.dumps(output_data)) + return + + # Table output mode (default) + click.echo("✅ Profile renamed successfully") + if was_current: + click.echo(f"✅ Set '{new_name}' as the active profile") + + # Display updated files + if not updated_files: + return + + click.echo(f"✅ Updated {len(updated_files)} project configuration(s)") + for file_path in updated_files: + click.echo(f" • {_format_file_path(file_path)}") + + @profiles.command() @click.argument("profile_name") @click.confirmation_option(prompt="Are you sure you want to delete this profile?") diff --git a/tests/conftest.py b/tests/conftest.py index 4530a87..b37b5d9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ """Pytest configuration and shared fixtures.""" +import json import tempfile -from collections.abc import Generator +from collections.abc import Callable, Generator from pathlib import Path from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -144,3 +145,80 @@ def prevent_keyring_errors() -> None: minimal_keyring.delete_password.return_value = None sys.modules["keyring"] = minimal_keyring + + +# Shared test helpers + + +def parse_json_output(capsys: pytest.CaptureFixture[str]) -> dict[str, Any]: + """Parse JSON output from capsys.""" + output = capsys.readouterr().out + result: dict[str, Any] = json.loads(output) + return result + + +def create_workatoenv_file( + tmp_path: Path, + dir_name: str, + profile: str, + project_id: int = 123, + **extra_fields: Any, +) -> Path: + """Create a test .workatoenv file. + + Args: + tmp_path: Temporary directory path + dir_name: Name of the project directory to create + profile: Profile name to set in the workatoenv file + project_id: Project ID (default: 123) + **extra_fields: Additional fields to include in the workatoenv file + """ + project_dir = tmp_path / dir_name + project_dir.mkdir() + workatoenv = project_dir / ".workatoenv" + + data = {"project_id": project_id, "profile": profile} + data.update(extra_fields) + + workatoenv.write_text(json.dumps(data)) + return workatoenv + + +def _make_workatoenv_updater(tmp_path: Path) -> Callable[[str, str], list[Path]]: + """Create a mock _update_workatoenv_files function for testing. + + Returns a function that searches tmp_path instead of home directory. + """ + + def mock_update(old_name: str, new_name: str) -> list[Path]: + updated_files = [] + for workatoenv_file in tmp_path.rglob(".workatoenv"): + try: + with open(workatoenv_file, "r+") as f: + data = json.load(f) + if data.get("profile") == old_name: + data["profile"] = new_name + f.seek(0) + f.truncate() + json.dump(data, f, indent=2) + f.write("\n") + updated_files.append(workatoenv_file) + except (OSError, json.JSONDecodeError): + continue + return updated_files + + return mock_update + + +def mock_workatoenv_updates(tmp_path: Path) -> Any: + """Context manager for mocking workatoenv file updates. + + Use this to mock the _update_workatoenv_files function in profiles module. + Must import profiles_module in your test file to use this helper. + """ + from workato_platform_cli.cli.commands import profiles as profiles_module + + mock_update = _make_workatoenv_updater(tmp_path) + return patch.object( + profiles_module, "_update_workatoenv_files", side_effect=mock_update + ) diff --git a/tests/unit/commands/test_profiles.py b/tests/unit/commands/test_profiles.py index a12809f..a7a831a 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -9,10 +9,16 @@ import asyncclick as click import pytest +from tests.conftest import ( + create_workatoenv_file, + mock_workatoenv_updates, + parse_json_output, +) from workato_platform_cli.cli.commands.profiles import ( create, delete, list_profiles, + rename, show, status, use, @@ -68,6 +74,40 @@ def _factory(**profile_methods: Mock) -> Mock: return _factory +# Test-specific fixtures + + +@pytest.fixture +def make_rename_config_manager( + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> Callable: + """Factory for creating config managers for rename tests.""" + + def _factory( + is_current: bool = False, **overrides: Mock + ) -> tuple[Mock, ProfileData]: + old_profile = profile_data_factory() + current = "old" if is_current else "other" + + defaults = { + "get_profile": Mock( + side_effect=lambda name: old_profile if name == "old" else None + ), + "_get_token_from_keyring": Mock(return_value="test_token"), + "set_profile": Mock(), + "get_current_profile_name": Mock(return_value=current), + "delete_profile": Mock(), + } + if is_current: + defaults["set_current_profile"] = Mock() + + defaults.update(overrides) + return make_config_manager(**defaults), old_profile + + return _factory + + @pytest.mark.asyncio async def test_list_profiles_displays_profile_details( capsys: pytest.CaptureFixture[str], @@ -668,10 +708,7 @@ async def test_list_profiles_json_output_mode( assert list_profiles.callback await list_profiles.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - - # Parse JSON output - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["current_profile"] == "dev" assert "dev" in parsed["profiles"] @@ -696,10 +733,7 @@ async def test_list_profiles_json_output_mode_empty( assert list_profiles.callback await list_profiles.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - - # Parse JSON output - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["current_profile"] is None assert parsed["profiles"] == {} @@ -718,8 +752,7 @@ async def test_status_json_no_profile( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["profile"] is None assert parsed["error"] == "No active profile configured" @@ -760,8 +793,7 @@ async def test_status_json_with_project_override( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["profile"]["name"] == "dev-profile" assert parsed["profile"]["source"]["type"] == "project_override" @@ -804,8 +836,7 @@ async def test_status_json_with_env_profile( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["profile"]["name"] == "env-profile" assert parsed["profile"]["source"]["type"] == "environment_variable" @@ -843,8 +874,7 @@ async def test_status_json_with_env_token( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["authentication"]["configured"] is True assert parsed["authentication"]["source"]["type"] == "environment_variable" @@ -878,8 +908,7 @@ async def test_status_json_no_token( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["authentication"]["configured"] is False @@ -919,8 +948,7 @@ async def test_status_json_project_path_none( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["project"]["configured"] is False @@ -951,8 +979,7 @@ async def test_status_json_exception_handling( assert status.callback await status.callback(output_mode="json", config_manager=config_manager) - output = capsys.readouterr().out - parsed = json.loads(output) + parsed = parse_json_output(capsys) assert parsed["project"]["configured"] is False @@ -1157,3 +1184,333 @@ async def test_create_profile_non_interactive( config_manager.profile_manager.set_current_profile.assert_called_once_with( "test_profile" ) + + +@pytest.mark.asyncio +async def test_rename_profile_success( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test successful profile rename.""" + config_manager, old_profile = make_rename_config_manager(is_current=False) + + assert rename.callback + with patch("asyncclick.confirm", return_value=True): + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "✅ Profile renamed successfully" in output + + # Verify profile was created with new name and old profile deleted + config_manager.profile_manager.set_profile.assert_called_once_with( + "new", old_profile, "test_token" + ) + config_manager.profile_manager.delete_profile.assert_called_once_with("old") + + +@pytest.mark.asyncio +async def test_rename_current_profile( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test renaming the current profile updates current profile setting.""" + config_manager, _ = make_rename_config_manager(is_current=True) + + assert rename.callback + with patch("asyncclick.confirm", return_value=True): + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "✅ Profile renamed successfully" in output + assert "✅ Set 'new' as the active profile" in output + + # Verify current profile was updated + config_manager.profile_manager.set_current_profile.assert_called_once_with("new") + + +@pytest.mark.asyncio +async def test_rename_profile_not_found( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test renaming a profile that doesn't exist.""" + config_manager, _ = make_rename_config_manager( + get_profile=Mock(return_value=None) # Profile doesn't exist + ) + + assert rename.callback + await rename.callback( + old_name="missing", new_name="new", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "❌ Profile 'missing' not found" in output + assert "Use 'workato profiles list'" in output + + +@pytest.mark.asyncio +async def test_rename_profile_new_name_exists( + capsys: pytest.CaptureFixture[str], + profile_data_factory: Callable[..., ProfileData], + make_rename_config_manager: Callable, +) -> None: + """Test renaming to a profile name that already exists.""" + # Create both profiles first to avoid circular dependency + config_manager, old_profile = make_rename_config_manager() + new_profile = profile_data_factory() + + # Override get_profile to return new_profile for non-"old" names + config_manager.profile_manager.get_profile = Mock( + side_effect=lambda name: old_profile if name == "old" else new_profile + ) + + assert rename.callback + await rename.callback( + old_name="old", new_name="existing", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "❌ Profile 'existing' already exists" in output + assert "Choose a different name" in output + + +@pytest.mark.asyncio +async def test_rename_profile_cancelled( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test cancelling profile rename.""" + config_manager, _ = make_rename_config_manager() + + assert rename.callback + with patch("asyncclick.confirm", return_value=False): # User cancels + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "❌ Rename cancelled" in output + + # Verify profile was not modified + config_manager.profile_manager.set_profile.assert_not_called() + config_manager.profile_manager.delete_profile.assert_not_called() + + +@pytest.mark.asyncio +async def test_rename_profile_set_profile_failure( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test handling of set_profile failure during rename.""" + config_manager, _ = make_rename_config_manager( + set_profile=Mock(side_effect=ValueError("Keyring error")) + ) + + assert rename.callback + with patch("asyncclick.confirm", return_value=True): + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + output = capsys.readouterr().out + assert "❌ Failed to create new profile:" in output + assert "Keyring error" in output + + +@pytest.mark.asyncio +async def test_rename_profile_updates_workatoenv_files( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, + tmp_path: Path, +) -> None: + """Test that rename updates .workatoenv files that reference the old profile.""" + config_manager, _ = make_rename_config_manager() + + # Create test .workatoenv files + workatoenv1 = create_workatoenv_file( + tmp_path, "project1", "old", project_name="Project 1", folder_id=456 + ) + workatoenv2 = create_workatoenv_file( + tmp_path, + "project2", + "other", + project_id=789, + project_name="Project 2", + folder_id=101, + ) + + with mock_workatoenv_updates(tmp_path): + assert rename.callback + with patch("asyncclick.confirm", return_value=True): + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + # Verify workatoenv1 was updated + with open(workatoenv1) as f: + data1 = json.load(f) + assert data1["profile"] == "new" + + # Verify workatoenv2 was NOT updated + with open(workatoenv2) as f: + data2 = json.load(f) + assert data2["profile"] == "other" + + output = capsys.readouterr().out + assert "✅ Updated 1 project configuration(s)" in output + + +@pytest.mark.asyncio +async def test_rename_profile_skips_malformed_workatoenv_files( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, + tmp_path: Path, +) -> None: + """Test that rename skips malformed .workatoenv files.""" + config_manager, _ = make_rename_config_manager() + + # Create malformed .workatoenv file + project1 = tmp_path / "project1" + project1.mkdir() + workatoenv1 = project1 / ".workatoenv" + workatoenv1.write_text("invalid json {") + + # Create valid .workatoenv file + workatoenv2 = create_workatoenv_file( + tmp_path, + "project2", + "old", + project_id=789, + project_name="Project 2", + folder_id=101, + ) + + with mock_workatoenv_updates(tmp_path): + assert rename.callback + with patch("asyncclick.confirm", return_value=True): + await rename.callback( + old_name="old", new_name="new", config_manager=config_manager + ) + + # Verify valid file was updated + with open(workatoenv2) as f: + data2 = json.load(f) + assert data2["profile"] == "new" + + # Verify malformed file was skipped (still invalid) + assert workatoenv1.read_text() == "invalid json {" + + output = capsys.readouterr().out + assert "✅ Updated 1 project configuration(s)" in output + + +@pytest.mark.asyncio +async def test_rename_profile_json_output_success( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, + tmp_path: Path, +) -> None: + """Test rename with JSON output mode.""" + config_manager, _ = make_rename_config_manager(is_current=True) + + # Create test .workatoenv file + create_workatoenv_file(tmp_path, "project1", "old") + + with mock_workatoenv_updates(tmp_path): + assert rename.callback + await rename.callback( + old_name="old", + new_name="new", + output_mode="json", + config_manager=config_manager, + ) + + parsed = parse_json_output(capsys) + + assert parsed["status"] == "success" + assert parsed["old_name"] == "old" + assert parsed["new_name"] == "new" + assert parsed["was_current_profile"] is True + assert parsed["updated_files_count"] == 1 + assert len(parsed["updated_files"]) == 1 + + +@pytest.mark.asyncio +async def test_rename_profile_json_output_error_not_found( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, +) -> None: + """Test rename JSON output when profile not found.""" + config_manager, _ = make_rename_config_manager(get_profile=Mock(return_value=None)) + + assert rename.callback + await rename.callback( + old_name="missing", + new_name="new", + output_mode="json", + config_manager=config_manager, + ) + + parsed = parse_json_output(capsys) + + assert parsed["status"] == "error" + assert "not found" in parsed["error"] + + +@pytest.mark.asyncio +async def test_rename_profile_json_output_error_exists( + capsys: pytest.CaptureFixture[str], + profile_data_factory: Callable[..., ProfileData], + make_rename_config_manager: Callable, +) -> None: + """Test rename JSON output when new name already exists.""" + # Create both profiles first to avoid circular dependency + config_manager, old_profile = make_rename_config_manager() + new_profile = profile_data_factory() + + # Override get_profile to return new_profile for non-"old" names + config_manager.profile_manager.get_profile = Mock( + side_effect=lambda name: old_profile if name == "old" else new_profile + ) + + assert rename.callback + await rename.callback( + old_name="old", + new_name="existing", + output_mode="json", + config_manager=config_manager, + ) + + parsed = parse_json_output(capsys) + + assert parsed["status"] == "error" + assert "already exists" in parsed["error"] + + +@pytest.mark.asyncio +async def test_rename_profile_yes_flag_skips_confirmation( + capsys: pytest.CaptureFixture[str], + make_rename_config_manager: Callable, + tmp_path: Path, +) -> None: + """Test that --yes flag skips confirmation prompt.""" + config_manager, _ = make_rename_config_manager() + + with mock_workatoenv_updates(tmp_path): + # Should not prompt with --yes flag + assert rename.callback + await rename.callback( + old_name="old", + new_name="new", + yes=True, + config_manager=config_manager, + ) + + output = capsys.readouterr().out + assert "✅ Profile renamed successfully" in output + # Verify no confirmation prompt was shown + assert "Rename profile" not in output