This commit is contained in:
458
tests/test_edge_cases.py
Normal file
458
tests/test_edge_cases.py
Normal file
@@ -0,0 +1,458 @@
|
||||
# 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"
|
||||
Reference in New Issue
Block a user