From e2ddb45b12361a07ddea830fcf55b1c55c20083d Mon Sep 17 00:00:00 2001 From: manavgup Date: Mon, 30 Mar 2026 15:59:17 -0400 Subject: [PATCH] =?UTF-8?q?Phase=204:=20Project=20scaffolding=20=E2=80=94?= =?UTF-8?q?=20faststack=20init=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 8 Jinja2 project templates: pyproject.toml, main.py, config.py, conftest.py, Dockerfile, docker-compose.yml, alembic.ini, alembic env.py - cli/cmd_init.py: `faststack init ` scaffolds complete project with directory structure, __init__.py files, .env, .project-config.yaml - Supports --entities flag for YAML-based entity pre-definition - Generated alembic/env.py uses async engine with auto-model discovery 35 new tests (281 total) covering: - Project scaffolding structure and file existence - Generated Python files parse without syntax errors - Template rendering with sample variables - --entities flag includes entity routers in main.py - Error handling for existing directories Part of Phase 4 in #1 Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/__init__.py | 14 +- cli/cmd_init.py | 112 ++++++++ templates/project/Dockerfile.j2 | 14 + templates/project/alembic.ini.j2 | 36 +++ templates/project/alembic_env.py.j2 | 48 ++++ templates/project/config.py.j2 | 19 ++ templates/project/conftest.py.j2 | 24 ++ templates/project/docker-compose.yml.j2 | 23 ++ templates/project/main.py.j2 | 16 ++ templates/project/pyproject.toml.j2 | 35 +++ tests/test_cli/test_init.py | 263 ++++++++++++++++++ .../test_templates/test_project_templates.py | 154 ++++++++++ 12 files changed, 757 insertions(+), 1 deletion(-) create mode 100644 cli/cmd_init.py create mode 100644 templates/project/Dockerfile.j2 create mode 100644 templates/project/alembic.ini.j2 create mode 100644 templates/project/alembic_env.py.j2 create mode 100644 templates/project/config.py.j2 create mode 100644 templates/project/conftest.py.j2 create mode 100644 templates/project/docker-compose.yml.j2 create mode 100644 templates/project/main.py.j2 create mode 100644 templates/project/pyproject.toml.j2 create mode 100644 tests/test_cli/test_init.py create mode 100644 tests/test_templates/test_project_templates.py diff --git a/cli/__init__.py b/cli/__init__.py index 92c90cb..fe049a9 100644 --- a/cli/__init__.py +++ b/cli/__init__.py @@ -7,4 +7,16 @@ @click.version_option(version="0.1.0", prog_name="faststack") def cli() -> None: """FastStack — Hybrid FastAPI framework + CLI generator.""" - pass + + +# Alias so submodules can import the Click group without name collision +# with the ``cli`` package itself. +cli_group = cli + + +def _register_commands() -> None: + """Import subcommands to register them with the CLI group.""" + import cli.cmd_init # noqa: F401 + + +_register_commands() diff --git a/cli/cmd_init.py b/cli/cmd_init.py new file mode 100644 index 0000000..e7fd466 --- /dev/null +++ b/cli/cmd_init.py @@ -0,0 +1,112 @@ +"""faststack init — scaffold a new FastStack project.""" + +from pathlib import Path + +import click +from jinja2 import Environment, FileSystemLoader + +from cli import cli_group +from cli.yaml_parser import parse_entities_yaml + +TEMPLATE_DIR = Path(__file__).parent.parent / "templates" / "project" + + +@cli_group.command("init") +@click.argument("project_name") +@click.option("--entities", type=click.Path(exists=True), help="Path to entities.yaml") +def init_project(project_name: str, entities: str | None = None) -> None: + """Scaffold a new FastStack project.""" + + project_dir = Path.cwd() / project_name + if project_dir.exists(): + raise click.ClickException(f"Directory '{project_name}' already exists") + + # Parse entities if YAML provided + entity_defs = [] + if entities: + entity_defs = parse_entities_yaml(Path(entities)) + + # Create directory structure + dirs = [ + "app", + "app/models", + "app/schemas", + "app/repositories", + "app/services", + "app/api", + "app/api/routes", + "alembic", + "alembic/versions", + "tests", + "tests/unit", + "tests/unit/fakes", + "tests/integration", + "tests/factories", + ] + for d in dirs: + (project_dir / d).mkdir(parents=True, exist_ok=True) + + # Create __init__.py files + init_dirs = [ + "app", + "app/models", + "app/schemas", + "app/repositories", + "app/services", + "app/api", + "app/api/routes", + "tests", + "tests/unit", + "tests/unit/fakes", + "tests/integration", + "tests/factories", + ] + for d in init_dirs: + (project_dir / d / "__init__.py").write_text("") + + # Render templates + env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), keep_trailing_newline=True) + + template_context = { + "project_name": project_name, + "entities": entity_defs, + } + + file_mappings = { + "pyproject.toml.j2": "pyproject.toml", + "main.py.j2": "app/main.py", + "config.py.j2": "app/config.py", + "conftest.py.j2": "tests/conftest.py", + "Dockerfile.j2": "Dockerfile", + "docker-compose.yml.j2": "docker-compose.yml", + "alembic.ini.j2": "alembic.ini", + "alembic_env.py.j2": "alembic/env.py", + } + + for template_name, output_path in file_mappings.items(): + template = env.get_template(template_name) + content = template.render(**template_context) + (project_dir / output_path).write_text(content) + + # Create .project-config.yaml + config_content = f"project_name: {project_name}\narchitecture: simple\nentities: {{}}\n" + (project_dir / ".project-config.yaml").write_text(config_content) + + # Create .env file + db_name = project_name.lower().replace("-", "_") + env_content = ( + f"DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/{db_name}\n" + f"LOG_LEVEL=INFO\n" + ) + (project_dir / ".env").write_text(env_content) + + click.echo(f"Created project '{project_name}' at {project_dir}") + click.echo() + click.echo("Next steps:") + click.echo(f" cd {project_name}") + click.echo(" poetry install") + if entity_defs: + click.echo(' faststack migrate generate "initial"') + click.echo(" faststack migrate upgrade") + else: + click.echo(" faststack add-entity YourEntity --fields 'name:string:required'") diff --git a/templates/project/Dockerfile.j2 b/templates/project/Dockerfile.j2 new file mode 100644 index 0000000..0478579 --- /dev/null +++ b/templates/project/Dockerfile.j2 @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install poetry && poetry config virtualenvs.create false + +COPY pyproject.toml poetry.lock* ./ +RUN poetry install --no-dev --no-interaction --no-ansi + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/templates/project/alembic.ini.j2 b/templates/project/alembic.ini.j2 new file mode 100644 index 0000000..9c779d6 --- /dev/null +++ b/templates/project/alembic.ini.j2 @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = will_be_overridden_by_env_py + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/templates/project/alembic_env.py.j2 b/templates/project/alembic_env.py.j2 new file mode 100644 index 0000000..7bd3c14 --- /dev/null +++ b/templates/project/alembic_env.py.j2 @@ -0,0 +1,48 @@ +"""Alembic environment — async engine with auto-model discovery.""" + +import asyncio +import importlib +import pkgutil +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.config import settings +from faststack_core.base.entity import Base + +# Auto-discover all model modules so Alembic sees them +import app.models +for _importer, modname, _ispkg in pkgutil.iter_modules(app.models.__path__): + importlib.import_module(f"app.models.{modname}") + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + engine = create_async_engine(settings.database_url) + async with engine.connect() as connection: + await connection.run_sync(do_run_migrations) + await engine.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/templates/project/config.py.j2 b/templates/project/config.py.j2 new file mode 100644 index 0000000..a37480c --- /dev/null +++ b/templates/project/config.py.j2 @@ -0,0 +1,19 @@ +"""Application settings loaded from environment variables.""" + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application configuration. + + Values are read from environment variables or a .env file. + """ + + app_name: str = "{{ project_name }}" + database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/{{ project_name | lower | replace('-', '_') }}" + log_level: str = "INFO" + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +settings = Settings() diff --git a/templates/project/conftest.py.j2 b/templates/project/conftest.py.j2 new file mode 100644 index 0000000..64c8a05 --- /dev/null +++ b/templates/project/conftest.py.j2 @@ -0,0 +1,24 @@ +"""Root test configuration with async database fixtures.""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from faststack_core.base.entity import Base + +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest.fixture +async def async_engine(): + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def session(async_engine): + factory = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + async with factory() as sess: + yield sess diff --git a/templates/project/docker-compose.yml.j2 b/templates/project/docker-compose.yml.j2 new file mode 100644 index 0000000..806ae73 --- /dev/null +++ b/templates/project/docker-compose.yml.j2 @@ -0,0 +1,23 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: {{ project_name | lower | replace('-', '_') }} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + app: + build: . + ports: + - "8000:8000" + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/{{ project_name | lower | replace('-', '_') }} + depends_on: + - db + +volumes: + pgdata: diff --git a/templates/project/main.py.j2 b/templates/project/main.py.j2 new file mode 100644 index 0000000..f854c86 --- /dev/null +++ b/templates/project/main.py.j2 @@ -0,0 +1,16 @@ +"""{{ project_name }} — FastAPI application.""" + +from fastapi import FastAPI +from faststack_core.settings.config import FastStackConfig +from faststack_core.setup import setup_app + +app = FastAPI(title="{{ project_name }}") + +setup_app(app, FastStackConfig( + app_version="0.1.0", +)) + +{% for entity in entities %} +from app.api.routes.{{ entity.name | lower }} import router as {{ entity.name | lower }}_router +app.include_router({{ entity.name | lower }}_router, prefix="/api") +{% endfor %} diff --git a/templates/project/pyproject.toml.j2 b/templates/project/pyproject.toml.j2 new file mode 100644 index 0000000..ebb4da2 --- /dev/null +++ b/templates/project/pyproject.toml.j2 @@ -0,0 +1,35 @@ +[tool.poetry] +name = "{{ project_name }}" +version = "0.1.0" +description = "" +authors = [] +packages = [{ include = "app" }] + +[tool.poetry.dependencies] +python = ">=3.12,<4.0" +faststack = ">=0.1.0" + +[tool.poetry.group.dev.dependencies] +pytest = ">=8.0" +pytest-asyncio = ">=0.24.0" +polyfactory = ">=2.0" +httpx = ">=0.28.0" +aiosqlite = ">=0.20.0" +ruff = ">=0.8.0" +black = ">=24.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.black] +line-length = 100 +target-version = ["py312"] diff --git a/tests/test_cli/test_init.py b/tests/test_cli/test_init.py new file mode 100644 index 0000000..44f4ff5 --- /dev/null +++ b/tests/test_cli/test_init.py @@ -0,0 +1,263 @@ +"""Tests for ``faststack init`` CLI command.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from cli import cli_group + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def sample_entities_yaml(tmp_path: Path) -> Path: + """Write a minimal entities.yaml and return its path.""" + yaml_content = """\ +entities: + User: + base: FullAuditedEntity + fields: + name: + type: string + required: true + email: + type: string + required: true + unique: true + searchable: + - name + - email + + Post: + fields: + title: + type: string + required: true + user_id: + type: uuid + references: User +""" + yaml_file = tmp_path / "entities.yaml" + yaml_file.write_text(yaml_content) + return yaml_file + + +class TestInitScaffolding: + """Test that ``faststack init`` creates the expected directory structure.""" + + def test_creates_all_expected_directories(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(cli_group, ["init", "my-project"], catch_exceptions=False) + + assert result.exit_code == 0 + + project = tmp_path / "my-project" + expected_dirs = [ + "app", + "app/models", + "app/schemas", + "app/repositories", + "app/services", + "app/api", + "app/api/routes", + "alembic", + "alembic/versions", + "tests", + "tests/unit", + "tests/unit/fakes", + "tests/integration", + "tests/factories", + ] + for d in expected_dirs: + assert (project / d).is_dir(), f"Missing directory: {d}" + + def test_creates_all_expected_files(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = runner.invoke(cli_group, ["init", "my-project"], catch_exceptions=False) + + assert result.exit_code == 0 + + project = tmp_path / "my-project" + expected_files = [ + "pyproject.toml", + "app/main.py", + "app/config.py", + "tests/conftest.py", + "Dockerfile", + "docker-compose.yml", + "alembic.ini", + "alembic/env.py", + ".project-config.yaml", + ".env", + ] + for f in expected_files: + assert (project / f).is_file(), f"Missing file: {f}" + + def test_creates_init_py_files(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "my-project"], catch_exceptions=False) + + project = tmp_path / "my-project" + init_dirs = [ + "app", + "app/models", + "app/schemas", + "app/repositories", + "app/services", + "app/api", + "app/api/routes", + "tests", + "tests/unit", + "tests/unit/fakes", + "tests/integration", + "tests/factories", + ] + for d in init_dirs: + assert (project / d / "__init__.py").is_file(), f"Missing __init__.py in {d}" + + +class TestGeneratedFilesAreSyntacticallyValid: + """Verify that all generated Python files parse without syntax errors.""" + + def test_main_py_parses(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "test-project"], catch_exceptions=False) + + source = (tmp_path / "test-project" / "app" / "main.py").read_text() + ast.parse(source) + + def test_config_py_parses(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "test-project"], catch_exceptions=False) + + source = (tmp_path / "test-project" / "app" / "config.py").read_text() + ast.parse(source) + + def test_conftest_py_parses(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "test-project"], catch_exceptions=False) + + source = (tmp_path / "test-project" / "tests" / "conftest.py").read_text() + ast.parse(source) + + def test_alembic_env_py_parses(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "test-project"], catch_exceptions=False) + + source = (tmp_path / "test-project" / "alembic" / "env.py").read_text() + ast.parse(source) + + +class TestFileContents: + """Verify key content expectations in generated files.""" + + def test_pyproject_contains_project_name(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / "pyproject.toml").read_text() + assert 'name = "cool-app"' in content + + def test_main_py_imports_faststack_core(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / "app" / "main.py").read_text() + assert "from faststack_core" in content + assert "setup_app" in content + + def test_alembic_env_uses_async_engine(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / "alembic" / "env.py").read_text() + assert "create_async_engine" in content + assert "run_sync" in content + + def test_conftest_has_async_fixtures(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / "tests" / "conftest.py").read_text() + assert "async def async_engine" in content + assert "async def session" in content + assert "@pytest.fixture" in content + + def test_project_config_yaml_exists(self, runner: CliRunner, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / ".project-config.yaml").read_text() + assert "project_name: cool-app" in content + + def test_docker_compose_contains_project_name( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + runner.invoke(cli_group, ["init", "cool-app"], catch_exceptions=False) + + content = (tmp_path / "cool-app" / "docker-compose.yml").read_text() + assert "cool_app" in content # project_name | lower | replace('-', '_') + + +class TestWithEntitiesFlag: + """Test ``faststack init`` with the ``--entities`` flag.""" + + def test_entity_routers_in_main_py( + self, + runner: CliRunner, + tmp_path: Path, + monkeypatch, + sample_entities_yaml: Path, + ): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + cli_group, + ["init", "entity-project", "--entities", str(sample_entities_yaml)], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + + content = (tmp_path / "entity-project" / "app" / "main.py").read_text() + assert "user_router" in content + assert "post_router" in content + assert "include_router" in content + + def test_next_steps_include_migrate( + self, + runner: CliRunner, + tmp_path: Path, + monkeypatch, + sample_entities_yaml: Path, + ): + monkeypatch.chdir(tmp_path) + result = runner.invoke( + cli_group, + ["init", "entity-project", "--entities", str(sample_entities_yaml)], + catch_exceptions=False, + ) + + assert "faststack migrate" in result.output + + +class TestErrorCases: + """Test error handling in ``faststack init``.""" + + def test_error_when_directory_already_exists( + self, runner: CliRunner, tmp_path: Path, monkeypatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "existing-project").mkdir() + + result = runner.invoke(cli_group, ["init", "existing-project"]) + + assert result.exit_code != 0 + assert "already exists" in result.output diff --git a/tests/test_templates/test_project_templates.py b/tests/test_templates/test_project_templates.py new file mode 100644 index 0000000..00228fd --- /dev/null +++ b/tests/test_templates/test_project_templates.py @@ -0,0 +1,154 @@ +"""Tests for project template rendering (without the CLI).""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest +import yaml +from jinja2 import Environment, FileSystemLoader + +TEMPLATE_DIR = Path(__file__).parent.parent.parent / "templates" / "project" + + +@pytest.fixture +def jinja_env(): + return Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)), keep_trailing_newline=True) + + +@pytest.fixture +def base_context(): + """Minimal template context with no entities.""" + return { + "project_name": "sample-project", + "entities": [], + } + + +@pytest.fixture +def entity_context(): + """Template context with sample entity-like objects.""" + from dataclasses import dataclass + + @dataclass + class FakeEntity: + name: str + + return { + "project_name": "entity-project", + "entities": [FakeEntity(name="User"), FakeEntity(name="Post")], + } + + +class TestPythonTemplatesProduceValidPython: + """All .py templates must produce syntactically valid Python.""" + + def test_main_py(self, jinja_env, base_context): + content = jinja_env.get_template("main.py.j2").render(**base_context) + ast.parse(content) + + def test_main_py_with_entities(self, jinja_env, entity_context): + content = jinja_env.get_template("main.py.j2").render(**entity_context) + ast.parse(content) + + def test_config_py(self, jinja_env, base_context): + content = jinja_env.get_template("config.py.j2").render(**base_context) + ast.parse(content) + + def test_conftest_py(self, jinja_env, base_context): + content = jinja_env.get_template("conftest.py.j2").render(**base_context) + ast.parse(content) + + def test_alembic_env_py(self, jinja_env, base_context): + content = jinja_env.get_template("alembic_env.py.j2").render(**base_context) + ast.parse(content) + + +class TestYamlTemplatesProduceValidYaml: + """YAML templates must produce parsable YAML.""" + + def test_docker_compose_yml(self, jinja_env, base_context): + content = jinja_env.get_template("docker-compose.yml.j2").render(**base_context) + parsed = yaml.safe_load(content) + assert "services" in parsed + assert "db" in parsed["services"] + assert "app" in parsed["services"] + + def test_docker_compose_uses_project_db_name(self, jinja_env, base_context): + content = jinja_env.get_template("docker-compose.yml.j2").render(**base_context) + parsed = yaml.safe_load(content) + db_env = parsed["services"]["db"]["environment"] + assert db_env["POSTGRES_DB"] == "sample_project" + + +class TestDockerfileTemplate: + """Verify the Dockerfile template produces expected content.""" + + def test_has_from_stage(self, jinja_env, base_context): + content = jinja_env.get_template("Dockerfile.j2").render(**base_context) + assert content.startswith("FROM python:3.12-slim") + + def test_has_expose(self, jinja_env, base_context): + content = jinja_env.get_template("Dockerfile.j2").render(**base_context) + assert "EXPOSE 8000" in content + + def test_has_cmd(self, jinja_env, base_context): + content = jinja_env.get_template("Dockerfile.j2").render(**base_context) + assert "CMD" in content + assert "uvicorn" in content + + +class TestAlembicIniTemplate: + """Verify alembic.ini template renders correctly.""" + + def test_has_script_location(self, jinja_env, base_context): + content = jinja_env.get_template("alembic.ini.j2").render(**base_context) + assert "script_location = alembic" in content + + def test_has_loggers_section(self, jinja_env, base_context): + content = jinja_env.get_template("alembic.ini.j2").render(**base_context) + assert "[loggers]" in content + + +class TestPyprojectTomlTemplate: + """Verify pyproject.toml template renders correctly.""" + + def test_contains_project_name(self, jinja_env, base_context): + content = jinja_env.get_template("pyproject.toml.j2").render(**base_context) + assert 'name = "sample-project"' in content + + def test_contains_faststack_dependency(self, jinja_env, base_context): + content = jinja_env.get_template("pyproject.toml.j2").render(**base_context) + assert "faststack" in content + + def test_contains_pytest_config(self, jinja_env, base_context): + content = jinja_env.get_template("pyproject.toml.j2").render(**base_context) + assert 'asyncio_mode = "auto"' in content + + +class TestConfigTemplate: + """Verify config.py template renders with correct DB name.""" + + def test_database_url_uses_snake_case_name(self, jinja_env): + ctx = {"project_name": "my-cool-app", "entities": []} + content = jinja_env.get_template("config.py.j2").render(**ctx) + assert "my_cool_app" in content + + def test_has_settings_class(self, jinja_env, base_context): + content = jinja_env.get_template("config.py.j2").render(**base_context) + assert "class Settings" in content + + +class TestMainTemplateWithEntities: + """Verify main.py template entity router inclusion.""" + + def test_includes_entity_routers(self, jinja_env, entity_context): + content = jinja_env.get_template("main.py.j2").render(**entity_context) + assert "user_router" in content + assert "post_router" in content + assert "include_router" in content + + def test_no_routers_without_entities(self, jinja_env, base_context): + content = jinja_env.get_template("main.py.j2").render(**base_context) + assert "include_router" not in content