# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky # # SPDX-License-Identifier: Apache-2.0 """Tests for edge cases and error handling in the CLI.""" import sys from importlib.metadata import PackageNotFoundError from pathlib import Path from subprocess import CalledProcessError from typing import Any from unittest.mock import MagicMock, patch import pytest import typer sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import jinja2 from jinja2 import Environment, FileSystemLoader, StrictUndefined from quickbot_cli.cli import ( ask_variables, load_template_spec, render_tree, run_post_tasks, ) class TestEdgeCases: """Test edge cases and error handling.""" def test_load_template_spec_with_empty_file(self, temp_dir: Path) -> None: """Test loading template spec from an empty file.""" spec_file = temp_dir / "__template__.yaml" spec_file.write_text("") result = load_template_spec(temp_dir) assert result == {"variables": {}, "post_tasks": []} def test_load_template_spec_with_malformed_yaml(self, temp_dir: Path) -> None: """Test loading template spec with malformed YAML.""" spec_file = temp_dir / "__template__.yaml" spec_file.write_text("variables:\n - invalid: list: structure:") with pytest.raises((typer.Exit, Exception)): load_template_spec(temp_dir) def test_ask_variables_with_empty_spec(self, mock_typer_prompt: MagicMock) -> None: """Test asking variables with empty specification.""" spec: dict[str, Any] = {"variables": {}} non_interactive: dict[str, Any] = {} result = ask_variables(spec, non_interactive) assert result == {"package_name": "app"} mock_typer_prompt.assert_not_called() def test_ask_variables_with_none_values(self, mock_typer_prompt: MagicMock) -> None: """Test asking variables with None values in non_interactive.""" spec: dict[str, Any] = {"variables": {"project_name": {"prompt": "Project name", "default": "default"}}} non_interactive: dict[str, Any] = {"project_name": None} mock_typer_prompt.return_value = "prompted_value" result = ask_variables(spec, non_interactive) assert result["project_name"] == "prompted_value" mock_typer_prompt.assert_called_once() def test_ask_variables_with_empty_string_choices(self, mock_typer_prompt: MagicMock) -> None: """Test asking variables with empty string choices.""" spec: dict[str, Any] = { "variables": { "choice_var": {"prompt": "Choose option", "choices": ["", "option1", "option2"], "default": "option1"} } } non_interactive: dict[str, Any] = {} # Test empty string choice mock_typer_prompt.return_value = "" result = ask_variables(spec, non_interactive) assert result["choice_var"] == "" def test_ask_variables_with_complex_regex_validation(self, mock_typer_prompt: MagicMock) -> None: """Test asking variables with complex regex validation.""" spec: dict[str, Any] = { "variables": { "email": { "prompt": "Email", "default": "test@example.com", "validate": r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", } } } non_interactive: dict[str, Any] = {} # Test valid email mock_typer_prompt.return_value = "user@domain.com" result = ask_variables(spec, non_interactive) assert result["email"] == "user@domain.com" # Test invalid email mock_typer_prompt.return_value = "invalid-email" with pytest.raises((SystemExit, Exception)): ask_variables(spec, non_interactive) def test_render_tree_with_empty_template(self, temp_dir: Path) -> None: """Test rendering tree with empty template directory.""" template_root = temp_dir / "template" template_root.mkdir() output_dir = temp_dir / "output" context: dict[str, Any] = {"project_name": "test"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) # Should not raise any errors render_tree(env, template_root, output_dir, context, overwrite=False) # Output directory should exist but be empty (except for __template__.yaml) assert output_dir.exists() assert len(list(output_dir.iterdir())) == 0 def test_render_tree_with_hidden_files(self, temp_dir: Path) -> None: """Test rendering tree with hidden files.""" template_root = temp_dir / "template" template_root.mkdir() # Create hidden files (template_root / ".gitignore.j2").write_text("*.pyc\n__pycache__/") (template_root / ".env.j2").write_text("DEBUG={{ debug_mode }}") output_dir = temp_dir / "output" context: dict[str, Any] = {"debug_mode": "true"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) render_tree(env, template_root, output_dir, context, overwrite=False) # Hidden files should be rendered assert (output_dir / ".gitignore").exists() assert (output_dir / ".env").exists() assert "DEBUG=true" in (output_dir / ".env").read_text() def test_render_tree_with_nested_directories(self, temp_dir: Path) -> None: """Test rendering tree with deeply nested directory structure.""" template_root = temp_dir / "template" template_root.mkdir() # Create deeply nested structure (template_root / "app" / "models" / "database" / "schemas").mkdir(parents=True) (template_root / "app" / "models" / "database" / "schemas" / "user.py.j2").write_text( "class User:\n name = '{{ project_name }}_user'" ) output_dir = temp_dir / "output" context: dict[str, Any] = {"project_name": "deep_nest"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) render_tree(env, template_root, output_dir, context, overwrite=False) # Deep structure should be preserved output_file = output_dir / "app" / "models" / "database" / "schemas" / "user.py" assert output_file.exists() assert "class User:" in output_file.read_text() assert "deep_nest_user" in output_file.read_text() def test_render_tree_with_binary_file_extension_case(self, temp_dir: Path) -> None: """Test rendering tree with case-sensitive binary file extensions.""" template_root = temp_dir / "template" template_root.mkdir() # Create files with different case extensions (template_root / "image.PNG").write_bytes(b"fake_png_data") (template_root / "document.PDF").write_bytes(b"fake_pdf_data") (template_root / "archive.ZIP").write_bytes(b"fake_zip_data") output_dir = temp_dir / "output" context: dict[str, Any] = {"project_name": "case_test"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) render_tree(env, template_root, output_dir, context, overwrite=False) # Case-sensitive extensions should be copied assert (output_dir / "image.PNG").exists() assert (output_dir / "document.PDF").exists() assert (output_dir / "archive.ZIP").exists() def test_run_post_tasks_with_empty_list(self, temp_dir: Path) -> None: """Test running post tasks with empty list.""" spec: dict[str, Any] = {"post_tasks": []} context: dict[str, Any] = {} cwd = temp_dir / "test_cwd" run_post_tasks(spec, context, cwd) def test_run_post_tasks_with_none(self, temp_dir: Path) -> None: """Test running post tasks with None value.""" spec: dict[str, Any] = {"post_tasks": None} context: dict[str, Any] = {} cwd = temp_dir / "test_cwd" run_post_tasks(spec, context, cwd) def test_run_post_tasks_with_missing_run_key(self, temp_dir: Path) -> None: """Test running post tasks with missing run key.""" spec: dict[str, Any] = {"post_tasks": [{"when": "{{ true }}", "description": "Task without run key"}]} context: dict[str, Any] = {"true": True} cwd = temp_dir / "test_cwd" run_post_tasks(spec, context, cwd) def test_run_post_tasks_with_complex_condition(self, temp_dir: Path, mock_subprocess_run: MagicMock) -> None: """Test running post tasks with complex conditional logic.""" spec: dict[str, Any] = { "post_tasks": [ { "when": "{{ include_alembic == 'yes' and database_type == 'postgresql' }}", "run": ["alembic", "init", "postgresql"], } ] } context: dict[str, Any] = {"include_alembic": "yes", "database_type": "postgresql"} cwd = temp_dir / "test_cwd" run_post_tasks(spec, context, cwd) mock_subprocess_run.assert_called_once_with(["alembic", "init", "postgresql"], cwd=cwd, check=True) def test_run_post_tasks_with_false_condition(self, temp_dir: Path, mock_subprocess_run: MagicMock) -> None: """Test running post tasks with false condition.""" spec: dict[str, Any] = {"post_tasks": [{"when": "{{ include_alembic == 'yes' }}", "run": ["alembic", "init"]}]} context: dict[str, Any] = {"include_alembic": "no"} cwd = temp_dir / "test_cwd" run_post_tasks(spec, context, cwd) mock_subprocess_run.assert_not_called() def test_post_tasks_with_missing_directories(self, temp_dir: Path) -> None: """Post tasks should tolerate missing directories and files.""" spec = { "post_tasks": [ { "when": "{{ not include_alembic }}", "run": [ "rm", "-rf", "alembic", "scripts/migrations_apply.sh", "scripts/migrations_generate.sh", ], }, { "when": "{{ not include_i18n }}", "run": [ "rm", "-rf", "locales", "scripts/babel_compile.sh", "scripts/babel_extract.sh", "scripts/babel_init.sh", "scripts/babel_update.sh", ], }, ] } run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert True def test_post_tasks_with_partial_structure(self, temp_dir: Path) -> None: """Post tasks should handle partial directory structure.""" (temp_dir / "alembic").mkdir() (temp_dir / "scripts").mkdir() (temp_dir / "scripts" / "migrations_generate.sh").write_text("script") spec = { "post_tasks": [ { "when": "{{ not include_alembic }}", "run": [ "rm", "-rf", "alembic", "scripts/migrations_generate.sh", ], } ] } run_post_tasks(spec, {"include_alembic": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "scripts" / "migrations_generate.sh").exists() def test_post_tasks_with_files_instead_of_directories(self, temp_dir: Path) -> None: """Post tasks should remove files named like directories as well.""" (temp_dir / "alembic").write_text("not a directory") (temp_dir / "locales").write_text("not a directory") spec = { "post_tasks": [ {"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]}, {"when": "{{ not include_i18n }}", "run": ["rm", "-rf", "locales"]}, ] } run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "locales").exists() class TestErrorHandling: """Test error handling scenarios.""" def test_ask_variables_with_invalid_choice_raises_system_exit(self, mock_typer_prompt: MagicMock) -> None: """Test that invalid choice raises SystemExit.""" spec: dict[str, Any] = { "variables": {"choice_var": {"prompt": "Choose option", "choices": ["yes", "no"], "default": "yes"}} } non_interactive: dict[str, Any] = {} mock_typer_prompt.return_value = "maybe" with pytest.raises((SystemExit, Exception)): ask_variables(spec, non_interactive) def test_ask_variables_with_invalid_regex_raises_system_exit(self, mock_typer_prompt: MagicMock) -> None: """Test that invalid regex validation raises SystemExit.""" spec: dict[str, Any] = { "variables": { "project_name": {"prompt": "Project name", "default": "test", "validate": r"^[a-z_][a-z0-9_]*$"} } } non_interactive: dict[str, Any] = {} mock_typer_prompt.return_value = "Invalid-Name" with pytest.raises((SystemExit, Exception)): ask_variables(spec, non_interactive) def test_render_tree_skips_existing_when_overwrite_disabled( self, temp_dir: Path, mock_typer_secho: MagicMock ) -> None: """Existing files are skipped (not overwritten) when overwrite is disabled.""" template_root = temp_dir / "template" template_root.mkdir() template_file = template_root / "main.py" template_file.write_text("app = FastAPI(title='{{ project_name }}')") output_dir = temp_dir / "output" output_dir.mkdir() # Create existing file existing_file = output_dir / "main.py" existing_file.write_text("existing content") context: dict[str, Any] = {"project_name": "test"} env = Environment(autoescape=True) render_tree(env, template_root, output_dir, context, overwrite=False) # Ensure original content remains assert (output_dir / "main.py").read_text() == "existing content" # Should show warning mock_typer_secho.assert_called_with( f"Warning: Skipping existing file: {output_dir / 'main.py'}", fg=typer.colors.YELLOW ) def test_render_tree_with_jinja_render_error(self, temp_dir: Path, mock_typer_secho: MagicMock) -> None: """Test that render_tree logs and re-raises when Jinja rendering fails.""" template_root = temp_dir / "template" template_root.mkdir() # Invalid Jinja to force a render error template_file = template_root / "broken.py.j2" template_file.write_text("{{ undefined_var | unknown_filter }}") output_dir = temp_dir / "output" context: dict[str, Any] = {} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) with pytest.raises((jinja2.exceptions.UndefinedError, jinja2.exceptions.TemplateError)): render_tree(env, template_root, output_dir, context, overwrite=False) # Ensure error was logged assert mock_typer_secho.call_count >= 1 def test_run_post_tasks_with_subprocess_error_continues( self, temp_dir: Path, mock_subprocess_run: MagicMock ) -> None: """Test that post task errors don't stop execution.""" mock_subprocess_run.side_effect = [ CalledProcessError(1, ["echo", "error1"]), MagicMock(returncode=0), ] spec = {"post_tasks": [{"run": ["echo", "error1"]}, {"run": ["echo", "success"]}]} context: dict[str, Any] = {} cwd = temp_dir / "test_cwd" # Should not raise exception run_post_tasks(spec, context, cwd) # Both tasks should be attempted expected_task_count = 2 assert mock_subprocess_run.call_count == expected_task_count class TestBoundaryConditions: """Test boundary conditions and limits.""" def test_ask_variables_with_very_long_input(self, mock_typer_prompt: MagicMock) -> None: """Test asking variables with very long input values.""" long_string_length = 10000 # 10KB string long_value = "a" * long_string_length spec = {"variables": {"long_var": {"prompt": "Long variable", "default": "default"}}} non_interactive: dict[str, Any] = {} mock_typer_prompt.return_value = long_value result = ask_variables(spec, non_interactive) assert result["long_var"] == long_value assert len(result["long_var"]) == long_string_length def test_render_tree_with_very_deep_nesting(self, temp_dir: Path) -> None: """Test rendering tree with very deep directory nesting.""" template_root = temp_dir / "template" template_root.mkdir() # Create deep nesting (10 levels to avoid filesystem limits) current = template_root for i in range(10): current = current / f"level_{i}" current.mkdir() # Add a file at the deepest level (current / "deep_file.py.j2").write_text("print('{{ project_name }}')") output_dir = temp_dir / "output" context = {"project_name": "deep_test"} env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True) # Should not raise any errors render_tree(env, template_root, output_dir, context, overwrite=False) # Deep structure should be preserved deep_file = ( output_dir / "level_0" / "level_1" / "level_2" / "level_3" / "level_4" / "level_5" / "level_6" / "level_7" / "level_8" / "level_9" / "deep_file.py" ) assert deep_file.exists() def test_post_tasks_with_many_files(self, temp_dir: Path) -> None: """Post tasks should remove only specific optional module directories.""" for i in range(100): (temp_dir / f"file_{i}.txt").write_text(f"content {i}") (temp_dir / "alembic").mkdir() (temp_dir / "locales").mkdir() spec = { "post_tasks": [ {"when": "{{ not include_alembic }}", "run": ["rm", "-rf", "alembic"]}, {"when": "{{ not include_i18n }}", "run": ["rm", "-rf", "locales"]}, ] } run_post_tasks(spec, {"include_alembic": False, "include_i18n": False}, temp_dir) assert not (temp_dir / "alembic").exists() assert not (temp_dir / "locales").exists() assert (temp_dir / "file_0.txt").exists() assert (temp_dir / "file_50.txt").exists() class TestPackageVersion: """Test package version handling.""" def test_package_version_fallback(self) -> None: """Test that package version falls back to '0.0.0' when package not found.""" # Test the fallback logic by patching the version function with patch("importlib.metadata.version") as mock_version: mock_version.side_effect = PackageNotFoundError("Package not found") # Import the module fresh to see the effect if "quickbot_cli" in sys.modules: del sys.modules["quickbot_cli"] # Now check the version import quickbot_cli # noqa: PLC0415 assert quickbot_cli.__version__ == "0.0.0"