Files
quickbot_cli/tests/test_edge_cases.py
Alexander Kalinovsky ab1aedd63e
All checks were successful
CI / test (3.13) (push) Successful in 42s
chore: update license from MIT to Apache-2.0 across all files
2025-08-26 19:38:15 +03:00

459 lines
18 KiB
Python

# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
#
# 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 (
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"