From 2fa94f03794f290b04a0d431cc65f375dfcc714f Mon Sep 17 00:00:00 2001 From: Shihan Pan Date: Wed, 11 Feb 2026 17:50:55 -0800 Subject: [PATCH 1/3] Add profiles rename command to allow renaming profile names Implement workato profiles rename command with positional arguments and confirmation prompt. The command: - Takes old_name and new_name as positional arguments - Shows confirmation prompt before renaming - Handles token migration from old to new profile via keyring - Updates current profile if renaming the current profile - Provides clear error messages for edge cases Added comprehensive test coverage for: - Successful rename - Renaming current profile - Profile not found - New name already exists - User cancellation - Keyring failure handling Fixes #113 Co-Authored-By: Claude Sonnet 4.5 --- .../cli/commands/profiles.py | 53 ++++++ tests/unit/commands/test_profiles.py | 170 ++++++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/src/workato_platform_cli/cli/commands/profiles.py b/src/workato_platform_cli/cli/commands/profiles.py index 5da767f..afec495 100644 --- a/src/workato_platform_cli/cli/commands/profiles.py +++ b/src/workato_platform_cli/cli/commands/profiles.py @@ -345,6 +345,59 @@ async def status( click.echo(" 💡 Or set WORKATO_API_TOKEN environment variable") +@profiles.command() +@click.argument("old_name") +@click.argument("new_name") +@handle_cli_exceptions +@inject +async def rename( + old_name: str, + new_name: str, + 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: + 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): + click.echo(f"❌ Profile '{new_name}' already exists") + click.echo("💡 Choose a different name or delete the existing profile first") + return + + # Show confirmation prompt + if 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: + 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() + if current_profile == old_name: + config_manager.profile_manager.set_current_profile(new_name) + + # Delete old profile + config_manager.profile_manager.delete_profile(old_name) + + # Show success message + click.echo("✅ Profile renamed successfully") + if current_profile == old_name: + click.echo(f"✅ Set '{new_name}' as the active profile") + + @profiles.command() @click.argument("profile_name") @click.confirmation_option(prompt="Are you sure you want to delete this profile?") diff --git a/tests/unit/commands/test_profiles.py b/tests/unit/commands/test_profiles.py index a12809f..7d2946d 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -13,6 +13,7 @@ create, delete, list_profiles, + rename, show, status, use, @@ -1157,3 +1158,172 @@ 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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> None: + """Test successful profile rename.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + 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="other"), # Not renaming current + delete_profile=Mock(), + ) + + 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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> None: + """Test renaming the current profile updates current profile setting.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + 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="old"), # Renaming current profile + set_current_profile=Mock(), + delete_profile=Mock(), + ) + + 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_config_manager: Callable[..., Mock], +) -> None: + """Test renaming a profile that doesn't exist.""" + config_manager = make_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_config_manager: Callable[..., Mock], +) -> None: + """Test renaming to a profile name that already exists.""" + old_profile = profile_data_factory() + new_profile = profile_data_factory() + config_manager = make_config_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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> None: + """Test cancelling profile rename.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + get_profile=Mock( + side_effect=lambda name: old_profile if name == "old" else None + ), + set_profile=Mock(), + delete_profile=Mock(), + ) + + 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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], +) -> None: + """Test handling of set_profile failure during rename.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + 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(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 From 9869209a2346f362596c79462af18c1e4f6b0c7d Mon Sep 17 00:00:00 2001 From: Shihan Pan Date: Thu, 12 Feb 2026 12:46:13 -0800 Subject: [PATCH 2/3] added workatoenv update when profile is renamed --- .../cli/commands/profiles.py | 58 +++++++ tests/unit/commands/test_profiles.py | 164 ++++++++++++++++++ 2 files changed, 222 insertions(+) diff --git a/src/workato_platform_cli/cli/commands/profiles.py b/src/workato_platform_cli/cli/commands/profiles.py index afec495..ea8de28 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,51 @@ 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") @@ -392,11 +438,23 @@ async def rename( # Delete old profile config_manager.profile_manager.delete_profile(old_name) + # Update all .workatoenv files that reference the old profile + click.echo("🔄 Updating project configurations...") + updated_files = _update_workatoenv_files(old_name, new_name) + # Show success message click.echo("✅ Profile renamed successfully") if current_profile == old_name: 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") diff --git a/tests/unit/commands/test_profiles.py b/tests/unit/commands/test_profiles.py index 7d2946d..b78d16e 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -9,6 +9,7 @@ import asyncclick as click import pytest +from workato_platform_cli.cli.commands import profiles as profiles_module from workato_platform_cli.cli.commands.profiles import ( create, delete, @@ -69,6 +70,32 @@ def _factory(**profile_methods: Mock) -> Mock: return _factory +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 + + @pytest.mark.asyncio async def test_list_profiles_displays_profile_details( capsys: pytest.CaptureFixture[str], @@ -1327,3 +1354,140 @@ async def test_rename_profile_set_profile_failure( 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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], + tmp_path: Path, +) -> None: + """Test that rename updates .workatoenv files that reference the old profile.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + 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="other"), + delete_profile=Mock(), + ) + + # Create test .workatoenv files + project1 = tmp_path / "project1" + project1.mkdir() + workatoenv1 = project1 / ".workatoenv" + workatoenv1.write_text( + json.dumps( + { + "project_id": 123, + "project_name": "Project 1", + "folder_id": 456, + "profile": "old", + } + ) + ) + + project2 = tmp_path / "project2" + project2.mkdir() + workatoenv2 = project2 / ".workatoenv" + workatoenv2.write_text( + json.dumps( + { + "project_id": 789, + "project_name": "Project 2", + "folder_id": 101, + "profile": "other", # Different profile - should not be updated + } + ) + ) + + # Mock _update_workatoenv_files to only search in tmp_path + mock_update = _make_workatoenv_updater(tmp_path) + + with patch.object( + profiles_module, "_update_workatoenv_files", side_effect=mock_update + ): + 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], + profile_data_factory: Callable[..., ProfileData], + make_config_manager: Callable[..., Mock], + tmp_path: Path, +) -> None: + """Test that rename skips malformed .workatoenv files.""" + old_profile = profile_data_factory() + config_manager = make_config_manager( + 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="other"), + delete_profile=Mock(), + ) + + # Create malformed .workatoenv file + project1 = tmp_path / "project1" + project1.mkdir() + workatoenv1 = project1 / ".workatoenv" + workatoenv1.write_text("invalid json {") + + # Create valid .workatoenv file + project2 = tmp_path / "project2" + project2.mkdir() + workatoenv2 = project2 / ".workatoenv" + workatoenv2.write_text( + json.dumps( + { + "project_id": 789, + "project_name": "Project 2", + "folder_id": 101, + "profile": "old", + } + ) + ) + + mock_update = _make_workatoenv_updater(tmp_path) + + with patch.object( + profiles_module, "_update_workatoenv_files", side_effect=mock_update + ): + 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 From 041537834e243f7140781435bbfeade77faeb7cb Mon Sep 17 00:00:00 2001 From: Shihan Pan Date: Thu, 12 Feb 2026 18:55:20 -0800 Subject: [PATCH 3/3] added output mode json --- .../cli/commands/profiles.py | 70 +++- tests/conftest.py | 80 +++- tests/unit/commands/test_profiles.py | 361 ++++++++++-------- 3 files changed, 330 insertions(+), 181 deletions(-) diff --git a/src/workato_platform_cli/cli/commands/profiles.py b/src/workato_platform_cli/cli/commands/profiles.py index ea8de28..5a9dcb4 100644 --- a/src/workato_platform_cli/cli/commands/profiles.py +++ b/src/workato_platform_cli/cli/commands/profiles.py @@ -394,29 +394,58 @@ def _update_workatoenv_files(old_name: str, new_name: str) -> list[Path]: @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: - click.echo(f"❌ Profile '{old_name}' not found") - click.echo("💡 Use 'workato profiles list' to see available profiles") + 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): - click.echo(f"❌ Profile '{new_name}' already exists") - click.echo("💡 Choose a different name or delete the existing profile first") + 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 - if not click.confirm(f"Rename profile '{old_name}' to '{new_name}'?"): + # 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 @@ -427,24 +456,43 @@ async def rename( try: config_manager.profile_manager.set_profile(new_name, old_profile, old_token) except ValueError as e: - click.echo(f"❌ Failed to create new profile: {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() - if current_profile == old_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 - click.echo("🔄 Updating project configurations...") + if output_mode == "table": + click.echo("🔄 Updating project configurations...") updated_files = _update_workatoenv_files(old_name, new_name) - # Show success message + # 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 current_profile == old_name: + if was_current: click.echo(f"✅ Set '{new_name}' as the active profile") # Display updated files 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 b78d16e..a7a831a 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -9,7 +9,11 @@ import asyncclick as click import pytest -from workato_platform_cli.cli.commands import profiles as profiles_module +from tests.conftest import ( + create_workatoenv_file, + mock_workatoenv_updates, + parse_json_output, +) from workato_platform_cli.cli.commands.profiles import ( create, delete, @@ -70,30 +74,38 @@ def _factory(**profile_methods: Mock) -> Mock: return _factory -def _make_workatoenv_updater(tmp_path: Path) -> Callable[[str, str], list[Path]]: - """Create a mock _update_workatoenv_files function for testing. +# 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.""" - Returns a function that searches tmp_path instead of home directory. - """ + 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() - 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 + defaults.update(overrides) + return make_config_manager(**defaults), old_profile - return mock_update + return _factory @pytest.mark.asyncio @@ -696,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"] @@ -724,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"] == {} @@ -746,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" @@ -788,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" @@ -832,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" @@ -871,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" @@ -906,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 @@ -947,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 @@ -979,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 @@ -1190,20 +1189,10 @@ async def test_create_profile_non_interactive( @pytest.mark.asyncio async def test_rename_profile_success( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test successful profile rename.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - 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="other"), # Not renaming current - delete_profile=Mock(), - ) + config_manager, old_profile = make_rename_config_manager(is_current=False) assert rename.callback with patch("asyncclick.confirm", return_value=True): @@ -1224,21 +1213,10 @@ async def test_rename_profile_success( @pytest.mark.asyncio async def test_rename_current_profile( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test renaming the current profile updates current profile setting.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - 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="old"), # Renaming current profile - set_current_profile=Mock(), - delete_profile=Mock(), - ) + config_manager, _ = make_rename_config_manager(is_current=True) assert rename.callback with patch("asyncclick.confirm", return_value=True): @@ -1257,11 +1235,11 @@ async def test_rename_current_profile( @pytest.mark.asyncio async def test_rename_profile_not_found( capsys: pytest.CaptureFixture[str], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test renaming a profile that doesn't exist.""" - config_manager = make_config_manager( - get_profile=Mock(return_value=None), # Profile doesn't exist + config_manager, _ = make_rename_config_manager( + get_profile=Mock(return_value=None) # Profile doesn't exist ) assert rename.callback @@ -1278,15 +1256,16 @@ async def test_rename_profile_not_found( async def test_rename_profile_new_name_exists( capsys: pytest.CaptureFixture[str], profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test renaming to a profile name that already exists.""" - old_profile = profile_data_factory() + # Create both profiles first to avoid circular dependency + config_manager, old_profile = make_rename_config_manager() new_profile = profile_data_factory() - config_manager = make_config_manager( - get_profile=Mock( - side_effect=lambda name: old_profile if name == "old" else new_profile - ), + + # 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 @@ -1302,18 +1281,10 @@ async def test_rename_profile_new_name_exists( @pytest.mark.asyncio async def test_rename_profile_cancelled( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test cancelling profile rename.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - get_profile=Mock( - side_effect=lambda name: old_profile if name == "old" else None - ), - set_profile=Mock(), - delete_profile=Mock(), - ) + config_manager, _ = make_rename_config_manager() assert rename.callback with patch("asyncclick.confirm", return_value=False): # User cancels @@ -1332,17 +1303,11 @@ async def test_rename_profile_cancelled( @pytest.mark.asyncio async def test_rename_profile_set_profile_failure( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, ) -> None: """Test handling of set_profile failure during rename.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - 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(side_effect=ValueError("Keyring error")), + config_manager, _ = make_rename_config_manager( + set_profile=Mock(side_effect=ValueError("Keyring error")) ) assert rename.callback @@ -1359,57 +1324,26 @@ async def test_rename_profile_set_profile_failure( @pytest.mark.asyncio async def test_rename_profile_updates_workatoenv_files( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, tmp_path: Path, ) -> None: """Test that rename updates .workatoenv files that reference the old profile.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - 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="other"), - delete_profile=Mock(), - ) + config_manager, _ = make_rename_config_manager() # Create test .workatoenv files - project1 = tmp_path / "project1" - project1.mkdir() - workatoenv1 = project1 / ".workatoenv" - workatoenv1.write_text( - json.dumps( - { - "project_id": 123, - "project_name": "Project 1", - "folder_id": 456, - "profile": "old", - } - ) + workatoenv1 = create_workatoenv_file( + tmp_path, "project1", "old", project_name="Project 1", folder_id=456 ) - - project2 = tmp_path / "project2" - project2.mkdir() - workatoenv2 = project2 / ".workatoenv" - workatoenv2.write_text( - json.dumps( - { - "project_id": 789, - "project_name": "Project 2", - "folder_id": 101, - "profile": "other", # Different profile - should not be updated - } - ) + workatoenv2 = create_workatoenv_file( + tmp_path, + "project2", + "other", + project_id=789, + project_name="Project 2", + folder_id=101, ) - # Mock _update_workatoenv_files to only search in tmp_path - mock_update = _make_workatoenv_updater(tmp_path) - - with patch.object( - profiles_module, "_update_workatoenv_files", side_effect=mock_update - ): + with mock_workatoenv_updates(tmp_path): assert rename.callback with patch("asyncclick.confirm", return_value=True): await rename.callback( @@ -1433,21 +1367,11 @@ async def test_rename_profile_updates_workatoenv_files( @pytest.mark.asyncio async def test_rename_profile_skips_malformed_workatoenv_files( capsys: pytest.CaptureFixture[str], - profile_data_factory: Callable[..., ProfileData], - make_config_manager: Callable[..., Mock], + make_rename_config_manager: Callable, tmp_path: Path, ) -> None: """Test that rename skips malformed .workatoenv files.""" - old_profile = profile_data_factory() - config_manager = make_config_manager( - 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="other"), - delete_profile=Mock(), - ) + config_manager, _ = make_rename_config_manager() # Create malformed .workatoenv file project1 = tmp_path / "project1" @@ -1456,25 +1380,16 @@ async def test_rename_profile_skips_malformed_workatoenv_files( workatoenv1.write_text("invalid json {") # Create valid .workatoenv file - project2 = tmp_path / "project2" - project2.mkdir() - workatoenv2 = project2 / ".workatoenv" - workatoenv2.write_text( - json.dumps( - { - "project_id": 789, - "project_name": "Project 2", - "folder_id": 101, - "profile": "old", - } - ) + workatoenv2 = create_workatoenv_file( + tmp_path, + "project2", + "old", + project_id=789, + project_name="Project 2", + folder_id=101, ) - mock_update = _make_workatoenv_updater(tmp_path) - - with patch.object( - profiles_module, "_update_workatoenv_files", side_effect=mock_update - ): + with mock_workatoenv_updates(tmp_path): assert rename.callback with patch("asyncclick.confirm", return_value=True): await rename.callback( @@ -1491,3 +1406,111 @@ async def test_rename_profile_skips_malformed_workatoenv_files( 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