"""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() # 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 "" @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