459 lines
18 KiB
Python
459 lines
18 KiB
Python
# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""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 (
|
|
apply_optionals,
|
|
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_apply_optionals_with_missing_directories(self, temp_dir: Path) -> None:
|
|
"""Test apply_optionals with missing directories."""
|
|
# Don't create any directories, just test the function
|
|
apply_optionals(temp_dir, include_alembic=False, include_i18n=False)
|
|
|
|
# Should not raise any errors
|
|
assert True
|
|
|
|
def test_apply_optionals_with_partial_structure(self, temp_dir: Path) -> None:
|
|
"""Test apply_optionals with partial directory structure."""
|
|
# Create only some of the expected directories
|
|
(temp_dir / "alembic").mkdir()
|
|
(temp_dir / "scripts").mkdir()
|
|
(temp_dir / "scripts" / "migrations_generate.sh").write_text("script")
|
|
|
|
# Don't create locales or babel scripts
|
|
|
|
apply_optionals(temp_dir, include_alembic=False, include_i18n=True)
|
|
|
|
# Alembic should be removed
|
|
assert not (temp_dir / "alembic").exists()
|
|
assert not (temp_dir / "scripts" / "migrations_generate.sh").exists()
|
|
|
|
def test_apply_optionals_with_files_instead_of_directories(self, temp_dir: Path) -> None:
|
|
"""Test apply_optionals with files instead of directories."""
|
|
# Create files with names that match expected directories
|
|
(temp_dir / "alembic").write_text("not a directory")
|
|
(temp_dir / "locales").write_text("not a directory")
|
|
|
|
apply_optionals(temp_dir, include_alembic=False, include_i18n=False)
|
|
|
|
# Files should be removed
|
|
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_with_file_exists_error(self, temp_dir: Path) -> None:
|
|
"""Test that render_tree raises FileExistsError when overwrite is disabled."""
|
|
template_root = temp_dir / "template"
|
|
template_root.mkdir()
|
|
|
|
template_file = template_root / "main.py.j2"
|
|
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)
|
|
|
|
with pytest.raises(FileExistsError):
|
|
render_tree(env, template_root, output_dir, context, overwrite=False)
|
|
|
|
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_apply_optionals_with_many_files(self, temp_dir: Path) -> None:
|
|
"""Test apply_optionals with many files to process."""
|
|
# Create many files
|
|
for i in range(100):
|
|
(temp_dir / f"file_{i}.txt").write_text(f"content {i}")
|
|
|
|
# Create the expected structure
|
|
(temp_dir / "alembic").mkdir()
|
|
(temp_dir / "locales").mkdir()
|
|
|
|
# Should not raise any errors
|
|
apply_optionals(temp_dir, include_alembic=False, include_i18n=False)
|
|
|
|
# Only the specific optional modules should be removed, not the random files
|
|
assert not (temp_dir / "alembic").exists()
|
|
assert not (temp_dir / "locales").exists()
|
|
# Random files should still exist
|
|
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"
|