opencode-config/tests/test_cookiecutter_templates.py

1076 lines
47 KiB
Python

"""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 ast
import importlib.util
import json
import re
import sys
from pathlib import Path
import pytest
from cookiecutter.exceptions import FailedHookException
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",
"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",
"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 + Tailwind v4 + shadcn-svelte stack (TS)
assert (render / "frontend/package.json").exists()
assert (render / "frontend/svelte.config.js").exists()
assert (render / "frontend/vite.config.ts").exists()
assert (render / "frontend/vitest.config.ts").exists()
assert (render / "frontend/biome.json").exists()
assert (render / "frontend/knip.json").exists()
assert (render / "frontend/tsconfig.json").exists()
assert (render / "frontend/components.json").exists()
assert (render / "frontend/src/app.html").exists()
assert (render / "frontend/src/app.css").exists()
assert (render / "frontend/src/hooks.server.js").exists()
assert (render / "frontend/src/routes/+layout.svelte").exists()
assert (render / "frontend/src/routes/+page.svelte").exists()
assert (render / "frontend/src/routes/+page.ts").exists()
assert (render / "frontend/src/lib/components/app-sidebar.svelte").exists()
assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
assert (render / "frontend/tests/e2e/app.spec.ts").exists()
# issue #278: mobile-first — PWA manifest + SVG icon + mobile + a11y specs
assert (render / "frontend/static/manifest.webmanifest").exists()
assert (render / "frontend/static/icon.svg").exists()
assert (render / "frontend/tests/e2e/mobile.spec.ts").exists()
assert (render / "frontend/tests/e2e/accessibility.spec.ts").exists()
# removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js
# root CI runs both
assert (render / ".github/workflows/ci.yml").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 (in dependencies or devDependencies)."""
pkg = json.loads((render / "frontend/package.json").read_text())
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
svelte_dep = deps.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 "<!DOCTYPE html" not in error_page, "+error.svelte must not be a full HTML document"
assert "<html" not in error_page, "+error.svelte must not wrap in <html>"
@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_ts = (render / "frontend/src/routes/+page.ts").read_text()
# load returns { title: ... }
assert "title" in page_ts
# +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"
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_has_tailwind_config(render):
"""vite.config.ts wires @tailwindcss/vite; package.json pins tailwindcss."""
vite = (render / "frontend/vite.config.ts").read_text()
assert "tailwindcss" in vite, "vite.config.ts must wire @tailwindcss/vite"
# tailwindcss() must run BEFORE sveltekit() (order matters)
assert vite.index("tailwindcss") < vite.index("sveltekit")
pkg = json.loads((render / "frontend/package.json").read_text())
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
assert "tailwindcss" in deps, "package.json must depend on tailwindcss"
assert "@tailwindcss/vite" in deps, "package.json must depend on @tailwindcss/vite"
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_has_shadcn_components_json(render):
"""components.json configures shadcn-svelte (new-york, neutral, TS)."""
cfg_path = render / "frontend/components.json"
assert cfg_path.exists(), "components.json must exist"
cfg = json.loads(cfg_path.read_text())
assert cfg["style"] == "new-york", f"style must be new-york, got {cfg.get('style')}"
assert cfg["tailwind"]["baseColor"] == "neutral"
assert cfg["typescript"] is True, "typescript must be true"
aliases = cfg["aliases"]
assert aliases["lib"] == "$lib"
assert aliases["utils"] == "$lib/utils"
assert aliases["ui"] == "$lib/components/ui"
assert aliases["hooks"] == "$lib/hooks"
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_layout_has_sidebar_provider(render):
"""+layout.svelte wires the sidebar around the content (directly or via
AppSidebar which embeds Sidebar.Provider)."""
layout = (render / "frontend/src/routes/+layout.svelte").read_text()
has_provider = "Sidebar.Provider" in layout or "SidebarProvider" in layout
has_app_sidebar = "AppSidebar" in layout
assert has_provider or has_app_sidebar, (
"+layout.svelte must wrap content in Sidebar.Provider (directly or via AppSidebar)"
)
# AppSidebar itself must contain the Sidebar.Provider
app_sidebar = (render / "frontend/src/lib/components/app-sidebar.svelte").read_text()
assert "Sidebar.Provider" in app_sidebar or "SidebarProvider" in app_sidebar, (
"app-sidebar.svelte must embed Sidebar.Provider"
)
@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_uses_typescript(render):
"""Frontend is TypeScript: tsconfig.json, no jsconfig.json, +page.ts, lang=ts."""
assert (render / "frontend/tsconfig.json").exists(), "tsconfig.json must exist"
assert not (render / "frontend/jsconfig.json").exists(), "jsconfig.json must be removed"
assert (render / "frontend/src/routes/+page.ts").exists(), "+page.ts must exist"
assert not (render / "frontend/src/routes/+page.js").exists(), "+page.js must be removed"
page_svelte = (render / "frontend/src/routes/+page.svelte").read_text()
assert 'lang="ts"' in page_svelte, '+page.svelte must use <script lang="ts">'
# @tanstack/table-core pinned to ^8 (NOT ^9 — shadcn-svelte pins v8)
pkg = json.loads((render / "frontend/package.json").read_text())
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
table_core = deps.get("@tanstack/table-core", "")
assert table_core.startswith("^8."), (
f"@tanstack/table-core must be ^8.x (shadcn-svelte pin), got {table_core}"
)
# @lucide/svelte (NOT deprecated lucide-svelte)
assert "@lucide/svelte" in deps, "must use @lucide/svelte (not lucide-svelte)"
assert "lucide-svelte" not in deps, "lucide-svelte is deprecated"
# tw-animate-css (NOT tw-animate)
assert "tw-animate-css" in deps, "must use tw-animate-css (not tw-animate)"
assert "tw-animate" not in deps or "tw-animate-css" in deps
# ── 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}"
assert 'readme = "README.md"' not in content, (
"README.md is absent (issue #269) — readme field must not reference it"
)
@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
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "be"}),
("cli", {"project_name": "cl"}),
("fullstack", {"project_name": "fs", "use_db": "yes"}),
],
)
def test_pyproject_no_readme_field(render, template_name):
"""pyproject must not reference README.md (issue #269 — README is absent).
hatchling fails the build when ``readme = "README.md"`` points to a
missing file, so the field must be dropped entirely.
"""
if template_name == "fullstack":
content = (render / "backend" / "pyproject.toml").read_text()
else:
content = (render / "pyproject.toml").read_text()
assert 'readme = "README.md"' not in content, (
"README.md is absent (issue #269) — readme field must not reference it"
)
# ── nested src/<package>/ 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/<package>"]`` (NOT ``["src"]``).
Issue #241: ``packages = ["src"]`` (flat convention) is deprecated —
only ``packages = ["src/<package>"]`` 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 ``<package>.db.models`` (NOT ``src.<package>...``).
Issue #241: with nested ``src/<package>/`` layout the import path is
``<package>.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_infra_pass(render):
"""Backend template passes the quality + infra checks.
README is intentionally absent (issue #269 — generated later via
repo-readme/create-readme on manual call), so the README group yields
WARN, not FAIL (non-blocking). Coverage of the WARN behavior lives in
``test_readme_absent_is_warn``.
Note: the structure check (``src/api/v1`` flat layout) expects the layout
from issue #2 — out of scope for this PR. The nested ``src/<pkg>/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
# 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_absent_is_warn(render):
"""README.md is intentionally absent (issue #269) — project-status yields
WARN, not FAIL (non-blocking, exit 0)."""
_, checks = _run_status_checks(render)
by_name = {c.name: c for c in checks}
assert by_name["README.md"].status == ps.CheckStatus.WARN
assert "12 delimiter tags" not in by_name
@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", "use_db": "yes", "use_auth": "yes"}),
("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}),
],
)
def test_fullstack_passes_project_status_all_groups(render, extra_context):
"""Fullstack template passes the project-status groups (issue #274/#275).
With ``use_db=yes`` all backend-source checks (Structure, Тонкие роуты,
Качество кода, Тесты, Pyproject, Coverage, Infra) resolve against
``backend/`` and yield OK/WARN. With ``use_db=no`` the db dep is absent
from ``pyproject.toml``, so ``db/models`` is auto-skipped and the project
still detects as FULLSTACK; ``Тесты`` may WARN because the use_db=no hook
strips all db-related test files (test_auth.py included when
use_auth=yes, since auth_service imports the removed User).
Issue #275: all checks are non-blocking — no group ever returns FAIL. The
exit code is always 0 (informational mode); this test asserts that no
group contains a FAIL check, which is now guaranteed by the contract.
README is intentionally absent (issue #269 → WARN, non-blocking) and is
not asserted here. Branch protection is skipped via ``--fast`` (WARN).
"""
ptype = ps.ProjectType.FULLSTACK
ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
detected = ps.detect_project_type(ctx)
assert detected == ptype
ctx = ps.RepoCtx(root=render, config=ctx.config, backend_root=ps._backend_root_for(ptype, ctx))
groups = ps.run_all_checks(ptype, ctx, fast=True)
by_name = {g.name: g for g in groups}
# Structure: backend/ + frontend/ present, nested src/<pkg>/api/v1 OK.
structure = by_name["Структура"]
assert structure.overall() != ps.CheckStatus.FAIL, (
f"Структура FAIL: {[c for c in structure.checks if c.status == ps.CheckStatus.FAIL]}"
)
# FULLSTACK backend-source checks are rooted at backend/ (issue #274):
# the expected paths are src/<pkg>/api/v1, schemas, services, ... — and
# root-level backend/ + frontend/ dirs are present (detection requirement).
assert (render / "backend").is_dir(), "fullstack requires backend/ dir"
assert (render / "frontend").is_dir(), "fullstack requires frontend/ dir"
assert any(
c.name == "src/fs/api/v1" and c.status == ps.CheckStatus.OK for c in structure.checks
), f"src/fs/api/v1 not OK: {structure.checks}"
# Issue #275: all checks are WARN (non-blocking) — no group can FAIL.
# Assert every group stays WARN-or-OK (no FAIL check anywhere).
for group_name, g in by_name.items():
failed = [c for c in g.checks if c.status == ps.CheckStatus.FAIL]
assert not failed, f"{group_name} has FAIL checks (issue #275 violation): " + ", ".join(
f"{c.name}={c.status.value}" for c in failed
)
@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
def test_cli_passes_package_check(render):
"""CLI template renders a src/<package>/__init__.py package."""
_, checks = _run_status_checks(render)
by_name = {c.name: c for c in checks}
assert by_name["src/<package>/"].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 <pkg>.<module>`` 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"
# ── issue #262: project_name must be a valid Python identifier ──────────────
# Regex mirrored in every ``pre_gen_project.py`` hook (kept here as a single
# source of truth for the test, validated against the actual hook files).
_VALID_PROJECT_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
@pytest.mark.parametrize(
"template_name, bad_name",
[
("backend", "my-project"), # hyphen
("backend", "my.project"), # dot
("backend", "my project"), # space
("backend", "My_Project"), # uppercase
("backend", "123abc"), # leading digit
("fullstack", "my-fullstack"),
("fullstack", "My_Fullstack"),
("cli", "my-cli"),
("cli", "123cli"),
],
)
def test_pre_gen_hook_rejects_invalid_project_name(tmp_path, template_name, bad_name):
"""pre_gen_project.py exits non-zero for invalid project_name.
Regression guard for issue #262: hyphens/dots/spaces/uppercase/leading
digits in ``project_name`` would render as ``from my-project.X import Y``
which is a ``SyntaxError`` in the generated project. The hook must abort
cookiecutter with ``FailedHookException`` BEFORE any file is rendered.
"""
with pytest.raises(FailedHookException):
cookiecutter(
template=str(TEMPLATES_DIR / template_name),
no_input=True,
output_dir=str(tmp_path),
extra_context={"project_name": bad_name},
)
# nothing rendered
assert not (tmp_path / bad_name).exists()
@pytest.mark.parametrize(
"template_name, good_name",
[
("backend", "my_project"),
("fullstack", "my_fullstack"),
("cli", "my_cli"),
("backend", "be"),
("cli", "cl"),
("fullstack", "fs"),
],
)
def test_pre_gen_hook_accepts_valid_project_name(tmp_path, template_name, good_name):
"""Valid identifiers pass the hook and render a project."""
out = cookiecutter(
template=str(TEMPLATES_DIR / template_name),
no_input=True,
output_dir=str(tmp_path),
extra_context={"project_name": good_name},
)
assert Path(out).is_dir()
@pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"])
def test_cookiecutter_json_default_is_valid_identifier(template_name):
"""The default ``project_name`` in cookiecutter.json passes the hook regex.
Issue #262: defaults must NOT contain hyphens (``my-project``) — they
are used as-is in paths/imports and would break the no-input flow.
"""
cfg = json.loads((TEMPLATES_DIR / template_name / "cookiecutter.json").read_text())
default = cfg["project_name"]
assert _VALID_PROJECT_NAME.fullmatch(default), (
f"default project_name {default!r} in {template_name}/cookiecutter.json "
f"is not a valid Python identifier (^[a-z][a-z0-9_]*$)"
)
# ── issue #274: cookiecutter use_db=no removes db dep (auto-detect) ─────────
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "demo_be", "use_db": "yes", "use_auth": "yes"}),
("cli", {"project_name": "demo_cli"}),
("fullstack", {"project_name": "demo_fs", "use_db": "yes", "use_auth": "yes"}),
],
)
def test_generated_imports_are_parseable(render, template_name, extra_context):
"""Every generated ``.py`` parses with ``ast.parse`` (no SyntaxError).
Issue #262: hyphens in ``project_name`` produced ``from my-project.X
import Y`` which is a ``SyntaxError``. With the pre_gen hook the name
is already a valid identifier, so all generated imports must parse.
"""
pkg = extra_context["project_name"]
offenders: list[str] = []
for py in render.rglob("*.py"):
text = py.read_text()
try:
ast.parse(text)
except SyntaxError as exc:
offenders.append(f"{py.relative_to(render)}: {exc}")
assert not offenders, f"SyntaxError in generated files (pkg={pkg}):\n" + "\n".join(offenders)
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "demo_be"}),
("cli", {"project_name": "demo_cli"}),
("fullstack", {"project_name": "demo_fs"}),
],
)
def test_resolve_package_name_matches_directory(render, template_name, extra_context):
"""``_resolve_package_name`` from project-status matches the package dir.
Issue #262: with a valid identifier ``project_name``, the directory name
equals the package name, and ``_resolve_package_name`` (which normalizes
``[project].name`` via PEP 503) returns the same value. This is the
contract that ``project-status`` relies on to detect BACKEND/FULLSTACK/
CLI without false FAIL/WARN.
"""
pkg = extra_context["project_name"]
ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
# fullstack: pyproject.toml lives under ``backend/`` (see _api_dirs_for
# in project-status.py), so resolve against a backend-rooted context.
if template_name == "fullstack":
ctx = ps.RepoCtx(root=render / "backend", config=ps.load_config(render / "backend"))
resolved = ps._resolve_package_name(ctx)
assert resolved is not None, "_resolve_package_name returned None"
assert resolved == pkg, (
f"_resolve_package_name()={resolved!r} != directory name {pkg!r}; "
f"project-status would look for src/{resolved}/ but the real dir is src/{pkg}/"
)
# fullstack: the package dir lives under backend/src/
if template_name == "fullstack":
assert (render / "backend" / "src" / pkg).is_dir()
else:
assert (render / "src" / pkg).is_dir()