"""Tests for the cookiecutter templates under ``.opencode/templates/``. Covers: - All three templates exist with the expected top-level shape - ``cookiecutter.json`` exposes the required variables - Conditional files are rendered (use_auth=yes/no, use_db=yes/no) - Generated projects conform to the ``project-status`` oracle contract: expected dirs, lifespan in main.py, ruff/mypy/pytest in pyproject.toml, README delimiter tags, ci.yml, LICENSE. - Backend ``user_service.get_users`` returns a list (bug fix). - IP whitelist defaults to ``["127.0.0.1", "::1"]`` (no hardcoded prod IPs). - Metadata lives only in ``utils/metadata.py`` (no duplication in __init__). """ from __future__ import annotations import importlib.util import json import sys from pathlib import Path import pytest from cookiecutter.main import cookiecutter REPO_ROOT = Path(__file__).resolve().parent.parent TEMPLATES_DIR = REPO_ROOT / ".opencode" / "templates" SCRIPT_PATH = REPO_ROOT / ".opencode" / "scripts" / "project-status.py" _spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH) ps = importlib.util.module_from_spec(spec=_spec) sys.modules["project_status"] = ps _spec.loader.exec_module(ps) # ── fixtures ───────────────────────────────────────────────────────────────── @pytest.fixture def render(tmp_path, template_name, extra_context): """Render a cookiecutter template into ``tmp_path`` and return the root.""" out_dir = cookiecutter( template=str(TEMPLATES_DIR / template_name), no_input=True, output_dir=str(tmp_path), extra_context=extra_context, ) return Path(out_dir) # ── template presence ─────────────────────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "demo-be"}), ("cli", {"project_name": "demo-cli"}), ("fullstack", {"project_name": "demo-fs"}), ], ) def test_template_dir_exists(render): """Each template renders to a project dir of the given name.""" assert render.is_dir() assert render.name in {"demo-be", "demo-cli", "demo-fs"} @pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"]) def test_cookiecutter_json_has_required_keys(template_name): """``cookiecutter.json`` must expose the 6 required variables.""" cfg = json.loads((TEMPLATES_DIR / template_name / "cookiecutter.json").read_text()) required = { "project_name", "project_type", "description", "use_auth", "use_db", "python_version", } assert required.issubset(cfg.keys()), f"missing: {required - set(cfg.keys())}" @pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})]) def test_python_version_synced(render): """``requires-python`` in pyproject must match ``.python-version``.""" pv = (render / ".python-version").read_text().strip() pyproject = (render / "pyproject.toml").read_text() assert f">={pv}" in pyproject, f"requires-python must be >={pv}" # ── backend template structure ─────────────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})], ) def test_backend_structure_full(render): """Backend with use_db=yes + use_auth=yes has the full tree.""" expected_files = [ "pyproject.toml", ".python-version", "env.example", ".gitignore", "README.md", "LICENSE", ".pre-commit-config.yaml", ".github/workflows/ci.yml", ".github/dependabot.yml", "main.py", "migrations/README.md", "src/be/__init__.py", "src/be/api/router.py", "src/be/api/v1/router.py", "src/be/api/v1/dependencies.py", "src/be/api/v1/routes/users.py", "src/be/api/v1/routes/auth.py", "src/be/config/settings.py", "src/be/config/logger.py", "src/be/db/connection.py", "src/be/db/models/user.py", "src/be/schemas/base.py", "src/be/schemas/user.py", "src/be/services/user_service.py", "src/be/services/auth_service.py", "src/be/utils/metadata.py", "tests/conftest.py", "tests/unit/test_user_service.py", "tests/unit/test_user.py", "tests/api/test_users.py", "tests/integration/test_real_external.py", "tests/test_auth.py", ] for rel in expected_files: assert (render / rel).exists(), f"missing: {rel}" @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"})], ) def test_backend_structure_no_db_no_auth(render): """use_db=no + use_auth=no strips db/, migrations/, auth files, and the db-dependent files (user_service, users routes, dependencies, schemas/user, test_user*). The previous wiring left unconditional ``from ...db.models import User`` imports behind, which broke startup (ImportError/NameError). """ assert not (render / "src/be/db").exists() assert not (render / "migrations").exists() assert not (render / "src/be/api/v1/routes/auth.py").exists() assert not (render / "src/be/services/auth_service.py").exists() assert not (render / "tests/test_auth.py").exists() # db-dependent files must be stripped too (root cause of broken start) assert not (render / "src/be/api/v1/routes/users.py").exists() assert not (render / "src/be/services/user_service.py").exists() assert not (render / "src/be/api/v1/dependencies.py").exists() assert not (render / "src/be/schemas/user.py").exists() assert not (render / "tests/unit/test_user.py").exists() assert not (render / "tests/unit/test_user_service.py").exists() assert not (render / "tests/api/test_users.py").exists() # core backend structure still present assert (render / "src/be/api/v1/router.py").exists() assert (render / "main.py").exists() assert (render / "src/be/config/settings.py").exists() @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"})], ) def test_backend_use_db_yes_use_auth_no(render): """use_db=yes keeps db + migrations; use_auth=no drops auth files.""" assert (render / "src/be/db/connection.py").exists() assert (render / "migrations").exists() assert not (render / "src/be/api/v1/routes/auth.py").exists() assert not (render / "src/be/services/auth_service.py").exists() # user model without hashed_password field (use_auth=no) — the docstring # still mentions the field name, so check the actual field declaration. user_model = (render / "src/be/db/models/user.py").read_text() assert "hashed_password = fields" not in user_model @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})], ) def test_backend_use_auth_yes_has_hashed_password(render): """use_auth=yes adds hashed_password to the user model.""" user_model = (render / "src/be/db/models/user.py").read_text() assert "hashed_password" in user_model # ── cli template structure ─────────────────────────────────────────────────── @pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})]) def test_cli_structure(render): """CLI template has the entry point + cli.py + core.py + tests.""" expected = [ "pyproject.toml", ".python-version", "README.md", "LICENSE", ".github/workflows/ci.yml", "src/cl/__init__.py", "src/cl/__main__.py", "src/cl/cli.py", "src/cl/core.py", "tests/conftest.py", "tests/test_cli.py", "tests/test_core.py", ] for rel in expected: assert (render / rel).exists(), f"missing: {rel}" @pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})]) def test_cli_has_scripts_entry(render): """``[project.scripts]`` must wire the entry point to __main__:app.""" pyproject = (render / "pyproject.toml").read_text() assert "[project.scripts]" in pyproject assert 'cl = "cl.__main__:app"' in pyproject # ── fullstack template structure ───────────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"})], ) def test_fullstack_structure(render): """Fullstack has backend/ + frontend/ with the expected files.""" assert (render / "backend").is_dir() assert (render / "frontend").is_dir() # backend mirrors the backend template assert (render / "backend/pyproject.toml").exists() assert (render / "backend/main.py").exists() assert (render / "backend/src/fs/api/v1/routes/users.py").exists() assert (render / "backend/src/fs/services/auth_service.py").exists() assert (render / "backend/migrations").is_dir() # frontend has the SvelteKit stack assert (render / "frontend/package.json").exists() assert (render / "frontend/svelte.config.js").exists() assert (render / "frontend/vite.config.js").exists() assert (render / "frontend/vitest.config.js").exists() assert (render / "frontend/biome.json").exists() assert (render / "frontend/knip.json").exists() assert (render / "frontend/jsconfig.json").exists() assert (render / "frontend/src/app.html").exists() assert (render / "frontend/src/hooks.server.js").exists() assert (render / "frontend/src/routes/+page.svelte").exists() assert (render / "frontend/src/lib/stores/counter.svelte.js").exists() assert (render / "frontend/tests/e2e/app.spec.js").exists() # root CI runs both assert (render / ".github/workflows/ci.yml").exists() assert (render / "README.md").exists() @pytest.mark.parametrize( "template_name, extra_context", [("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"})], ) def test_fullstack_conditional_stripped(render): """use_db=no + use_auth=no strip auth/db from the backend subtree. Also strips the db-dependent files (users routes, user_service, dependencies, schemas/user, test_user*) — the broken-conditional fix. """ assert not (render / "backend/src/fs/db").exists() assert not (render / "backend/migrations").exists() assert not (render / "backend/src/fs/api/v1/routes/auth.py").exists() assert not (render / "backend/src/fs/services/auth_service.py").exists() # db-dependent files stripped too assert not (render / "backend/src/fs/api/v1/routes/users.py").exists() assert not (render / "backend/src/fs/services/user_service.py").exists() assert not (render / "backend/src/fs/api/v1/dependencies.py").exists() assert not (render / "backend/src/fs/schemas/user.py").exists() @pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})]) def test_fullstack_frontend_uses_svelte5(render): """package.json pins svelte 5.""" pkg = json.loads((render / "frontend/package.json").read_text()) svelte_dep = pkg.get("devDependencies", {}).get("svelte", "") assert svelte_dep.startswith("^5.") or svelte_dep.startswith("5"), svelte_dep @pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})]) def test_fullstack_frontend_no_isomorphic_fetch(render): """package.json must not depend on isomorphic-fetch (SvelteKit has native fetch).""" pkg = json.loads((render / "frontend/package.json").read_text()) deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} assert "isomorphic-fetch" not in deps, "isomorphic-fetch is unused in SvelteKit" @pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})]) def test_fullstack_error_page_is_fragment(render): """+error.svelte must be a SvelteKit fragment, not a full HTML document.""" error_page = (render / "frontend/src/routes/+error.svelte").read_text() assert "" @pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})]) def test_fullstack_page_uses_data_title(render): """+page.svelte must consume ``data.title`` from the load function.""" page_svelte = (render / "frontend/src/routes/+page.svelte").read_text() page_js = (render / "frontend/src/routes/+page.js").read_text() # load returns { title: ... } assert "title" in page_js # +page.svelte consumes data.title (not a hardcoded cookiecutter literal) assert "data.title" in page_svelte, "+page.svelte must use data.title from load" @pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})]) def test_fullstack_hooks_no_unused_redirect(render): """hooks.server.js must not import redirect (unused per biome).""" hooks = (render / "frontend/src/hooks.server.js").read_text() assert "redirect" not in hooks, "hooks.server.js must not import unused redirect" # ── README delimiter tags (create-readme standard) ────────────────────────── README_REQUIRED_TAGS = [ "", "", "", "", "", "", "", "", "", "", "", "", ] @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"}), ("fullstack", {"project_name": "fs"}), ], ) def test_readme_has_delimiter_tags(render): """README must contain all 12 delimiter tags + standard headers.""" content = (render / "README.md").read_text() for tag in README_REQUIRED_TAGS: assert tag in content, f"missing tag: {tag}" assert "# 🚀 " in content assert "## 🇺🇸 English" in content assert "## 🇷🇺 Русский" in content assert "[English](#-english)" in content assert "[Русский](#-русский)" in content assert "assets/cover.png" in content # ── pyproject.toml completeness ────────────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})], ) def test_pyproject_has_required_sections(render): """pyproject.toml must have build-system + ruff + mypy + pytest + project-status.""" content = (render / "pyproject.toml").read_text() required = [ "[build-system]", "hatchling", "[tool.ruff]", "[tool.ruff.lint]", "[tool.mypy]", "strict = true", "[tool.pytest.ini_options]", "[tool.project-status]", ] for section in required: assert section in content, f"missing section: {section}" @pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})]) def test_backend_pytest_asyncio_auto(render): """Backend pytest must use asyncio_mode=auto (no @pytest.mark.asyncio).""" pyproject = (render / "pyproject.toml").read_text() assert 'asyncio_mode = "auto"' in pyproject # ── nested src// layout (issue #241) ────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"}), ], ) def test_pyproject_hatch_packages_nested(render): """Hatch packages must be ``["src/"]`` (NOT ``["src"]``). Issue #241: ``packages = ["src"]`` (flat convention) is deprecated — only ``packages = ["src/"]`` is valid for a publishable, reusable-as-git-dep package. """ pyproject = (render / "pyproject.toml").read_text() pkg = extra_context_value(render, "project_name") assert f'packages = ["src/{pkg}"]' in pyproject, ( f"expected packages=['src/{pkg}'], got flat or wrong packages" ) assert 'packages = ["src"]' not in pyproject, "flat packages=['src'] is deprecated" @pytest.mark.parametrize( "template_name, extra_context", [("fullstack", {"project_name": "fs", "use_db": "yes"})], ) def test_pyproject_hatch_packages_nested_fullstack(render): """Fullstack backend pyproject must use nested packages (issue #241).""" pyproject = (render / "backend" / "pyproject.toml").read_text() assert 'packages = ["src/fs"]' in pyproject assert 'packages = ["src"]' not in pyproject @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be", "use_db": "yes"}), ("fullstack", {"project_name": "fs", "use_db": "yes"}), ], ) def test_db_connection_models_path_no_src_prefix(render, template_name): """``_MODELS_PATH`` must be ``.db.models`` (NOT ``src....``). Issue #241: with nested ``src//`` layout the import path is ``.db.models`` (no ``src.`` prefix — there is no ``src`` package). """ if template_name == "backend": conn = render / "src" / "be" / "db" / "connection.py" else: conn = render / "backend" / "src" / "fs" / "db" / "connection.py" text = conn.read_text() assert 'be.db.models"' in text or 'fs.db.models"' in text assert "src." not in text, "_MODELS_PATH must not use src. prefix (nested layout)" def extra_context_value(render, key): """Recover the extra_context value for ``key`` from the rendered project. The fixtures pass ``project_name`` explicitly; we infer it back from the top-level dir name (cookiecutter uses it as the project dir). """ return render.name # ── project-status oracle compatibility ─────────────────────────────────────── def _run_status_checks(repo_root: Path, fast: bool = True) -> tuple[str, list[ps.CheckResult]]: """Run the project-status checks against ``repo_root`` (in-process). Builds a ``RepoCtx`` rooted at ``repo_root`` so check-functions get an explicit context (no module-global mutation after the #242 refactor). """ ctx = ps.RepoCtx(root=repo_root, config=ps.load_config(repo_root)) ptype = ps.detect_project_type(ctx) groups = ps.run_all_checks(ptype, ctx, fast=fast) all_checks = [c for g in groups for c in g.checks] report = ps.format_output(ptype, groups) return report, all_checks @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})], ) def test_backend_project_status_quality_and_readme_pass(render): """Backend template passes the quality + README + infra checks. Note: the structure check (``src/api/v1`` flat layout) expects the layout from issue #2 — out of scope for this PR. The nested ``src//api/v1`` layout (per issue #229) is detected as ``unknown`` by the current oracle; full compatibility lands after issue #2. """ _report, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} # quality (ruff/mypy/pytest) must be OK regardless of layout assert by_name["ruff"].status == ps.CheckStatus.OK assert by_name["mypy"].status == ps.CheckStatus.OK assert by_name["pytest"].status == ps.CheckStatus.OK # README delimiter tags + standard headers must pass assert by_name["12 delimiter tags"].status == ps.CheckStatus.OK assert by_name["# 🚀 "].status == ps.CheckStatus.OK assert by_name["## 🇺🇸 English"].status == ps.CheckStatus.OK assert by_name["## 🇷🇺 Русский"].status == ps.CheckStatus.OK assert by_name["[English](#-english)"].status == ps.CheckStatus.OK # infra: ci.yml + LICENSE + pre-commit assert by_name[".github/workflows/ci.yml"].status == ps.CheckStatus.OK assert by_name["LICENSE"].status == ps.CheckStatus.OK assert by_name["pre-commit"].status == ps.CheckStatus.OK # main.py exists (lifespan check may WARN for unknown type — but file is present) assert (render / "main.py").exists() assert "lifespan" in (render / "main.py").read_text() @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})], ) def test_pyproject_quality_checks_pass(render): """ruff/mypy/pytest presence checks pass in the rendered pyproject.""" _, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} assert by_name["ruff"].status == ps.CheckStatus.OK assert by_name["mypy"].status == ps.CheckStatus.OK assert by_name["pytest"].status == ps.CheckStatus.OK @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})], ) def test_readme_check_passes(render): """README delimiter-tag check passes in the rendered project.""" _, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} assert by_name["12 delimiter tags"].status == ps.CheckStatus.OK @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"}), ], ) def test_infra_checks_present(render): """ci.yml + LICENSE checks pass; pre-commit is present for backend.""" _, checks = _run_status_checks(render, fast=True) by_name = {c.name: c for c in checks} assert by_name[".github/workflows/ci.yml"].status == ps.CheckStatus.OK assert by_name["LICENSE"].status == ps.CheckStatus.OK @pytest.mark.parametrize( "template_name, extra_context", [("fullstack", {"project_name": "fs"})], ) def test_fullstack_passes_project_status_structure(render): """Fullstack is detected as fullstack (backend/ + frontend/ present).""" ptype = ps.ProjectType.FULLSTACK ctx = ps.RepoCtx(root=render, config=ps.load_config(render)) detected = ps.detect_project_type(ctx) assert detected == ptype _, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} assert by_name["backend"].status == ps.CheckStatus.OK assert by_name["frontend"].status == ps.CheckStatus.OK @pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})]) def test_cli_passes_package_check(render): """CLI template renders a src//__init__.py package.""" _, checks = _run_status_checks(render) by_name = {c.name: c for c in checks} assert by_name["src//"].status == ps.CheckStatus.OK # ── fixes from slaid098/templates ──────────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})], ) def test_no_hardcoded_production_ips(render): """IP whitelist defaults to 127.0.0.1 + ::1 (no hardcoded prod IPs).""" deps = (render / "src/be/api/v1/dependencies.py").read_text() assert '"127.0.0.1"' in deps assert '"::1"' in deps # env.example also uses the safe default, never a prod IP env = (render / "env.example").read_text() assert "127.0.0.1" in env assert "::1" in env @pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})]) def test_no_metadata_duplication_in_init(render): """metadata lives ONLY in metadata.py — __init__.py of utils does not redeclare.""" init = (render / "src/be/utils/__init__.py").read_text() meta = (render / "src/be/utils/metadata.py").read_text() # metadata.py declares the dataclass + load_metadata; __init__ only re-exports assert "class ProjectMetadata" in meta assert "def load_metadata" in meta assert "class ProjectMetadata" not in init, "init must not redeclare the dataclass" @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes"})], ) def test_user_service_get_users_returns_list(render): """get_users returns a list (NOT a tuple — bug fixed).""" source = (render / "src/be/services/user_service.py").read_text() # The signature declares list[User] and the body wraps with list(...) assert "list[User]" in source assert "return list(" in source # no `return (` returning a bare tuple of query results assert "return (" not in source assert "return tuple" not in source.lower().replace("not a tuple", "") @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes"})], ) def test_path_relative_not_cwd(render): """main.py uses Path(__file__) for StaticFiles (not CWD-relative).""" main = (render / "main.py").read_text() assert "Path(__file__)" in main # no os.getcwd() reliance assert "os.getcwd()" not in main @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_db": "yes"})], ) def test_migrations_dir_present(render): """migrations/ directory exists for Tortoise built-in migrator (NOT Aerich).""" assert (render / "migrations").is_dir() # the README in migrations mentions Tortoise; no `aerich` CLI commands readme = (render / "migrations/README.md").read_text() assert "Tortoise" in readme assert "aerich" not in readme.lower() @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_auth": "yes"})], ) def test_jwt_auth_template_present(render): """use_auth=yes renders the full JWT auth stack.""" assert (render / "src/be/services/auth_service.py").exists() assert (render / "src/be/api/v1/routes/auth.py").exists() # auth_service uses passlib[bcrypt] + pyjwt auth_src = (render / "src/be/services/auth_service.py").read_text() assert "passlib" in auth_src assert "CryptContext" in auth_src assert "bcrypt" in auth_src assert "jwt" in auth_src.lower() or "import jwt" in auth_src # auth routes expose /login + /register routes = (render / "src/be/api/v1/routes/auth.py").read_text() assert "/login" in routes assert "/register" in routes # schemas include UserCreate/UserLogin/Token schemas = (render / "src/be/schemas/user.py").read_text() assert "class UserCreate" in schemas assert "class UserLogin" in schemas assert "class Token" in schemas # pyproject pulls passlib + pyjwt pyproject = (render / "pyproject.toml").read_text() assert "passlib[bcrypt]" in pyproject assert "pyjwt" in pyproject @pytest.mark.parametrize( "template_name, extra_context", [("backend", {"project_name": "be", "use_auth": "no"})], ) def test_jwt_auth_template_absent_when_disabled(render): """use_auth=no strips the entire JWT auth stack + deps.""" assert not (render / "src/be/services/auth_service.py").exists() assert not (render / "src/be/api/v1/routes/auth.py").exists() pyproject = (render / "pyproject.toml").read_text() assert "passlib" not in pyproject assert "pyjwt" not in pyproject # ── hooks: post_gen_project runs cleanly ────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}), ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"}), ], ) def test_post_gen_hook_strips_conditionals(render, template_name): """The post-gen hook strips db + auth files when both flags are 'no'. Also strips db-dependent files (broken-conditional fix from PR#235 review). """ # same assertions as the structure tests, but explicitly verifies the # hook ran (cookiecutter would have failed otherwise) if template_name == "backend": assert not (render / "src/be/db").exists() assert not (render / "migrations").exists() assert not (render / "src/be/api/v1/routes/users.py").exists() assert not (render / "src/be/services/user_service.py").exists() assert not (render / "src/be/api/v1/dependencies.py").exists() assert not (render / "src/be/schemas/user.py").exists() else: assert not (render / "backend/src/fs/db").exists() assert not (render / "backend/migrations").exists() assert not (render / "backend/src/fs/api/v1/routes/users.py").exists() assert not (render / "backend/src/fs/services/user_service.py").exists() assert not (render / "backend/src/fs/api/v1/dependencies.py").exists() assert not (render / "backend/src/fs/schemas/user.py").exists() # ── thin routes: ≤ 50 lines per handler ─────────────────────────────────────── @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}), ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}), ], ) def test_routes_are_thin(render, template_name): """Each route handler file stays under 50 lines (thin routes contract).""" if template_name == "backend": routes_dir = render / "src/be/api/v1/routes" else: routes_dir = render / "backend/src/fs/api/v1/routes" for route_file in routes_dir.glob("*.py"): if route_file.name == "__init__.py": continue content = route_file.read_text() line_count = len(content.splitlines()) assert line_count <= 50, f"{route_file.name}: {line_count} lines (limit 50)" # ── smoke: no dangling imports of stripped modules ──────────────────────────── # Modules that are removed by the post-gen hook when their dependency flag is # "no". Any surviving ``from .`` import in the generated tree is # an ImportError waiting to happen (the broken-conditional root cause). _STRIPPED_MODULES = { ("db", "no"): ["db.models.user", "db.connection", "services.user_service"], ("auth", "no"): ["services.auth_service", "api.v1.routes.auth"], } @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}), ("backend", {"project_name": "be", "use_db": "no", "use_auth": "yes"}), ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"}), ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}), ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"}), ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}), ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "no"}), ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}), ], ) def test_no_dangling_imports_of_stripped_modules(render, template_name, extra_context): """No generated ``.py`` imports a module that the hook removed. Regression guard for the broken-conditional findings in PR#235 review: when ``use_db=no`` the hook deletes ``db/`` and the db-dependent files, so no surviving file may reference ``db.models.user`` / ``user_service`` / ``schemas.user`` / ``dependencies`` / ``routes.users``. Likewise auth. """ use_db = extra_context.get("use_db", "yes") use_auth = extra_context.get("use_auth", "yes") pkg = extra_context["project_name"] forbidden = [] if use_db == "no": forbidden += [ f"{pkg}.db.models.user", f"{pkg}.db.connection", f"{pkg}.services.user_service", f"{pkg}.schemas.user", f"{pkg}.api.v1.dependencies", f"{pkg}.api.v1.routes.users", ] if use_auth == "no" or use_db == "no": # auth_service imports User -> requires db; stripped when either is "no" forbidden += [ f"{pkg}.services.auth_service", f"{pkg}.api.v1.routes.auth", ] offenders: list[str] = [] for py in render.rglob("*.py"): text = py.read_text() for mod in forbidden: if f"from {mod}" in text or f"import {mod}" in text: offenders.append(f"{py.relative_to(render)}: {mod}") assert not offenders, "dangling imports of stripped modules:\n" + "\n".join(offenders) @pytest.mark.parametrize( "template_name, extra_context", [ ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}), ("backend", {"project_name": "be", "use_db": "no", "use_auth": "yes"}), ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"}), ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}), ], ) def test_main_imports_setup_logging_unconditionally(render): """``main.py`` must import ``setup_logging`` outside the ``use_db`` block. Regression guard for critical #1 of PR#235 review: the lifespan ``else`` branch calls ``setup_logging()`` even when ``use_db=no``, so the import must not be gated behind ``{% if cookiecutter.use_db == "yes" %}``. """ main = (render / "main.py").read_text() assert "from " in main and "setup_logging" in main # the import line itself must NOT sit inside a use_db conditional — # verify by checking the import is present and there is no stray # ``setup_logging()`` call without a preceding import in the same file. import_lines = [ln for ln in main.splitlines() if "import" in ln and "setup_logging" in ln] assert import_lines, "setup_logging not imported in main.py"