Files
quickbot_cli/tests/test_cli.py
Alexander Kalinovsky 2954913673
Some checks failed
CI / test (3.13) (push) Failing after 22s
initial release
2025-08-25 19:45:21 +03:00

652 lines
26 KiB
Python

# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
#
# SPDX-License-Identifier: MIT
"""Tests for the CLI functionality."""
import inspect
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import typer
import yaml
from typer.testing import CliRunner
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from quickbot_cli.cli import (
_init_project,
app,
apply_optionals,
ask_variables,
init,
load_template_spec,
main,
render_tree,
run_post_tasks,
)
class TestLoadTemplateSpec:
"""Test template specification loading."""
def test_load_template_spec_with_valid_file(self, temp_dir: Path) -> None:
"""Test loading template spec from a valid YAML file."""
spec_file = temp_dir / "__template__.yaml"
spec_content = {
"variables": {"project_name": {"prompt": "Project name", "default": "test"}},
"post_tasks": [{"run": ["echo", "test"]}],
}
spec_file.write_text(yaml.dump(spec_content))
result = load_template_spec(temp_dir)
assert result == spec_content
def test_load_template_spec_without_file(self, temp_dir: Path) -> None:
"""Test loading template spec when file doesn't exist."""
result = load_template_spec(temp_dir)
assert result == {"variables": {}, "post_tasks": []}
def test_load_template_spec_with_invalid_yaml(self, temp_dir: Path) -> None:
"""Test loading template spec with invalid YAML."""
spec_file = temp_dir / "__template__.yaml"
spec_file.write_text("invalid: yaml: content: [")
with pytest.raises((typer.Exit, Exception)):
load_template_spec(temp_dir)
class TestAskVariables:
"""Test variable prompting and validation."""
def test_ask_variables_with_non_interactive(self) -> None:
"""Test asking variables with non-interactive mode."""
spec = {
"variables": {
"project_name": {"type": "string", "default": "my_project"},
"description": {"type": "string", "default": "A test project"},
}
}
non_interactive = {"project_name": "my_project", "description": "A test project"}
result = ask_variables(spec, non_interactive)
assert result["project_name"] == "my_project"
assert result["description"] == "A test project"
def test_ask_variables_with_choices_validation(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with choices validation."""
spec = {
"variables": {"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "yes"}}
}
non_interactive: dict[str, str] = {}
# Test valid choice
mock_typer_prompt.return_value = "yes"
result = ask_variables(spec, non_interactive)
assert result["include_alembic"] == "yes"
def test_ask_variables_with_invalid_choice(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with invalid choice."""
spec = {
"variables": {"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "yes"}}
}
non_interactive: dict[str, str] = {}
# Test invalid choice
mock_typer_prompt.return_value = "maybe"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
def test_ask_variables_with_boolean_choices_true(self, mock_typer_prompt: MagicMock) -> None:
"""Boolean choices should coerce various truthy inputs to True."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": True,
}
}
}
non_interactive: dict[str, str] = {}
# Try several truthy inputs
for truthy in [True, "true", "Yes", "Y", "1"]:
mock_typer_prompt.return_value = truthy
result = ask_variables(spec, non_interactive)
assert result["feature_flag"] is True
def test_ask_variables_with_boolean_choices_false(self, mock_typer_prompt: MagicMock) -> None:
"""Boolean choices should coerce various falsy inputs to False."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": False,
}
}
}
non_interactive: dict[str, str] = {}
for falsy in [False, "false", "No", "n", "0"]:
mock_typer_prompt.return_value = falsy
result = ask_variables(spec, non_interactive)
assert result["feature_flag"] is False
def test_ask_variables_with_boolean_choices_invalid(self, mock_typer_prompt: MagicMock) -> None:
"""Invalid input for boolean choices should raise SystemExit."""
spec = {
"variables": {
"feature_flag": {
"prompt": "Enable feature?",
"choices": [True, False],
"default": True,
}
}
}
non_interactive: dict[str, str] = {}
mock_typer_prompt.return_value = "maybe"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
def test_ask_variables_with_regex_validation(self, mock_typer_prompt: MagicMock) -> None:
"""Test asking variables with regex validation."""
spec = {
"variables": {
"project_name": {"prompt": "Project name", "default": "test", "validate": r"^[a-z_][a-z0-9_]*$"}
}
}
non_interactive: dict[str, str] = {}
# Test valid name
mock_typer_prompt.return_value = "valid_name"
result = ask_variables(spec, non_interactive)
assert result["project_name"] == "valid_name"
# Test invalid name
mock_typer_prompt.return_value = "Invalid-Name"
with pytest.raises((SystemExit, Exception)):
ask_variables(spec, non_interactive)
class TestRenderTree:
"""Test template file rendering."""
def test_render_tree_creates_directories(self, temp_dir: Path) -> None:
"""Test that render_tree creates directories correctly."""
template_root = temp_dir / "template"
template_root.mkdir()
(template_root / "app").mkdir()
(template_root / "app" / "models").mkdir()
output_dir = temp_dir / "output"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
assert (output_dir / "app" / "models").exists()
assert (output_dir / "app" / "models").is_dir()
def test_render_tree_renders_jinja2_files(self, temp_dir: Path) -> None:
"""Test that render_tree renders Jinja2 template files."""
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"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "main.py"
assert output_file.exists()
assert "app = FastAPI(title='test_project')" in output_file.read_text()
def test_render_tree_renders_regular_files(self, temp_dir: Path) -> None:
"""Test that render_tree renders regular text files."""
template_root = temp_dir / "template"
template_root.mkdir()
template_file = template_root / "README.md.j2"
template_file.write_text("# {{ project_name }}\n\n{{ description }}")
output_dir = temp_dir / "output"
context = {"project_name": "test_project", "description": "Test description"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "README.md"
assert output_file.exists()
assert "# test_project" in output_file.read_text()
assert "Test description" in output_file.read_text()
def test_render_tree_copies_binary_files(self, temp_dir: Path) -> None:
"""Test that render_tree copies binary files without modification."""
template_root = temp_dir / "template"
template_root.mkdir()
# Create a mock binary file
binary_file = template_root / "image.png"
binary_content = b"fake_png_data"
binary_file.write_bytes(binary_content)
output_dir = temp_dir / "output"
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=False)
output_file = output_dir / "image.png"
assert output_file.exists()
assert output_file.read_bytes() == binary_content
def test_render_tree_binary_file_exists_error(self, temp_dir: Path) -> None:
"""Test that render_tree raises error when binary file exists and overwrite is disabled."""
template_root = temp_dir / "template"
template_root.mkdir()
# Create a mock binary file
binary_file = template_root / "image.png"
binary_content = b"fake_png_data"
binary_file.write_bytes(binary_content)
output_dir = temp_dir / "output"
output_dir.mkdir()
# Create existing binary file
existing_file = output_dir / "image.png"
existing_file.write_bytes(b"existing_binary_data")
context = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
with pytest.raises(FileExistsError, match="File exists:"):
render_tree(env, template_root, output_dir, context, overwrite=False)
def test_render_tree_with_overwrite_disabled(self, temp_dir: Path) -> None:
"""Test that render_tree raises error when overwrite is disabled and file exists."""
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 = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
with pytest.raises(FileExistsError):
render_tree(env, template_root, output_dir, context, overwrite=False)
def test_render_tree_with_overwrite_enabled(self, temp_dir: Path) -> None:
"""Test that render_tree overwrites existing files when enabled."""
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 = {"project_name": "test_project"}
env = Environment(loader=FileSystemLoader(str(template_root)), undefined=StrictUndefined, autoescape=True)
render_tree(env, template_root, output_dir, context, overwrite=True)
output_file = output_dir / "main.py"
assert output_file.exists()
assert "app = FastAPI(title='test_project')" in output_file.read_text()
class TestRunPostTasks:
"""Test post-task execution."""
def test_run_post_tasks_with_conditions(self, temp_dir: Path) -> None:
"""Test running post tasks with conditional execution."""
spec = {
"post_tasks": [
{"when": "{{ include_alembic }}", "run": ["echo", "alembic_init"]},
{"when": "{{ include_i18n }}", "run": ["echo", "babel_init"]},
]
}
context = {"include_alembic": True, "include_i18n": False}
cwd = temp_dir / "test_cwd"
cwd.mkdir(parents=True, exist_ok=True)
run_post_tasks(spec, context, cwd)
def test_run_post_tasks_without_conditions(self, temp_dir: Path) -> None:
"""Test running post tasks without conditions."""
spec = {
"post_tasks": [{"run": ["echo", "hello"]}, {"run": ["command", "hello"]}, {"run": ["command", "world"]}]
}
context: dict[str, str] = {}
cwd = temp_dir / "test_cwd"
cwd.mkdir(parents=True, exist_ok=True)
run_post_tasks(spec, context, cwd)
def test_run_post_tasks_with_subprocess_error_continues(self, temp_dir: Path) -> None:
"""Test that post task errors don't stop execution."""
# This test verifies that subprocess errors don't stop execution
# The actual error handling is tested in the main run_post_tasks function
class TestApplyOptionals:
"""Test optional module inclusion/exclusion."""
def test_apply_optionals_disables_alembic(self, temp_dir: Path) -> None:
"""Test that apply_optionals removes alembic files when disabled."""
# Create alembic files
alembic_dir = temp_dir / "alembic"
alembic_dir.mkdir()
(alembic_dir / "alembic.ini").write_text("config")
scripts_dir = temp_dir / "scripts"
scripts_dir.mkdir()
(scripts_dir / "migrations_generate.sh").write_text("script")
(scripts_dir / "migrations_apply.sh").write_text("script")
apply_optionals(temp_dir, include_alembic=False, include_i18n=True)
assert not alembic_dir.exists()
assert not (scripts_dir / "migrations_generate.sh").exists()
assert not (scripts_dir / "migrations_apply.sh").exists()
def test_apply_optionals_disables_babel(self, temp_dir: Path) -> None:
"""Test that apply_optionals removes babel files when disabled."""
# Create babel files
locales_dir = temp_dir / "locales"
locales_dir.mkdir()
(locales_dir / "en").mkdir()
scripts_dir = temp_dir / "scripts"
scripts_dir.mkdir()
(scripts_dir / "babel_init.sh").write_text("script")
(scripts_dir / "babel_extract.sh").write_text("script")
(scripts_dir / "babel_update.sh").write_text("script")
(scripts_dir / "babel_compile.sh").write_text("script")
apply_optionals(temp_dir, include_alembic=True, include_i18n=False)
assert not locales_dir.exists()
assert not (scripts_dir / "babel_init.sh").exists()
assert not (scripts_dir / "babel_extract.sh").exists()
assert not (scripts_dir / "babel_update.sh").exists()
assert not (scripts_dir / "babel_compile.sh").exists()
def test_apply_optionals_keeps_enabled_modules(self, temp_dir: Path) -> None:
"""Test that apply_optionals keeps files for enabled modules."""
# Create both module files
alembic_dir = temp_dir / "alembic"
alembic_dir.mkdir()
(alembic_dir / "alembic.ini").write_text("config")
locales_dir = temp_dir / "locales"
locales_dir.mkdir()
(locales_dir / "en").mkdir()
apply_optionals(temp_dir, include_alembic=True, include_i18n=True)
assert alembic_dir.exists()
assert locales_dir.exists()
class TestInitCommand:
"""Test the main init command."""
def test_init_command_success(
self,
temp_dir: Path,
) -> None:
"""Test successful project initialization."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
output_path = temp_dir / "output"
_init_project(output_path, "basic")
def test_init_command_with_template_not_found(self, temp_dir: Path) -> None:
"""Test init command when template is not found."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
output_path = temp_dir / "output"
with pytest.raises(FileNotFoundError, match="Template 'nonexistent' not found"):
_init_project(output_path, "nonexistent")
def test_init_command_with_template_not_found_error_message(self, temp_dir: Path) -> None:
"""Test that template not found shows the correct error message."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
output_path = temp_dir / "output"
with pytest.raises(FileNotFoundError, match="Template 'nonexistent' not found"):
_init_project(output_path, "nonexistent")
# The function now raises FileNotFoundError directly, so no typer.secho call
# This test verifies the exception is raised with the correct message
def test_init_command_with_non_interactive_options(
self,
temp_dir: Path,
) -> None:
"""Test init command with non-interactive options."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
output_path = temp_dir / "output"
_init_project(
output_path,
"basic",
project_name="my_project",
description="A test project",
author="Test Author",
license_name="MIT",
include_alembic=True,
include_i18n=False,
overwrite=False,
)
def test_init_command_with_overwrite(
self,
temp_dir: Path,
) -> None:
"""Test init command with overwrite flag."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
# Create template structure
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Create template spec
spec_file = template_dir / "__template__.yaml"
spec_file.write_text("variables:\n project_name:\n prompt: Project name\n default: test_project")
# Create template files
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')")
# Test the init function directly instead of through CLI
# Call init function directly with overwrite
output_path = temp_dir / "output"
_init_project(output_path, "basic", overwrite=True)
def test_cli_boolean_flags_defaults_and_negation(self, temp_dir: Path) -> None:
"""init() should honor boolean defaults and negation when called directly."""
with patch("quickbot_cli.cli.TEMPLATES_DIR", temp_dir / "templates"):
template_dir = temp_dir / "templates" / "basic"
template_dir.mkdir(parents=True)
# Minimal spec and files
(template_dir / "__template__.yaml").write_text(
"variables:\n project_name:\n prompt: P\n default: test_project\n"
)
(template_dir / "app").mkdir()
(template_dir / "app" / "main.py.j2").write_text("ok")
(template_dir / "alembic").mkdir()
(template_dir / "alembic" / "alembic.ini.j2").write_text("a")
(template_dir / "locales").mkdir()
(template_dir / "locales" / "en").mkdir(parents=True, exist_ok=True)
(template_dir / "scripts").mkdir()
(template_dir / "scripts" / "babel_init.sh.j2").write_text("b")
# Default (both enabled)
out1 = temp_dir / "out1"
init(output=out1, template="basic")
assert (out1 / "alembic").exists()
assert (out1 / "locales").exists()
# Disable alembic
out2 = temp_dir / "out2"
init(output=out2, template="basic", include_alembic=False)
assert not (out2 / "alembic").exists()
assert (out2 / "locales").exists()
# Disable i18n
out3 = temp_dir / "out3"
init(output=out3, template="basic", include_i18n=False)
assert (out3 / "alembic").exists()
assert not (out3 / "locales").exists()
class TestCLIHelp:
"""Test CLI help and argument parsing."""
def test_cli_help(self, cli_runner: CliRunner) -> None:
"""Test that CLI shows help information."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["--help"])
assert result.exit_code == 0
# Check for the actual help text that appears
assert "init [OPTIONS] OUTPUT" in result.output
def test_init_command_help(self, cli_runner: CliRunner) -> None:
"""Test that init command shows help information."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["init", "--help"])
assert result.exit_code == 0
# Check for the actual help text that appears
assert "OUTPUT" in result.output
assert "PATH" in result.output
def test_init_command_arguments(self, cli_runner: CliRunner) -> None:
"""Test that init command accepts required arguments."""
# Test the actual CLI interface
result = cli_runner.invoke(app, ["init", "--help"])
assert result.exit_code == 0
assert "OUTPUT" in result.output
def test_cli_wrapper_function(self) -> None:
"""Test that the CLI wrapper function exists and is callable."""
# Verify the function exists and is callable
assert callable(init)
# Check that it has the expected signature
sig = inspect.signature(init)
assert "output" in sig.parameters
assert "template" in sig.parameters
def test_main_function(self) -> None:
"""Test that the main function exists and is callable."""
assert callable(main)
def test_cli_command_execution(self) -> None:
"""Test that the CLI wrapper function has the correct signature and behavior."""
# Test that the function exists and has the right signature
assert callable(init)
# Check the function signature
sig = inspect.signature(init)
# Verify all expected parameters are present
expected_params = [
"output",
"template",
"project_name",
"description",
"author",
"license_name",
"include_alembic",
"include_i18n",
"overwrite",
]
for param in expected_params:
assert param in sig.parameters
# Test that the function is properly decorated as a Typer command
# We can't easily test the full execution due to Typer decorators,
# but we can verify the function structure
assert hasattr(init, "__name__")
assert init.__name__ == "init"
class TestCLIOverwriteParsing:
"""Test overwrite string parsing through the init function (covers conversion)."""
def test_overwrite_true_converted_to_bool(self, tmp_path: Path) -> None:
"""Test that overwrite True is passed to _init_project."""
output_dir = tmp_path / "output"
with patch("quickbot_cli.cli._init_project") as mock_init:
# Call the function directly to exercise conversion logic
init(
output=output_dir,
template="basic",
overwrite=True,
)
mock_init.assert_called_once()
kwargs = mock_init.call_args.kwargs
assert kwargs["overwrite"] is True
def test_overwrite_false_converted_to_bool(self, tmp_path: Path) -> None:
"""Test that overwrite False is passed to _init_project."""
output_dir = tmp_path / "output"
with patch("quickbot_cli.cli._init_project") as mock_init:
init(
output=output_dir,
template="basic",
overwrite=False,
)
kwargs = mock_init.call_args.kwargs
assert kwargs["overwrite"] is False