This commit is contained in:
123
tests/README.md
Normal file
123
tests/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
|
||||
# CLI Tests
|
||||
|
||||
This directory contains comprehensive tests for the QuickBot CLI functionality.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### `conftest.py`
|
||||
Contains pytest fixtures and configuration:
|
||||
- `cli_runner`: Typer CLI test runner
|
||||
- `temp_dir`: Temporary directory for testing
|
||||
- `mock_template_dir`: Mock template directory structure
|
||||
- `mock_typer_prompt`: Mock for typer.prompt to avoid interactive input
|
||||
- `mock_typer_secho`: Mock for typer.secho output
|
||||
- `mock_typer_echo`: Mock for typer.echo output
|
||||
- `mock_subprocess_run`: Mock for subprocess.run
|
||||
|
||||
### `test_cli.py`
|
||||
Core unit tests covering:
|
||||
- Template specification loading
|
||||
- Variable prompting and validation
|
||||
- Template file rendering
|
||||
- Post-task execution
|
||||
- Optional module inclusion/exclusion
|
||||
- CLI command functionality
|
||||
- Help and argument parsing
|
||||
|
||||
### `test_integration.py`
|
||||
Integration tests covering:
|
||||
- Full project generation workflow
|
||||
- Module inclusion/exclusion scenarios
|
||||
- Overwrite functionality
|
||||
- End-to-end CLI operations
|
||||
|
||||
### `test_edge_cases.py`
|
||||
Edge case and error handling tests:
|
||||
- Boundary conditions
|
||||
- Error scenarios
|
||||
- Malformed input handling
|
||||
- Deep nesting and large files
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Using pytest directly
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=src/quickbot_cli --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_cli.py
|
||||
|
||||
# Run specific test class
|
||||
pytest tests/test_cli.py::TestLoadTemplateSpec
|
||||
|
||||
# Run specific test method
|
||||
pytest tests/test_cli.py::TestLoadTemplateSpec::test_load_template_spec_with_valid_file
|
||||
```
|
||||
|
||||
### Using the test runner script
|
||||
```bash
|
||||
python run_tests.py
|
||||
```
|
||||
|
||||
### Using development dependencies
|
||||
```bash
|
||||
# Install development dependencies
|
||||
pip install -e .[dev]
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
- **Template Loading**: YAML parsing, error handling, default values
|
||||
- **Variable Handling**: Interactive prompts, validation, choices, regex
|
||||
- **File Rendering**: Jinja2 templating, binary files, directory structure
|
||||
- **Post Tasks**: Conditional execution, subprocess handling, error recovery
|
||||
- **Optional Modules**: Alembic and Babel inclusion/exclusion
|
||||
- **CLI Interface**: Command parsing, help, arguments, error handling
|
||||
- **Integration**: End-to-end workflows, file operations, edge cases
|
||||
|
||||
## Adding New Tests
|
||||
|
||||
When adding new tests:
|
||||
|
||||
1. **Unit Tests**: Add to appropriate test class in `test_cli.py`
|
||||
2. **Integration Tests**: Add to `test_integration.py`
|
||||
3. **Edge Cases**: Add to `test_edge_cases.py`
|
||||
4. **Fixtures**: Add to `conftest.py` if reusable
|
||||
|
||||
### Test Naming Convention
|
||||
- Test files: `test_*.py`
|
||||
- Test classes: `Test*`
|
||||
- Test methods: `test_*`
|
||||
|
||||
### Test Documentation
|
||||
Each test should have a descriptive docstring explaining what it tests and why.
|
||||
|
||||
## Mocking Strategy
|
||||
|
||||
- **External Dependencies**: Use `unittest.mock.patch` for file system, subprocess, etc.
|
||||
- **User Input**: Mock `typer.prompt` to avoid interactive input during tests
|
||||
- **Output**: Mock `typer.secho` and `typer.echo` to capture and verify output
|
||||
- **File Operations**: Use temporary directories to avoid affecting the real file system
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests are configured to run with:
|
||||
- Coverage reporting (HTML, XML, terminal)
|
||||
- Strict marker validation
|
||||
- Verbose output for debugging
|
||||
- Short traceback format for readability
|
||||
5
tests/__init__.py
Normal file
5
tests/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Tests for QuickBot CLI."""
|
||||
146
tests/conftest.py
Normal file
146
tests/conftest.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Pytest configuration and fixtures for CLI tests."""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_runner() -> CliRunner:
|
||||
"""Provide a CLI runner for testing commands."""
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir() -> Generator[Path]:
|
||||
"""Provide a temporary directory for testing."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
yield Path(tmp_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_template_dir() -> Generator[Path]:
|
||||
"""Provide a mock template directory structure."""
|
||||
template_dir = Path(__file__).parent / "fixtures" / "mock_template"
|
||||
template_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create template spec
|
||||
spec_file = template_dir / "__template__.yaml"
|
||||
spec_content = """
|
||||
variables:
|
||||
project_name:
|
||||
prompt: "Project name"
|
||||
default: "test_project"
|
||||
description:
|
||||
prompt: "Description"
|
||||
default: "Test description"
|
||||
author:
|
||||
prompt: "Author"
|
||||
default: "Test Author"
|
||||
license:
|
||||
prompt: "License"
|
||||
default: "MIT"
|
||||
include_alembic:
|
||||
prompt: "Include Alembic?"
|
||||
choices: ["yes", "no"]
|
||||
default: "yes"
|
||||
include_i18n:
|
||||
prompt: "Include i18n?"
|
||||
choices: [true, false]
|
||||
default: true
|
||||
post_tasks:
|
||||
- when: "{{ include_alembic == 'yes' }}"
|
||||
run: ["echo", "alembic_init"]
|
||||
- when: "{{ include_i18n == true }}"
|
||||
run: ["echo", "babel_init"]
|
||||
"""
|
||||
spec_file.write_text(spec_content)
|
||||
|
||||
# Create some template files
|
||||
(template_dir / "app").mkdir()
|
||||
(template_dir / "app" / "main.py.j2").write_text(
|
||||
"from fastapi import FastAPI\n\napp = FastAPI(title='{{ project_name }}')\n"
|
||||
)
|
||||
(template_dir / "app" / "config.py.j2").write_text(
|
||||
"PROJECT_NAME = '{{ project_name }}'\nDESCRIPTION = '{{ description }}'\n"
|
||||
)
|
||||
(template_dir / "README.md.j2").write_text(
|
||||
"# {{ project_name }}\n\n{{ description }}\n\nAuthor: {{ author }}\nLicense: {{ license }}"
|
||||
)
|
||||
(template_dir / "pyproject.toml.j2").write_text(
|
||||
"[project]\nname = '{{ project_name }}'\ndescription = '{{ description }}'"
|
||||
)
|
||||
|
||||
# Create optional modules
|
||||
(template_dir / "alembic").mkdir()
|
||||
(template_dir / "alembic" / "alembic.ini.j2").write_text("alembic config for {{ project_name }}")
|
||||
(template_dir / "locales").mkdir()
|
||||
(template_dir / "locales" / "en").mkdir()
|
||||
(template_dir / "locales" / "en" / "LC_MESSAGES").mkdir()
|
||||
|
||||
# Create scripts
|
||||
(template_dir / "scripts").mkdir()
|
||||
(template_dir / "scripts" / "migrations_generate.sh.j2").write_text(
|
||||
"#!/bin/bash\necho 'Generate migrations for {{ project_name }}'"
|
||||
)
|
||||
(template_dir / "scripts" / "migrations_apply.sh.j2").write_text(
|
||||
"#!/bin/bash\necho 'Apply migrations for {{ project_name }}'"
|
||||
)
|
||||
(template_dir / "scripts" / "babel_init.sh.j2").write_text("#!/bin/bash\necho 'Init Babel for {{ project_name }}'")
|
||||
(template_dir / "scripts" / "babel_extract.sh.j2").write_text(
|
||||
"#!/bin/bash\necho 'Extract Babel for {{ project_name }}'"
|
||||
)
|
||||
(template_dir / "scripts" / "babel_update.sh.j2").write_text(
|
||||
"#!/bin/bash\necho 'Update Babel for {{ project_name }}'"
|
||||
)
|
||||
(template_dir / "scripts" / "babel_compile.sh.j2").write_text(
|
||||
"#!/bin/bash\necho 'Compile Babel for {{ project_name }}'"
|
||||
)
|
||||
|
||||
yield template_dir
|
||||
|
||||
# Cleanup
|
||||
shutil.rmtree(template_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_typer_prompt() -> Generator[MagicMock]:
|
||||
"""Mock typer.prompt to avoid interactive input during tests."""
|
||||
with patch("quickbot_cli.cli.typer.prompt") as mock_prompt:
|
||||
mock_prompt.return_value = "test_value"
|
||||
yield mock_prompt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_typer_secho() -> Generator[MagicMock]:
|
||||
"""Mock typer.secho to capture output during tests."""
|
||||
with patch("quickbot_cli.cli.typer.secho") as mock_secho:
|
||||
yield mock_secho
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_typer_echo() -> Generator[MagicMock]:
|
||||
"""Mock typer.echo to capture output during tests."""
|
||||
with patch("quickbot_cli.cli.typer.echo") as mock_echo:
|
||||
yield mock_echo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subprocess_run() -> Generator[MagicMock]:
|
||||
"""Mock subprocess.run to avoid actual command execution during tests."""
|
||||
with patch("quickbot_cli.cli.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
yield mock_run
|
||||
30
tests/pytest.ini
Normal file
30
tests/pytest.ini
Normal file
@@ -0,0 +1,30 @@
|
||||
; SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
||||
;
|
||||
; SPDX-License-Identifier: MIT
|
||||
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--tb=short
|
||||
--strict-markers
|
||||
--disable-warnings
|
||||
--cov=src/quickbot_cli
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-fail-under=95
|
||||
--cov-config=tests/pytest.ini
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
slow: Slow running tests
|
||||
cli: CLI specific tests
|
||||
|
||||
[coverage:report]
|
||||
exclude_lines =
|
||||
^if __name__ == .__main__.:$
|
||||
^\s*main\(\)\s*$
|
||||
651
tests/test_cli.py
Normal file
651
tests/test_cli.py
Normal file
@@ -0,0 +1,651 @@
|
||||
# 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
|
||||
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"
|
||||
318
tests/test_integration.py
Normal file
318
tests/test_integration.py
Normal file
@@ -0,0 +1,318 @@
|
||||
# SPDX-FileCopyrightText: 2025 Alexander Kalinovsky <a@k8y.ru>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Integration tests for the CLI functionality."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from quickbot_cli.cli import _init_project
|
||||
|
||||
# Add src to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
|
||||
class TestCLIIntegration:
|
||||
"""Integration tests for the CLI."""
|
||||
|
||||
def _create_template_spec(self) -> dict[str, Any]:
|
||||
"""Create a comprehensive template specification."""
|
||||
return {
|
||||
"variables": {
|
||||
"project_name": {"prompt": "Project name", "default": "test_bot"},
|
||||
"description": {"prompt": "Description", "default": "A test bot"},
|
||||
"author": {"prompt": "Author", "default": "Test Author"},
|
||||
"license": {"prompt": "License", "default": "MIT"},
|
||||
"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "yes"},
|
||||
"include_i18n": {"prompt": "Include i18n?", "choices": ["yes", "no"], "default": "yes"},
|
||||
},
|
||||
"post_tasks": [
|
||||
{"when": "{{ include_alembic }}", "run": ["echo", "alembic_initialized"]},
|
||||
{"when": "{{ include_i18n }}", "run": ["echo", "babel_initialized"]},
|
||||
],
|
||||
}
|
||||
|
||||
def _create_template_files(self, template_dir: Path) -> None:
|
||||
"""Create template files in the template directory."""
|
||||
# Create app files
|
||||
(template_dir / "app").mkdir()
|
||||
(template_dir / "app" / "main.py.j2").write_text(
|
||||
"from fastapi import FastAPI\n\n"
|
||||
"app = FastAPI(title='{{ project_name }}', description='{{ description }}')\n\n"
|
||||
"if __name__ == '__main__':\n"
|
||||
" import uvicorn\n"
|
||||
" uvicorn.run(app, host='0.0.0.0', port=8000)\n"
|
||||
)
|
||||
|
||||
(template_dir / "app" / "config.py.j2").write_text(
|
||||
"PROJECT_NAME = '{{ project_name }}'\n"
|
||||
"DESCRIPTION = '{{ description }}'\n"
|
||||
"AUTHOR = '{{ author }}'\n"
|
||||
"LICENSE = '{{ license }}'\n"
|
||||
)
|
||||
|
||||
# Create root files
|
||||
(template_dir / "README.md.j2").write_text(
|
||||
"# {{ project_name }}\n\n{{ description }}\n\n## Author\n{{ author }}\n\n## License\n{{ license }}\n"
|
||||
)
|
||||
|
||||
(template_dir / "pyproject.toml.j2").write_text(
|
||||
"[project]\n"
|
||||
"name = '{{ project_name }}'\n"
|
||||
"description = '{{ description }}'\n"
|
||||
"authors = [{name = '{{ author }}'}]\n"
|
||||
"license = {text = '{{ license }}'}\n"
|
||||
)
|
||||
|
||||
def _create_optional_modules(self, template_dir: Path) -> None:
|
||||
"""Create optional module files."""
|
||||
# Create Alembic files
|
||||
(template_dir / "alembic").mkdir()
|
||||
(template_dir / "alembic" / "alembic.ini.j2").write_text(
|
||||
"[alembic]\n"
|
||||
"script_location = alembic\n"
|
||||
"sqlalchemy.url = postgresql://user:pass@localhost/{{ project_name }}\n"
|
||||
)
|
||||
|
||||
# Create Babel files
|
||||
(template_dir / "locales").mkdir()
|
||||
(template_dir / "locales" / "en").mkdir()
|
||||
(template_dir / "locales" / "en" / "LC_MESSAGES").mkdir()
|
||||
(template_dir / "locales" / "en" / "LC_MESSAGES" / "messages.po.j2").write_text(
|
||||
'msgid ""\n'
|
||||
'msgstr ""\n'
|
||||
'"Project-Id-Version: {{ project_name }}\\n"\n'
|
||||
'"Report-Msgid-Bugs-To: \\n"\n'
|
||||
'"POT-Creation-Date: 2024-01-01 00:00+0000\\n"\n'
|
||||
'"PO-Revision-Date: 2024-01-01 00:00+0000\\n"\n'
|
||||
'"Last-Translator: {{ author }}\\n"\n'
|
||||
'"Language: en\\n"\n'
|
||||
'"MIME-Version: 1.0\\n"\n'
|
||||
'"Content-Type: text/plain; charset=UTF-8\\n"\n'
|
||||
'"Content-Transfer-Encoding: 8bit\\n"\n'
|
||||
)
|
||||
|
||||
# Create scripts
|
||||
(template_dir / "scripts").mkdir()
|
||||
(template_dir / "scripts" / "migrations_generate.sh.j2").write_text(
|
||||
"#!/bin/bash\n"
|
||||
"echo 'Generate migrations for {{ project_name }}'\n"
|
||||
"alembic revision --autogenerate -m 'Auto-generated migration'\n"
|
||||
)
|
||||
|
||||
(template_dir / "scripts" / "babel_init.sh.j2").write_text(
|
||||
"#!/bin/bash\n"
|
||||
"echo 'Initialize Babel for {{ project_name }}'\n"
|
||||
"pybabel init -i messages.pot -d locales -l en\n"
|
||||
)
|
||||
|
||||
def _verify_output_structure(self, output_dir: Path) -> None:
|
||||
"""Verify the output directory structure and content."""
|
||||
# Check main app files
|
||||
assert (output_dir / "app" / "main.py").exists()
|
||||
assert (output_dir / "app" / "config.py").exists()
|
||||
assert (output_dir / "README.md").exists()
|
||||
assert (output_dir / "pyproject.toml").exists()
|
||||
|
||||
# Check rendered content
|
||||
main_py = (output_dir / "app" / "main.py").read_text()
|
||||
assert "app = FastAPI(title='my_awesome_bot'" in main_py
|
||||
assert "description='My awesome bot description'" in main_py
|
||||
|
||||
config_py = (output_dir / "app" / "config.py").read_text()
|
||||
assert "PROJECT_NAME = 'my_awesome_bot'" in config_py
|
||||
assert "AUTHOR = 'John Doe'" in config_py
|
||||
assert "LICENSE = 'Apache-2.0'" in config_py
|
||||
|
||||
readme = (output_dir / "README.md").read_text()
|
||||
assert "# my_awesome_bot" in readme
|
||||
assert "My awesome bot description" in readme
|
||||
assert "John Doe" in readme
|
||||
assert "Apache-2.0" in readme
|
||||
|
||||
# Check optional modules (should be included)
|
||||
assert (output_dir / "alembic" / "alembic.ini").exists()
|
||||
assert (output_dir / "locales" / "en" / "LC_MESSAGES" / "messages.po").exists()
|
||||
assert (output_dir / "scripts" / "migrations_generate.sh").exists()
|
||||
assert (output_dir / "scripts" / "babel_init.sh").exists()
|
||||
|
||||
def test_full_project_generation(self) -> None:
|
||||
"""Test full project generation with all components."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
|
||||
# Mock the templates directory
|
||||
with patch("quickbot_cli.cli.TEMPLATES_DIR", tmp_path / "templates"):
|
||||
template_dir = tmp_path / "templates" / "basic"
|
||||
template_dir.mkdir(parents=True)
|
||||
|
||||
# Create template spec and files
|
||||
spec_content = self._create_template_spec()
|
||||
spec_file = template_dir / "__template__.yaml"
|
||||
spec_file.write_text(str(spec_content))
|
||||
|
||||
self._create_template_files(template_dir)
|
||||
self._create_optional_modules(template_dir)
|
||||
|
||||
# Mock subprocess.run to avoid actual command execution
|
||||
with patch("quickbot_cli.cli.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
|
||||
output_path = tmp_path / "output"
|
||||
|
||||
# Call init function directly with options
|
||||
_init_project(
|
||||
output_path,
|
||||
"basic",
|
||||
project_name="my_awesome_bot",
|
||||
description="My awesome bot description",
|
||||
author="John Doe",
|
||||
license_name="Apache-2.0",
|
||||
include_alembic=True,
|
||||
include_i18n=True,
|
||||
)
|
||||
|
||||
# Verify output
|
||||
output_dir = tmp_path / "output"
|
||||
assert output_dir.exists()
|
||||
self._verify_output_structure(output_dir)
|
||||
|
||||
# Check that post tasks were called
|
||||
expected_post_tasks_count = 2
|
||||
assert mock_run.call_count == expected_post_tasks_count
|
||||
mock_run.assert_any_call(["echo", "alembic_initialized"], cwd=output_dir, check=True)
|
||||
mock_run.assert_any_call(["echo", "babel_initialized"], cwd=output_dir, check=True)
|
||||
|
||||
def test_project_generation_with_disabled_modules(self) -> None:
|
||||
"""Test project generation with optional modules disabled."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
|
||||
# Mock the templates directory
|
||||
with patch("quickbot_cli.cli.TEMPLATES_DIR", tmp_path / "templates"):
|
||||
template_dir = tmp_path / "templates" / "basic"
|
||||
template_dir.mkdir(parents=True)
|
||||
|
||||
# Create template spec
|
||||
spec_content = {
|
||||
"variables": {
|
||||
"project_name": {"prompt": "Project name", "default": "simple_bot"},
|
||||
"include_alembic": {"prompt": "Include Alembic?", "choices": ["yes", "no"], "default": "no"},
|
||||
"include_i18n": {"prompt": "Include i18n?", "choices": ["yes", "no"], "default": "no"},
|
||||
}
|
||||
}
|
||||
|
||||
spec_file = template_dir / "__template__.yaml"
|
||||
spec_file.write_text(str(spec_content))
|
||||
|
||||
# Create template files
|
||||
(template_dir / "app").mkdir()
|
||||
(template_dir / "app" / "main.py.j2").write_text(
|
||||
"from fastapi import FastAPI\n\napp = FastAPI(title='{{ project_name }}')\n"
|
||||
)
|
||||
|
||||
# Create optional modules (should be removed)
|
||||
(template_dir / "alembic").mkdir()
|
||||
(template_dir / "alembic" / "alembic.ini.j2").write_text("alembic config")
|
||||
|
||||
(template_dir / "locales").mkdir()
|
||||
(template_dir / "locales" / "en").mkdir()
|
||||
|
||||
(template_dir / "scripts").mkdir()
|
||||
(template_dir / "scripts" / "migrations_generate.sh.j2").write_text("migration script")
|
||||
(template_dir / "scripts" / "babel_init.sh.j2").write_text("babel script")
|
||||
|
||||
output_path = tmp_path / "output"
|
||||
|
||||
# Call init function directly with options
|
||||
_init_project(
|
||||
output_path, "basic", project_name="simple_bot", include_alembic=False, include_i18n=False
|
||||
)
|
||||
|
||||
# Verify output directory structure
|
||||
output_dir = tmp_path / "output"
|
||||
assert output_dir.exists()
|
||||
|
||||
# Check main app files
|
||||
assert (output_dir / "app" / "main.py").exists()
|
||||
|
||||
# Check that optional modules were removed
|
||||
assert not (output_dir / "alembic").exists()
|
||||
assert not (output_dir / "locales").exists()
|
||||
assert not (output_dir / "scripts" / "migrations_generate.sh").exists()
|
||||
assert not (output_dir / "scripts" / "babel_init.sh").exists()
|
||||
|
||||
def test_project_generation_with_overwrite(self) -> None:
|
||||
"""Test project generation with overwrite enabled."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
|
||||
# Mock the templates directory
|
||||
with patch("quickbot_cli.cli.TEMPLATES_DIR", tmp_path / "templates"):
|
||||
template_dir = tmp_path / "templates" / "basic"
|
||||
template_dir.mkdir(parents=True)
|
||||
|
||||
# Create template spec
|
||||
spec_content = {"variables": {"project_name": {"prompt": "Project name", "default": "overwrite_test"}}}
|
||||
|
||||
spec_file = template_dir / "__template__.yaml"
|
||||
spec_file.write_text(str(spec_content))
|
||||
|
||||
# Create template file in app subdirectory
|
||||
(template_dir / "app").mkdir()
|
||||
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')\n")
|
||||
|
||||
# Create output directory with existing file
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
existing_file = output_dir / "app" / "main.py"
|
||||
existing_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_file.write_text("existing content")
|
||||
|
||||
# Call init function directly with overwrite
|
||||
_init_project(output_dir, "basic", project_name="overwrite_test", overwrite=True)
|
||||
|
||||
# Check that file was overwritten
|
||||
assert (output_dir / "app" / "main.py").exists()
|
||||
assert "app = FastAPI(title='overwrite_test')" in (output_dir / "app" / "main.py").read_text()
|
||||
assert "existing content" not in (output_dir / "app" / "main.py").read_text()
|
||||
|
||||
def test_project_generation_without_overwrite_fails(self) -> None:
|
||||
"""Test that project generation fails without overwrite when files exist."""
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
|
||||
# Mock the templates directory
|
||||
with patch("quickbot_cli.cli.TEMPLATES_DIR", tmp_path / "templates"):
|
||||
template_dir = tmp_path / "templates" / "basic"
|
||||
template_dir.mkdir(parents=True)
|
||||
|
||||
# Create template spec
|
||||
spec_content = {"variables": {"project_name": {"prompt": "Project name", "default": "overwrite_test"}}}
|
||||
|
||||
spec_file = template_dir / "__template__.yaml"
|
||||
spec_file.write_text(str(spec_content))
|
||||
|
||||
# Create template file in app subdirectory
|
||||
(template_dir / "app").mkdir()
|
||||
(template_dir / "app" / "main.py.j2").write_text("app = FastAPI(title='{{ project_name }}')\n")
|
||||
|
||||
# Create output directory with existing file
|
||||
output_dir = tmp_path / "output"
|
||||
output_dir.mkdir()
|
||||
existing_file = output_dir / "app" / "main.py"
|
||||
existing_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_file.write_text("existing content")
|
||||
|
||||
# Should fail with FileExistsError when overwrite is False
|
||||
with pytest.raises(FileExistsError):
|
||||
_init_project(output_dir, "basic", project_name="overwrite_test")
|
||||
|
||||
# Check that file was not overwritten
|
||||
assert "existing content" in (output_dir / "app" / "main.py").read_text()
|
||||
Reference in New Issue
Block a user