* fix(templates): add pre_gen_project hook validating project_name identifier * fix(templates): use valid identifier defaults in cookiecutter.json * test(templates): add regression tests for project_name identifier validation * fix(tests): resolve fullstack package name via backend ctx --------- Co-authored-by: opencode-agent <agent@opencode.local>
968 lines
40 KiB
Python
968 lines
40 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",
|
|
"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 "<!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_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 = [
|
|
"<!-- tagline-en:start -->",
|
|
"<!-- tagline-en:end -->",
|
|
"<!-- tagline-ru:start -->",
|
|
"<!-- tagline-ru:end -->",
|
|
"<!-- summary-en:start -->",
|
|
"<!-- summary-en:end -->",
|
|
"<!-- features-en:start -->",
|
|
"<!-- features-en:end -->",
|
|
"<!-- summary-ru:start -->",
|
|
"<!-- summary-ru:end -->",
|
|
"<!-- features-ru:start -->",
|
|
"<!-- features-ru:end -->",
|
|
]
|
|
|
|
|
|
@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/<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_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/<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
|
|
# 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/<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_]*$)"
|
|
)
|
|
|
|
|
|
@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()
|