From a4611ab8143efe52177189da2d74396440ff1eb6 Mon Sep 17 00:00:00 2001 From: j-madrone Date: Tue, 30 Sep 2025 15:07:15 +0100 Subject: [PATCH 1/2] Enhance CLI command structure and output options - Added aliases for existing commands in the CLI for improved usability, allowing commands like `project`, `profiles`, and `property` to be recognized alongside their original names. - Introduced an `--output-mode` option in the `init` and `status` commands to support both table and JSON formats, enhancing user experience and data accessibility. - Implemented validation to ensure JSON output mode is only used in non-interactive mode, providing clear error messages for incorrect usage. - Updated error handling in the `init` command to return structured JSON responses for various validation errors, improving consistency in user feedback. - Enhanced unit tests to cover new output modes and validation scenarios, ensuring robust functionality and error handling across commands. --- src/workato_platform/cli/__init__.py | 10 + src/workato_platform/cli/commands/init.py | 159 +++++++- src/workato_platform/cli/commands/profiles.py | 109 ++++- src/workato_platform/cli/commands/pull.py | 17 +- .../cli/utils/config/manager.py | 64 ++- .../cli/utils/exception_handler.py | 101 ++++- tests/unit/commands/test_init.py | 386 ++++++++++++++++++ tests/unit/commands/test_profiles.py | 258 +++++++++++- tests/unit/config/test_manager.py | 8 +- tests/unit/utils/test_exception_handler.py | 195 +++++++++ 10 files changed, 1234 insertions(+), 73 deletions(-) diff --git a/src/workato_platform/cli/__init__.py b/src/workato_platform/cli/__init__.py index 5620a7e..9da456c 100644 --- a/src/workato_platform/cli/__init__.py +++ b/src/workato_platform/cli/__init__.py @@ -67,8 +67,11 @@ def cli( # Core setup and configuration commands cli.add_command(init.init) cli.add_command(projects.projects) +cli.add_command(projects.projects, name="project") cli.add_command(profiles.profiles) +cli.add_command(profiles.profiles, name="profiles") cli.add_command(properties.properties) +cli.add_command(properties.properties, name="property") # Development commands cli.add_command(guide.guide) @@ -77,12 +80,19 @@ def cli( # API and resource management commands cli.add_command(api_collections.api_collections) +cli.add_command(api_collections.api_collections, name="api-collection") cli.add_command(api_clients.api_clients) +cli.add_command(api_clients.api_clients, name="api-client") cli.add_command(data_tables.data_tables) +cli.add_command(data_tables.data_tables, name="data-table") cli.add_command(connections.connections) +cli.add_command(connections.connections, name="connection") cli.add_command(connectors.connectors) +cli.add_command(connectors.connectors, name="connector") cli.add_command(recipes.recipes) +cli.add_command(recipes.recipes, name="recipe") # Information commands cli.add_command(assets.assets) +cli.add_command(assets.assets, name="asset") cli.add_command(workspace.workspace) diff --git a/src/workato_platform/cli/commands/init.py b/src/workato_platform/cli/commands/init.py index 9013cec..68f7f26 100644 --- a/src/workato_platform/cli/commands/init.py +++ b/src/workato_platform/cli/commands/init.py @@ -1,6 +1,11 @@ """Initialize Workato CLI for a new project""" +import json + +from typing import Any + import asyncclick as click +import certifi from workato_platform import Workato from workato_platform.cli.commands.projects.project_manager import ProjectManager @@ -28,6 +33,12 @@ is_flag=True, help="Run in non-interactive mode (requires all necessary options)", ) +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json (only with --non-interactive)", +) @handle_api_exceptions async def init( profile: str | None = None, @@ -37,32 +48,71 @@ async def init( project_name: str | None = None, project_id: int | None = None, non_interactive: bool = False, + output_mode: str = "table", ) -> None: """Initialize Workato CLI for a new project""" + # Validate that --output-mode json requires --non-interactive + if output_mode == "json" and not non_interactive: + # Return JSON error for consistency + error_data: dict[str, Any] = { + "status": "error", + "error": "--output-mode json can only be used with --non-interactive flag", + "error_code": "INVALID_OPTIONS", + } + click.echo(json.dumps(error_data)) + return + if non_interactive: # Validate required parameters for non-interactive mode + error_msg = None + error_code = None + # Either profile OR individual attributes (region, api_token) are required if not profile and not (region and api_token): - click.echo( - "❌ Either --profile or both --region and --api-token are " - "required in non-interactive mode" + error_msg = ( + "Either --profile or both --region and --api-token are required " + "in non-interactive mode" ) - raise click.Abort() - if region == "custom" and not api_url: - click.echo( - "❌ --api-url is required when region=custom in non-interactive mode" + error_code = "MISSING_REQUIRED_OPTIONS" + elif region == "custom" and not api_url: + error_msg = ( + "--api-url is required when region=custom in non-interactive mode" ) - raise click.Abort() - if not project_name and not project_id: - click.echo( - "❌ Either --project-name or --project-id is " - "required in non-interactive mode" + error_code = "MISSING_REQUIRED_OPTIONS" + elif not project_name and not project_id: + error_msg = ( + "Either --project-name or --project-id is required in " + "non-interactive mode" ) - raise click.Abort() - if project_name and project_id: - click.echo("❌ Cannot specify both --project-name and --project-id") - raise click.Abort() + error_code = "MISSING_REQUIRED_OPTIONS" + elif project_name and project_id: + error_msg = "Cannot specify both --project-name and --project-id" + error_code = "CONFLICTING_OPTIONS" + + if error_msg: + if output_mode == "json": + error_data = { + "status": "error", + "error": error_msg, + "error_code": error_code, + } + click.echo(json.dumps(error_data)) + return + else: + click.echo(f"❌ {error_msg}") + raise click.Abort() # For non-JSON mode, keep the normal abort behavior + + # Initialize JSON output data structure if in JSON mode + # Since we've already validated that json mode requires non-interactive, + # we can use output_data existence as our flag throughout the code + output_data = None + if output_mode == "json": + output_data = { + "status": "success", + "profile": {}, + "project": {}, + } config_manager = await ConfigManager.initialize( profile_name=profile, @@ -71,10 +121,44 @@ async def init( api_url=api_url, project_name=project_name, project_id=project_id, + output_mode=output_mode, + non_interactive=non_interactive, ) + # Check if project directory exists and is non-empty + project_dir = config_manager.get_project_directory() + if project_dir and project_dir.exists() and any(project_dir.iterdir()): + # Directory is non-empty + if non_interactive: + # In non-interactive mode, fail with error + error_msg = ( + f"Directory '{project_dir}' is not empty. " + "Please use an empty directory or remove existing files." + ) + if output_mode == "json": + error_data = { + "status": "error", + "error": error_msg, + "error_code": "DIRECTORY_NOT_EMPTY", + } + click.echo(json.dumps(error_data)) + return + else: + click.echo(f"❌ {error_msg}") + raise click.Abort() + else: + # Interactive mode - ask for confirmation + click.echo(f"⚠️ Directory '{project_dir}' is not empty.") + if not click.confirm( + "Proceed with initialization? This may overwrite or delete files.", + default=False, + ): + click.echo("❌ Initialization cancelled") + return + # Automatically run pull to set up project structure - click.echo() + if not output_data: + click.echo() # Get API credentials from the newly configured profile config_data = config_manager.load_config() @@ -83,9 +167,27 @@ async def init( project_profile_override ) + # Populate profile data for JSON output + if output_data: + profile_data = config_manager.profile_manager.get_profile( + project_profile_override + or config_manager.profile_manager.get_current_profile_name() + or "", + ) + if profile_data: + output_data["profile"] = { + "name": project_profile_override + or config_manager.profile_manager.get_current_profile_name(), + "region": profile_data.region, + "region_name": profile_data.region_name, + "api_url": profile_data.region_url, + "workspace_id": profile_data.workspace_id, + } + # Create API client configuration - api_config = Configuration(access_token=api_token, host=api_host) - api_config.verify_ssl = False + api_config = Configuration( + access_token=api_token, host=api_host, ssl_ca_cert=certifi.where() + ) # Create project manager and run pull async with Workato(configuration=api_config) as workato_api_client: @@ -93,7 +195,22 @@ async def init( await _pull_project( config_manager=config_manager, project_manager=project_manager, + non_interactive=non_interactive, ) - # Final completion message - click.echo("🎉 Project setup complete!") + # Populate project data for JSON output + if output_data: + meta_data = config_manager.load_config() + project_path = config_manager.get_project_directory() + output_data["project"] = { + "name": meta_data.project_name or "project", + "id": meta_data.project_id, + "folder_id": meta_data.folder_id, + "path": str(project_path) if project_path else None, + } + + # Output final result + if output_data: + click.echo(json.dumps(output_data)) + else: + click.echo("🎉 Project setup complete!") diff --git a/src/workato_platform/cli/commands/profiles.py b/src/workato_platform/cli/commands/profiles.py index 700ecb3..eb9f7eb 100644 --- a/src/workato_platform/cli/commands/profiles.py +++ b/src/workato_platform/cli/commands/profiles.py @@ -136,7 +136,7 @@ async def use( config_data = config_manager.load_config() except Exception: workspace_root = None - config_data = ConfigData() + config_data = ConfigData.model_construct() # If we have a workspace config (project_id exists), update workspace profile if config_data.project_id and workspace_root: @@ -162,8 +162,15 @@ async def use( @profiles.command() +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) @inject async def status( + output_mode: str = "table", config_manager: ConfigManager = Provide[Container.config_manager], ) -> None: """Show current profile status and configuration""" @@ -176,11 +183,107 @@ async def status( project_profile_override ) + output_data: dict[str, Any] = {} + if not current_profile_name: - click.echo("❌ No active profile configured") - click.echo("💡 Run 'workato init' to create and set a profile") + if output_mode == "json": + output_data = {"profile": None, "error": "No active profile configured"} + click.echo(json.dumps(output_data, indent=2)) + else: + click.echo("❌ No active profile configured") + click.echo("💡 Run 'workato init' to create and set a profile") + return + + # JSON output mode + if output_mode == "json": + # Profile information + profile_source_type = None + profile_source_location = None + if project_profile_override: + profile_source_type = "project_override" + profile_source_location = ".workatoenv" + elif os.environ.get("WORKATO_PROFILE"): + profile_source_type = "environment_variable" + profile_source_location = "WORKATO_PROFILE" + else: + profile_source_type = "global_default" + profile_source_location = "~/.workato/profiles" + + profile_data = config_manager.profile_manager.get_current_profile_data( + project_profile_override + ) + + output_data["profile"] = { + "name": current_profile_name, + "source": { + "type": profile_source_type, + "location": profile_source_location, + }, + } + + if profile_data: + output_data["profile"]["configuration"] = { + "region": { + "code": profile_data.region, + "name": profile_data.region_name, + "url": profile_data.region_url, + }, + "workspace_id": profile_data.workspace_id, + } + + # Authentication information + api_token, _ = config_manager.profile_manager.resolve_environment_variables( + project_profile_override + ) + + if api_token: + auth_source_type = None + auth_source_location = None + if os.environ.get("WORKATO_API_TOKEN"): + auth_source_type = "environment_variable" + auth_source_location = "WORKATO_API_TOKEN" + else: + auth_source_type = "keyring" + auth_source_location = "~/.workato/profiles" + + output_data["authentication"] = { + "configured": True, + "source": {"type": auth_source_type, "location": auth_source_location}, + } + else: + output_data["authentication"] = {"configured": False} + + # Project information + try: + workspace_root = config_manager.get_workspace_root() + if workspace_root and (config_data.project_id or config_data.project_name): + project_metadata: dict[str, Any] = {} + if config_data.project_name: + project_metadata["name"] = config_data.project_name + if config_data.project_id: + project_metadata["id"] = config_data.project_id + if config_data.folder_id: + project_metadata["folder_id"] = config_data.folder_id + + project_path = config_manager.get_project_directory() + + if project_path: + output_data["project"] = { + "configured": True, + "path": str(project_path), + "metadata": project_metadata, + } + else: + output_data["project"] = {"configured": False} + else: + output_data["project"] = {"configured": False} + except Exception: + output_data["project"] = {"configured": False} + + click.echo(json.dumps(output_data)) return + # Table output (existing code) click.echo("📊 Profile Status:") click.echo(f" Current Profile: {current_profile_name}") diff --git a/src/workato_platform/cli/commands/pull.py b/src/workato_platform/cli/commands/pull.py index 108e82a..b7355e8 100644 --- a/src/workato_platform/cli/commands/pull.py +++ b/src/workato_platform/cli/commands/pull.py @@ -23,6 +23,7 @@ async def _pull_project( config_manager: ConfigManager, project_manager: ProjectManager, + non_interactive: bool = False, ) -> None: """Internal pull logic that can be called from other commands""" @@ -80,7 +81,12 @@ async def _pull_project( ignore_patterns = load_ignore_patterns(workspace_root) # Merge changes between remote and local - changes = merge_directories(temp_project_path, project_dir, ignore_patterns) + changes = merge_directories( + temp_project_path, + project_dir, + ignore_patterns, + non_interactive, + ) # Show summary of changes if changes["added"] or changes["modified"] or changes["removed"]: @@ -214,7 +220,10 @@ def calculate_json_diff_stats(old_file: Path, new_file: Path) -> dict[str, int]: def merge_directories( - remote_dir: Path, local_dir: Path, ignore_patterns: set[str] + remote_dir: Path, + local_dir: Path, + ignore_patterns: set[str], + non_interactive: bool = False, ) -> dict[str, list[tuple[str, dict[str, int]]]]: """Merge remote directory into local directory, return summary of changes""" remote_path = Path(remote_dir) @@ -270,8 +279,8 @@ def merge_directories( continue files_to_delete.append(rel_path) - # If there are files to delete, ask for confirmation - if files_to_delete: + # If there are files to delete, ask for confirmation (unless non-interactive) + if files_to_delete and not non_interactive: click.echo( f"\n⚠️ The following {len(files_to_delete)} file(s) will be deleted:" ) diff --git a/src/workato_platform/cli/utils/config/manager.py b/src/workato_platform/cli/utils/config/manager.py index 48ee2fe..605b109 100644 --- a/src/workato_platform/cli/utils/config/manager.py +++ b/src/workato_platform/cli/utils/config/manager.py @@ -8,6 +8,7 @@ from urllib.parse import urlparse import asyncclick as click +import certifi import inquirer from workato_platform import Workato @@ -51,13 +52,16 @@ async def initialize( api_url: str | None = None, project_name: str | None = None, project_id: int | None = None, + output_mode: str = "table", + non_interactive: bool = False, ) -> "ConfigManager": """Initialize workspace with interactive or non-interactive setup""" - if profile_name or (region and api_token): - click.echo("🚀 Welcome to Workato CLI (Non-interactive mode)") - else: - click.echo("🚀 Welcome to Workato CLI") - click.echo() + if output_mode == "table": + if non_interactive: + click.echo("🚀 Welcome to Workato CLI (Non-interactive mode)") + else: + click.echo("🚀 Welcome to Workato CLI") + click.echo() # Create manager without validation for setup manager = cls(config_dir, skip_validation=True) @@ -65,7 +69,7 @@ async def initialize( # Validate we're not in a project directory manager.workspace_manager.validate_not_in_project() - if profile_name or (region and api_token): + if non_interactive and (profile_name or (region and api_token)): # Non-interactive setup await manager._setup_non_interactive( profile_name=profile_name, @@ -138,8 +142,9 @@ async def _setup_non_interactive( region_info = AVAILABLE_REGIONS[region] # Test authentication and get workspace info - api_config = Configuration(access_token=api_token, host=region_info.url) - api_config.verify_ssl = False + api_config = Configuration( + access_token=api_token, host=region_info.url, ssl_ca_cert=certifi.where() + ) async with Workato(configuration=api_config) as workato_api_client: user_info = await workato_api_client.users_api.get_workspace_details() @@ -157,8 +162,9 @@ async def _setup_non_interactive( self.profile_manager.set_current_profile(current_profile_name) # Get API client for project operations - api_config = Configuration(access_token=api_token, host=region_info.url) - api_config.verify_ssl = False + api_config = Configuration( + access_token=api_token, host=region_info.url, ssl_ca_cert=certifi.where() + ) async with Workato(configuration=api_config) as workato_api_client: project_manager = ProjectManager(workato_api_client=workato_api_client) @@ -182,14 +188,9 @@ async def _setup_non_interactive( if not selected_project: raise click.ClickException("No project selected") - # Determine project path + # Always create project subdirectory named after the project current_dir = Path.cwd().resolve() - if current_dir == workspace_root: - # Running from workspace root - create subdirectory - project_path = workspace_root / selected_project.name - else: - # Running from subdirectory - use current directory - project_path = current_dir + project_path = current_dir / selected_project.name # Create project directory project_path.mkdir(parents=True, exist_ok=True) @@ -317,8 +318,9 @@ async def _create_new_profile(self, profile_name: str) -> None: sys.exit(1) # Test authentication and get workspace info - api_config = Configuration(access_token=token, host=selected_region.url) - api_config.verify_ssl = False + api_config = Configuration( + access_token=token, host=selected_region.url, ssl_ca_cert=certifi.where() + ) async with Workato(configuration=api_config) as workato_api_client: user_info = await workato_api_client.users_api.get_workspace_details() @@ -375,21 +377,13 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None: click.echo(f"✅ Project: {existing_config.project_name}") return - # Determine project location from current directory - current_dir = Path.cwd().resolve() - if current_dir == workspace_root: - # Running from workspace root - need to create subdirectory - project_location_mode = "workspace_root" - else: - # Running from subdirectory - use current directory as project location - project_location_mode = "current_dir" - # Get API client for project operations api_token, api_host = self.profile_manager.resolve_environment_variables( profile_name ) - api_config = Configuration(access_token=api_token, host=api_host) - api_config.verify_ssl = False + api_config = Configuration( + access_token=api_token, host=api_host, ssl_ca_cert=certifi.where() + ) async with Workato(configuration=api_config) as workato_api_client: project_manager = ProjectManager(workato_api_client=workato_api_client) @@ -438,14 +432,8 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None: sys.exit(1) # Always create project subdirectory named after the project - project_name = selected_project.name - - if project_location_mode == "current_dir": - # Create project subdirectory within current directory - project_path = current_dir / project_name - else: - # Create project subdirectory in workspace root - project_path = workspace_root / project_name + current_dir = Path.cwd().resolve() + project_path = current_dir / selected_project.name # Validate project path try: diff --git a/src/workato_platform/cli/utils/exception_handler.py b/src/workato_platform/cli/utils/exception_handler.py index 3a5bfcc..2d9761e 100644 --- a/src/workato_platform/cli/utils/exception_handler.py +++ b/src/workato_platform/cli/utils/exception_handler.py @@ -2,6 +2,7 @@ import asyncio import functools +import json from collections.abc import Callable from json import JSONDecodeError @@ -99,10 +100,35 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: return cast(F, sync_wrapper) +def _get_output_mode() -> str: + """Get the output mode from Click context.""" + ctx = click.get_current_context(silent=True) + if ctx and hasattr(ctx, "params"): + output_mode: str = ctx.params.get("output_mode", "table") + return output_mode + return "table" + + def _handle_client_error( e: BadRequestException | UnprocessableEntityException, ) -> None: """Handle 400 Bad Request and 422 Unprocessable Entity errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_details = _extract_error_details(e) + error_data = { + "status": "error", + "error": error_details + or e.reason + or "Bad request - check your input parameters", + "error_code": "BAD_REQUEST" + if isinstance(e, BadRequestException) + else "UNPROCESSABLE_ENTITY", + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Invalid request") # Try to extract error details from response body @@ -115,8 +141,19 @@ def _handle_client_error( click.echo("💡 Please check your input and try again") -def _handle_auth_error(_: UnauthorizedException) -> None: +def _handle_auth_error(e: UnauthorizedException) -> None: """Handle 401 Unauthorized errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_data = { + "status": "error", + "error": "Authentication failed - invalid or missing API token", + "error_code": "UNAUTHORIZED", + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Authentication failed") click.echo(" Your API token may be invalid") click.echo("💡 Please check your authentication:") @@ -127,6 +164,18 @@ def _handle_auth_error(_: UnauthorizedException) -> None: def _handle_forbidden_error(e: ForbiddenException) -> None: """Handle 403 Forbidden errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_details = _extract_error_details(e) + error_data = { + "status": "error", + "error": error_details or "Access forbidden - insufficient permissions", + "error_code": "FORBIDDEN", + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Access forbidden") click.echo(" You don't have permission to perform this action") @@ -142,6 +191,18 @@ def _handle_forbidden_error(e: ForbiddenException) -> None: def _handle_not_found_error(e: NotFoundException) -> None: """Handle 404 Not Found errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_details = _extract_error_details(e) + error_data = { + "status": "error", + "error": error_details or "Resource not found", + "error_code": "NOT_FOUND", + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Resource not found") click.echo(" The requested resource could not be found") @@ -157,6 +218,18 @@ def _handle_not_found_error(e: NotFoundException) -> None: def _handle_conflict_error(e: ConflictException) -> None: """Handle 409 Conflict errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_details = _extract_error_details(e) + error_data = { + "status": "error", + "error": error_details or "Request conflicts with current state", + "error_code": "CONFLICT", + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Conflict detected") click.echo(" The request conflicts with the current state") @@ -172,6 +245,18 @@ def _handle_conflict_error(e: ConflictException) -> None: def _handle_server_error(e: ServiceException) -> None: """Handle 5xx Server errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_data = { + "status": "error", + "error": "Server error - Workato API is experiencing issues", + "error_code": "SERVER_ERROR", + "http_status": e.status, + } + click.echo(json.dumps(error_data)) + return + click.echo("❌ Server error") click.echo(" The Workato API is experiencing issues") click.echo(f" Status: {e.status}") @@ -184,6 +269,20 @@ def _handle_server_error(e: ServiceException) -> None: def _handle_generic_api_error(e: ApiException) -> None: """Handle other API errors.""" + output_mode = _get_output_mode() + + if output_mode == "json": + error_details = _extract_error_details(e) + error_data = { + "status": "error", + "error": error_details or e.reason or "API error occurred", + "error_code": "API_ERROR", + } + if e.status: + error_data["http_status"] = e.status + click.echo(json.dumps(error_data)) + return + click.echo("❌ API error occurred") if e.status: diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index 71a4956..3456f4d 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -1,5 +1,9 @@ """Tests for the init command.""" +import json + +from io import StringIO +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import asyncclick as click @@ -19,6 +23,11 @@ async def test_init_interactive_mode(monkeypatch: pytest.MonkeyPatch) -> None: patch.object( mock_config_manager, "load_config", return_value=Mock(profile="default") ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -53,6 +62,8 @@ async def test_init_interactive_mode(monkeypatch: pytest.MonkeyPatch) -> None: api_url=None, project_name=None, project_id=None, + output_mode="table", + non_interactive=False, ) mock_pull.assert_awaited_once() @@ -70,6 +81,11 @@ async def test_init_non_interactive_success(monkeypatch: pytest.MonkeyPatch) -> "load_config", return_value=Mock(profile="test-profile"), ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -114,6 +130,8 @@ async def test_init_non_interactive_success(monkeypatch: pytest.MonkeyPatch) -> api_url=None, project_name="test-project", project_id=None, + output_mode="table", + non_interactive=True, ) mock_pull.assert_awaited_once() @@ -133,6 +151,11 @@ async def test_init_non_interactive_custom_region( "load_config", return_value=Mock(profile="test-profile"), ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -175,6 +198,8 @@ async def test_init_non_interactive_custom_region( api_url="https://custom.workato.com", project_name=None, project_id=123, + output_mode="table", + non_interactive=True, ) @@ -291,6 +316,11 @@ async def test_init_non_interactive_with_region_and_token( "load_config", return_value=Mock(profile="default"), ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), patch.object( mock_config_manager.profile_manager, "resolve_environment_variables", @@ -332,4 +362,360 @@ async def test_init_non_interactive_with_region_and_token( api_url=None, project_name="test-project", project_id=None, + output_mode="table", + non_interactive=True, + ) + + +@pytest.mark.asyncio +async def test_init_json_mode_without_non_interactive() -> None: + """Test that JSON output mode requires non-interactive flag.""" + import json + + from io import StringIO + + output = StringIO() + + with patch.object(init_module.click, "echo", lambda msg: output.write(msg)): + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region="us", + api_token="test-token", + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=False, + output_mode="json", + ) + + result = json.loads(output.getvalue()) + assert result["status"] == "error" + assert "non-interactive" in result["error"] + assert result["error_code"] == "INVALID_OPTIONS" + + +@pytest.mark.asyncio +async def test_init_json_mode_with_validation_errors() -> None: + """Test that validation errors in JSON mode return proper JSON.""" + import json + + from io import StringIO + + output = StringIO() + + with patch.object(init_module.click, "echo", lambda msg: output.write(msg)): + # Test missing profile and credentials + assert init_module.init.callback + await init_module.init.callback( + profile=None, + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=True, + output_mode="json", + ) + + result = json.loads(output.getvalue()) + assert result["status"] == "error" + assert "profile" in result["error"] or "region" in result["error"] + assert result["error_code"] == "MISSING_REQUIRED_OPTIONS" + + +@pytest.mark.asyncio +async def test_init_non_empty_directory_non_interactive_json( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test non-empty directory error in non-interactive JSON mode.""" + mock_config_manager = Mock() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "file.txt").write_text("content") + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="test-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=project_dir, + ), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + output = StringIO() + monkeypatch.setattr(init_module.click, "echo", lambda msg: output.write(msg)) + + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=True, + output_mode="json", + ) + + result = json.loads(output.getvalue()) + assert result["status"] == "error" + assert "not empty" in result["error"] + assert result["error_code"] == "DIRECTORY_NOT_EMPTY" + + +@pytest.mark.asyncio +async def test_init_non_empty_directory_non_interactive_table( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test non-empty directory error in non-interactive table mode.""" + mock_config_manager = Mock() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "file.txt").write_text("content") + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="test-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=project_dir, + ), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + monkeypatch.setattr(init_module.click, "echo", lambda _: None) + + assert init_module.init.callback + with pytest.raises(click.Abort): + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=True, + output_mode="table", + ) + + +@pytest.mark.asyncio +async def test_init_non_empty_directory_interactive_cancelled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test user cancelling init when directory is non-empty.""" + mock_config_manager = Mock() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "file.txt").write_text("content") + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="test-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=project_dir, + ), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + monkeypatch.setattr(init_module.click, "echo", lambda _: None) + monkeypatch.setattr(init_module.click, "confirm", lambda *args, **kwargs: False) + + mock_pull = AsyncMock() + monkeypatch.setattr(init_module, "_pull_project", mock_pull) + + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=False, + output_mode="table", + ) + + # Should not call pull when user cancels + mock_pull.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_init_non_empty_directory_interactive_confirmed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Test user confirming init when directory is non-empty.""" + mock_config_manager = Mock() + mock_workato_client = Mock() + workato_context = AsyncMock() + project_dir = tmp_path / "project" + project_dir.mkdir() + (project_dir / "file.txt").write_text("content") + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock(profile="test-profile"), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=project_dir, + ), + patch.object( + mock_config_manager.profile_manager, + "resolve_environment_variables", + return_value=("test-token", "https://api.workato.com"), + ), + patch.object(workato_context, "__aenter__", return_value=mock_workato_client), + patch.object(workato_context, "__aexit__", return_value=False), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, ) + + mock_pull = AsyncMock() + monkeypatch.setattr(init_module, "_pull_project", mock_pull) + monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context) + monkeypatch.setattr(init_module, "Configuration", lambda **_: Mock()) + + monkeypatch.setattr(init_module.click, "echo", lambda msg="": None) + monkeypatch.setattr(init_module.click, "confirm", lambda *args, **kwargs: True) + + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=False, + output_mode="table", + ) + + # Should call pull when user confirms + mock_pull.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_init_json_output_mode_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test successful init with JSON output mode.""" + import json + + from io import StringIO + + mock_config_manager = Mock() + mock_workato_client = Mock() + workato_context = AsyncMock() + + with ( + patch.object( + mock_config_manager, + "load_config", + return_value=Mock( + profile="test-profile", + project_name="test-project", + project_id=123, + folder_id=456, + ), + ), + patch.object( + mock_config_manager, + "get_project_directory", + return_value=None, + ), + patch.object( + mock_config_manager.profile_manager, + "resolve_environment_variables", + return_value=("test-token", "https://api.workato.com"), + ), + patch.object( + mock_config_manager.profile_manager, + "get_profile", + return_value=Mock( + region="us", + region_name="US", + region_url="https://api.workato.com", + workspace_id=789, + ), + ), + patch.object( + mock_config_manager.profile_manager, + "get_current_profile_name", + return_value="test-profile", + ), + patch.object(workato_context, "__aenter__", return_value=mock_workato_client), + patch.object(workato_context, "__aexit__", return_value=False), + ): + mock_initialize = AsyncMock(return_value=mock_config_manager) + monkeypatch.setattr( + init_module.ConfigManager, + "initialize", + mock_initialize, + ) + + mock_pull = AsyncMock() + monkeypatch.setattr(init_module, "_pull_project", mock_pull) + monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context) + monkeypatch.setattr(init_module, "Configuration", lambda **_: Mock()) + + output = StringIO() + monkeypatch.setattr( + init_module.click, "echo", lambda msg: output.write(str(msg)) + ) + + assert init_module.init.callback + await init_module.init.callback( + profile="test-profile", + region=None, + api_token=None, + api_url=None, + project_name="test-project", + project_id=None, + non_interactive=True, + output_mode="json", + ) + + result = json.loads(output.getvalue()) + assert result["status"] == "success" + assert result["profile"]["name"] == "test-profile" + assert result["profile"]["region"] == "us" + assert result["project"]["name"] == "test-project" + assert result["project"]["id"] == 123 diff --git a/tests/unit/commands/test_profiles.py b/tests/unit/commands/test_profiles.py index b2bfbb1..f8adc35 100644 --- a/tests/unit/commands/test_profiles.py +++ b/tests/unit/commands/test_profiles.py @@ -1,5 +1,7 @@ """Focused tests for the profiles command module.""" +import json + from collections.abc import Callable from pathlib import Path from unittest.mock import Mock, patch @@ -667,8 +669,6 @@ async def test_list_profiles_json_output_mode( output = capsys.readouterr().out # Parse JSON output - import json - parsed = json.loads(output) assert parsed["current_profile"] == "dev" @@ -697,9 +697,259 @@ async def test_list_profiles_json_output_mode_empty( output = capsys.readouterr().out # Parse JSON output - import json - parsed = json.loads(output) assert parsed["current_profile"] is None assert parsed["profiles"] == {} + + +@pytest.mark.asyncio +async def test_status_json_no_profile( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test status JSON output when no profile is configured.""" + config_manager = make_config_manager( + get_current_profile_name=Mock(return_value=None), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["profile"] is None + assert parsed["error"] == "No active profile configured" + + +@pytest.mark.asyncio +async def test_status_json_with_project_override( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + tmp_path: Path, +) -> None: + """Test status JSON output with project override.""" + config_manager = make_config_manager( + load_config=Mock( + return_value=Mock( + profile="dev-profile", + project_id=123, + project_name="Test Project", + folder_id=456, + ) + ), + get_current_profile_name=Mock(return_value="dev-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=("test-token", "https://www.workato.com") + ), + get_workspace_root=Mock(return_value=tmp_path), + get_project_directory=Mock(return_value=tmp_path / "project"), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["profile"]["name"] == "dev-profile" + assert parsed["profile"]["source"]["type"] == "project_override" + assert parsed["profile"]["source"]["location"] == ".workatoenv" + assert parsed["profile"]["configuration"]["region"]["code"] == "us" + assert parsed["profile"]["configuration"]["workspace_id"] == 789 + assert parsed["authentication"]["configured"] is True + assert parsed["authentication"]["source"]["type"] == "keyring" + assert parsed["project"]["configured"] is True + assert "Test Project" in str(parsed["project"]["metadata"]["name"]) + + +@pytest.mark.asyncio +async def test_status_json_with_env_profile( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test status JSON output with environment variable profile.""" + monkeypatch.setenv("WORKATO_PROFILE", "env-profile") + + config_manager = make_config_manager( + load_config=Mock(return_value=Mock(profile=None)), + get_current_profile_name=Mock(return_value="env-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=("test-token", "https://www.workato.com") + ), + get_workspace_root=Mock(return_value=None), + get_project_directory=Mock(return_value=None), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["profile"]["name"] == "env-profile" + assert parsed["profile"]["source"]["type"] == "environment_variable" + assert parsed["profile"]["source"]["location"] == "WORKATO_PROFILE" + assert parsed["project"]["configured"] is False + + +@pytest.mark.asyncio +async def test_status_json_with_env_token( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test status JSON output with environment variable token.""" + monkeypatch.setenv("WORKATO_API_TOKEN", "env-token") + + config_manager = make_config_manager( + load_config=Mock(return_value=Mock(profile=None)), + get_current_profile_name=Mock(return_value="default-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=("env-token", "https://www.workato.com") + ), + get_workspace_root=Mock(return_value=None), + get_project_directory=Mock(return_value=None), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["authentication"]["configured"] is True + assert parsed["authentication"]["source"]["type"] == "environment_variable" + assert parsed["authentication"]["source"]["location"] == "WORKATO_API_TOKEN" + + +@pytest.mark.asyncio +async def test_status_json_no_token( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test status JSON output with no authentication token.""" + config_manager = make_config_manager( + load_config=Mock(return_value=Mock(profile=None)), + get_current_profile_name=Mock(return_value="default-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=(None, "https://www.workato.com") + ), + get_workspace_root=Mock(return_value=None), + get_project_directory=Mock(return_value=None), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["authentication"]["configured"] is False + + +@pytest.mark.asyncio +async def test_status_json_project_path_none( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], + tmp_path: Path, +) -> None: + """Test status JSON output when project path doesn't exist.""" + config_manager = make_config_manager( + load_config=Mock( + return_value=Mock( + profile=None, + project_id=123, + project_name="Test Project", + folder_id=456, + ) + ), + get_current_profile_name=Mock(return_value="default-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=("test-token", "https://www.workato.com") + ), + get_workspace_root=Mock(return_value=tmp_path), + get_project_directory=Mock(return_value=None), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["project"]["configured"] is False + + +@pytest.mark.asyncio +async def test_status_json_exception_handling( + capsys: pytest.CaptureFixture[str], + make_config_manager: Callable[..., Mock], +) -> None: + """Test status JSON output handles exceptions gracefully.""" + config_manager = make_config_manager( + load_config=Mock(return_value=Mock(profile=None)), + get_current_profile_name=Mock(return_value="default-profile"), + get_current_profile_data=Mock( + return_value=Mock( + region="us", + region_name="US Data Center", + region_url="https://www.workato.com", + workspace_id=789, + ) + ), + resolve_environment_variables=Mock( + return_value=("test-token", "https://www.workato.com") + ), + get_workspace_root=Mock(side_effect=Exception("Test exception")), + ) + + assert status.callback + await status.callback(output_mode="json", config_manager=config_manager) + + output = capsys.readouterr().out + parsed = json.loads(output) + + assert parsed["project"]["configured"] is False diff --git a/tests/unit/config/test_manager.py b/tests/unit/config/test_manager.py index 859e0b0..3a59acf 100644 --- a/tests/unit/config/test_manager.py +++ b/tests/unit/config/test_manager.py @@ -211,7 +211,11 @@ async def test_initialize_non_interactive_branch( ) manager = await ConfigManager.initialize( - tmp_path, profile_name="dev", region="us", api_token="token" + tmp_path, + profile_name="dev", + region="us", + api_token="token", + non_interactive=True, ) assert isinstance(manager, ConfigManager) @@ -379,7 +383,7 @@ async def test_setup_non_interactive_custom_region_subdirectory( workspace_env = json.loads( (tmp_path / ".workatoenv").read_text(encoding="utf-8") ) - assert workspace_env["project_path"] == "subdir" + assert workspace_env["project_path"] == "subdir/CustomProj" assert StubProjectManager.created_projects[-1].name == "CustomProj" @pytest.mark.asyncio diff --git a/tests/unit/utils/test_exception_handler.py b/tests/unit/utils/test_exception_handler.py index 1d38b16..7061411 100644 --- a/tests/unit/utils/test_exception_handler.py +++ b/tests/unit/utils/test_exception_handler.py @@ -446,3 +446,198 @@ def test_extract_error_details_non_string_errors(self) -> None: # Should handle non-string errors gracefully result = _extract_error_details(exc) assert "Validation error:" in result + + # JSON output mode tests + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_bad_request( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for BadRequestException""" + from workato_platform.client.workato_api.exceptions import BadRequestException + + # Mock Click context to return json output mode + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def bad_request_json() -> None: + raise BadRequestException(status=400, reason="Bad request") + + bad_request_json() + + # Should output JSON + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('{"status": "error"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_unauthorized( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for UnauthorizedException""" + from workato_platform.client.workato_api.exceptions import UnauthorizedException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def unauthorized_json() -> None: + raise UnauthorizedException(status=401, reason="Unauthorized") + + unauthorized_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "UNAUTHORIZED"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_forbidden( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for ForbiddenException""" + from workato_platform.client.workato_api.exceptions import ForbiddenException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def forbidden_json() -> None: + raise ForbiddenException(status=403, reason="Forbidden") + + forbidden_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "FORBIDDEN"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_not_found( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for NotFoundException""" + from workato_platform.client.workato_api.exceptions import NotFoundException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def not_found_json() -> None: + raise NotFoundException(status=404, reason="Not found") + + not_found_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "NOT_FOUND"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_conflict( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for ConflictException""" + from workato_platform.client.workato_api.exceptions import ConflictException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def conflict_json() -> None: + raise ConflictException(status=409, reason="Conflict") + + conflict_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "CONFLICT"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_server_error( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for ServiceException""" + from workato_platform.client.workato_api.exceptions import ServiceException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def server_error_json() -> None: + raise ServiceException(status=500, reason="Server error") + + server_error_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "SERVER_ERROR"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_generic_api_error( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output for generic ApiException""" + from workato_platform.client.workato_api.exceptions import ApiException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def generic_error_json() -> None: + raise ApiException(status=418, reason="I'm a teapot") + + generic_error_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any('"error_code": "API_ERROR"' in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.echo") + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_json_output_with_error_details( + self, mock_get_context: MagicMock, mock_echo: MagicMock + ) -> None: + """Test JSON output includes error details from body""" + from workato_platform.client.workato_api.exceptions import BadRequestException + + mock_ctx = MagicMock() + mock_ctx.params = {"output_mode": "json"} + mock_get_context.return_value = mock_ctx + + @handle_api_exceptions + def with_details_json() -> None: + raise BadRequestException( + status=400, body='{"message": "Field validation failed"}' + ) + + with_details_json() + + call_args = [call[0][0] for call in mock_echo.call_args_list] + assert any("Field validation failed" in arg for arg in call_args) + + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_get_output_mode_no_context(self, mock_get_context: MagicMock) -> None: + """Test _get_output_mode returns 'table' when no context""" + from workato_platform.cli.utils.exception_handler import _get_output_mode + + mock_get_context.return_value = None + + result = _get_output_mode() + assert result == "table" + + @patch("workato_platform.cli.utils.exception_handler.click.get_current_context") + def test_get_output_mode_no_params(self, mock_get_context: MagicMock) -> None: + """Test _get_output_mode returns 'table' when context has no params""" + from workato_platform.cli.utils.exception_handler import _get_output_mode + + mock_ctx = MagicMock() + del mock_ctx.params # Remove params attribute + mock_get_context.return_value = mock_ctx + + result = _get_output_mode() + assert result == "table" From baa2ee2b0b8cf691d5e365b649af35c7921399bc Mon Sep 17 00:00:00 2001 From: j-madrone Date: Tue, 30 Sep 2025 15:17:57 +0100 Subject: [PATCH 2/2] Refactor unit tests to remove unused imports - Eliminated unnecessary imports of `json` and `StringIO` in the `test_init.py` file, streamlining the test code and improving readability. - Updated test functions to maintain functionality while enhancing clarity and maintainability. --- tests/unit/commands/test_init.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/unit/commands/test_init.py b/tests/unit/commands/test_init.py index 3456f4d..6c12627 100644 --- a/tests/unit/commands/test_init.py +++ b/tests/unit/commands/test_init.py @@ -370,10 +370,6 @@ async def test_init_non_interactive_with_region_and_token( @pytest.mark.asyncio async def test_init_json_mode_without_non_interactive() -> None: """Test that JSON output mode requires non-interactive flag.""" - import json - - from io import StringIO - output = StringIO() with patch.object(init_module.click, "echo", lambda msg: output.write(msg)): @@ -398,10 +394,6 @@ async def test_init_json_mode_without_non_interactive() -> None: @pytest.mark.asyncio async def test_init_json_mode_with_validation_errors() -> None: """Test that validation errors in JSON mode return proper JSON.""" - import json - - from io import StringIO - output = StringIO() with patch.object(init_module.click, "echo", lambda msg: output.write(msg)): @@ -637,10 +629,6 @@ async def test_init_json_output_mode_success( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test successful init with JSON output mode.""" - import json - - from io import StringIO - mock_config_manager = Mock() mock_workato_client = Mock() workato_context = AsyncMock()