diff --git a/.opencode/templates/backend/cookiecutter.json b/.opencode/templates/backend/cookiecutter.json new file mode 100644 index 0000000..313f5c2 --- /dev/null +++ b/.opencode/templates/backend/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "project_name": "my-project", + "project_type": "backend", + "description": "Project description", + "use_auth": ["no", "yes"], + "use_db": ["yes", "no"], + "python_version": "3.13" +} \ No newline at end of file diff --git a/.opencode/templates/backend/hooks/post_gen_project.py b/.opencode/templates/backend/hooks/post_gen_project.py new file mode 100644 index 0000000..2422648 --- /dev/null +++ b/.opencode/templates/backend/hooks/post_gen_project.py @@ -0,0 +1,60 @@ +"""Post-generation hook for the backend cookiecutter template. + +Removes files that are conditional on the ``use_auth`` and ``use_db`` flags +so the rendered tree only contains the parts the user asked for. + +- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``, + ``models/user.py`` (hashed_password), and strip the auth dependency wiring. +- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +PROJECT_DIR = Path.cwd() + + +def _remove(path: str) -> None: + """Remove a file or directory relative to the generated project root.""" + p = PROJECT_DIR / path + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + elif p.exists(): + p.unlink() + + +def main() -> None: + use_auth = "{{ cookiecutter.use_auth }}" + use_db = "{{ cookiecutter.use_db }}" + pkg = "{{ cookiecutter.project_name }}" + + if use_auth == "no": + _remove(f"src/{pkg}/api/v1/routes/auth.py") + _remove(f"src/{pkg}/services/auth_service.py") + _remove(f"tests/test_auth.py") + + if use_db == "no": + # db is the root cause for the broken-conditional findings: files + # with unconditional ``from ...db.models.user import User`` must be + # stripped together with db/, otherwise the generated project + # fails to import (ImportError/NameError on startup). + _remove(f"src/{pkg}/db") + _remove("migrations") + _remove(f"src/{pkg}/services/user_service.py") + _remove(f"src/{pkg}/api/v1/routes/users.py") + _remove(f"src/{pkg}/api/v1/dependencies.py") + _remove(f"src/{pkg}/schemas/user.py") + _remove(f"tests/unit/test_user.py") + _remove(f"tests/unit/test_user_service.py") + _remove(f"tests/api/test_users.py") + if use_auth == "yes": + # auth_service imports User; strip it and its wiring too. + _remove(f"src/{pkg}/api/v1/routes/auth.py") + _remove(f"src/{pkg}/services/auth_service.py") + _remove(f"tests/test_auth.py") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml b/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml new file mode 100644 index 0000000..68d50b7 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml b/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml new file mode 100644 index 0000000..1b30a39 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run ruff check . + - run: uv run ruff format --check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run mypy src tests + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run pytest + + build: + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv build \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore b/.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore new file mode 100644 index 0000000..f1f9a7a --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.sqlite3 +*.db +.env +.venv/ +venv/ \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/.pre-commit-config.yaml b/.opencode/templates/backend/{{cookiecutter.project_name}}/.pre-commit-config.yaml new file mode 100644 index 0000000..12016d2 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: [pydantic-settings] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/.python-version b/.opencode/templates/backend/{{cookiecutter.project_name}}/.python-version new file mode 100644 index 0000000..bff1460 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/.python-version @@ -0,0 +1 @@ +{{ cookiecutter.python_version }} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/LICENSE b/.opencode/templates/backend/{{cookiecutter.project_name}}/LICENSE new file mode 100644 index 0000000..3a27a77 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {% now 'utc', '%Y' %} slaid098 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/README.md b/.opencode/templates/backend/{{cookiecutter.project_name}}/README.md new file mode 100644 index 0000000..4f9f883 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/README.md @@ -0,0 +1,67 @@ +# πŸš€ {{ cookiecutter.project_name }} + +> Language switcher: **[English](#-english)** | **[Русский](#-русский)** + +![Cover](assets/cover.png) + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + +## πŸ‡ΊπŸ‡Έ English + + +- FastAPI backend with Tortoise ORM +- Pydantic-settings configuration +- Ruff + mypy strict + pytest +- Pre-commit hooks + + +### ⚑ Quick Start + +```bash +uv sync --extra dev +uv run uvicorn main:app --reload +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** + +--- + +## πŸ‡·πŸ‡Ί Русский + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + + +- БэкСнд Π½Π° FastAPI с Tortoise ORM +- ΠšΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡ Ρ‡Π΅Ρ€Π΅Π· pydantic-settings +- Ruff + mypy strict + pytest +- Pre-commit Ρ…ΡƒΠΊΠΈ + + +### ⚑ Быстрый старт + +```bash +uv sync --extra dev +uv run uvicorn main:app --reload +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/env.example b/.opencode/templates/backend/{{cookiecutter.project_name}}/env.example new file mode 100644 index 0000000..f3fdbf8 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/env.example @@ -0,0 +1,18 @@ +# Application +APP__ENVIRONMENT=dev +SERVER__HOST=0.0.0.0 +SERVER__PORT=8000 + +# Database (nested via __ separator β€” pydantic-settings convention) +{% if cookiecutter.use_db == "yes" %} +DATABASE__URL=sqlite://db.sqlite3 +{% endif %} +{% if cookiecutter.use_auth == "yes" %} +# Auth +JWT__SECRET=change-me-in-production +JWT__ALGORITHM=HS256 +JWT__EXPIRE_MINUTES=60 +{% endif %} + +# IP whitelist (no hardcoded production IPs β€” override in production) +IP_WHITELIST=["127.0.0.1", "::1"] \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/main.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/main.py new file mode 100644 index 0000000..c3531e5 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/main.py @@ -0,0 +1,42 @@ +"""FastAPI application entry point for {{ cookiecutter.project_name }}.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from {{ cookiecutter.project_name }}.config.logger import setup_logging +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.db.connection import close_db, init_db +{% endif %} +from {{ cookiecutter.project_name }}.api.router import api_router + +_BASE_DIR = Path(__file__).resolve().parent + + +@asynccontextmanager +async def lifespan(app: FastAPI): + {% if cookiecutter.use_db == "yes" %}setup_logging() + await init_db() + try: + yield + finally: + await close_db(){% else %}setup_logging() + yield{% endif %} + + +app = FastAPI(lifespan=lifespan) + +static_dir = _BASE_DIR / "static" +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=static_dir), name="static") + +app.include_router(api_router, prefix="/api") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/migrations/README.md b/.opencode/templates/backend/{{cookiecutter.project_name}}/migrations/README.md new file mode 100644 index 0000000..82137a6 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/migrations/README.md @@ -0,0 +1,8 @@ +# Tortoise migrations directory + +This directory holds migration files generated by the built-in Tortoise +migrator. Run: + + python -m tortoise.migrator makemigrations + +Generated files land here and are committed to git. \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml b/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml new file mode 100644 index 0000000..2c9544e --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml @@ -0,0 +1,139 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ cookiecutter.project_name }}" +version = "0.1.0" +description = "{{ cookiecutter.description }}" +readme = "README.md" +license = "MIT" +requires-python = ">={{ cookiecutter.python_version }}" +authors = [{ name = "slaid098" }] +keywords = [] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "fastapi", + "uvicorn[standard]", + "pydantic-settings", + "loguru", +{% if cookiecutter.use_db == "yes" %} + "tortoise-orm", + "asyncpg", +{% endif %} +{% if cookiecutter.use_auth == "yes" %} + "passlib[bcrypt]", + "pyjwt", +{% endif %} +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-asyncio>=0.23", + "pytest-timeout>=2.2", + "httpx", + "mypy>=1.10", + "ruff>=0.5", + "pre-commit>=3.7", +] + +[project.urls] +Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ── Ruff ────────────────────────────────────────────────────────────────── + +[tool.ruff] +target-version = "py313" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", + "F", + "I", + "B", + "UP", + "SIM", + "C90", + "PL", + "RUF", + "S", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S311", + "RUF001", + "RUF002", + "RUF003", + "TRY003", + "PLR2004", + "S106", +] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-returns = 5 +max-statements = 50 + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"] + +# ── mypy ────────────────────────────────────────────────────────────────── + +[tool.mypy] +python_version = "{{ cookiecutter.python_version }}" +strict = true +explicit_package_bases = true +warn_return_any = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +# ── pytest ──────────────────────────────────────────────────────────────── + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=src --cov-report=term-missing --timeout=120" + +# ── coverage ────────────────────────────────────────────────────────────── + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +# ── project-status ───────────────────────────────────────────────────────── + +[tool.project-status] +route_line_limit = 50 +min_test_count = 1 +require_branch_protection = false \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py new file mode 100644 index 0000000..7172ae5 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py @@ -0,0 +1 @@ +"""{{ cookiecutter.project_name }} package.""" \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/__init__.py new file mode 100644 index 0000000..1f82bf8 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/__init__.py @@ -0,0 +1,3 @@ +"""API package for {{ cookiecutter.project_name }}.""" + +from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/router.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/router.py new file mode 100644 index 0000000..4a58c62 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/router.py @@ -0,0 +1,8 @@ +"""API router aggregation for {{ cookiecutter.project_name }}.""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.api.v1.router import v1_router + +api_router = APIRouter() +api_router.include_router(v1_router, prefix="/v1") \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/__init__.py new file mode 100644 index 0000000..a89c490 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/__init__.py @@ -0,0 +1,3 @@ +"""v1 API package.""" + +from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/dependencies.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/dependencies.py new file mode 100644 index 0000000..84b7a8d --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/dependencies.py @@ -0,0 +1,49 @@ +"""Shared dependencies for v1 routes. + +``check_ip_whitelist`` is always present; ``get_current_user`` is wired +only when ``use_auth == "yes``. +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request, status + +from {{ cookiecutter.project_name }}.config.settings import settings + +DEFAULT_WHITELIST = ["127.0.0.1", "::1"] + + +async def check_ip_whitelist(request: Request) -> None: + """Reject requests from non-whitelisted IPs. + + Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs); + override via ``IP_WHITELIST`` in env. + """ + client = request.client.host if request.client else None + whitelist = settings.ip_whitelist or DEFAULT_WHITELIST + if client and client not in whitelist: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"IP {client} not allowed", + ) + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + +_bearer = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer), + auth: AuthService = Depends(AuthService), +) -> str: + """Resolve the current user from the bearer token (JWT). + + Returns the username/subject of the token. Only present when + ``use_auth == "yes"``. + """ + return await auth.get_current_user(credentials.credentials) +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/router.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/router.py new file mode 100644 index 0000000..71ce2d7 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/router.py @@ -0,0 +1,22 @@ +"""Versioned router (v1) for {{ cookiecutter.project_name }}.""" + +from fastapi import APIRouter +{% if cookiecutter.use_db == "yes" %} +from fastapi import Depends + +from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist +from {{ cookiecutter.project_name }}.api.v1.routes import users +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import auth +{% endif %} + +{% if cookiecutter.use_db == "yes" %} +v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)]) +v1_router.include_router(users.router, prefix="/users", tags=["users"]) +{% else %} +v1_router = APIRouter() +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +v1_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py new file mode 100644 index 0000000..beaeccf --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py @@ -0,0 +1,8 @@ +"""Routes package for v1.""" + +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401 +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py new file mode 100644 index 0000000..d96d937 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py @@ -0,0 +1,22 @@ +"""Auth routes β€” /login, /register (only when use_auth=yes).""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + +router = APIRouter() + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register(payload: UserCreate) -> UserResponse: + """Register a new user β€” returns the public profile.""" + user = await AuthService.register(payload.username, payload.email, payload.password) + return UserResponse(id=user.id, username=user.username, email=user.email) + + +@router.post("/login", response_model=Token) +async def login(payload: UserLogin) -> Token: + """Login with username + password β€” returns a JWT.""" + access_token = await AuthService.login(payload.username, payload.password) + return Token(access_token=access_token) \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/users.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/users.py new file mode 100644 index 0000000..97600bb --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/api/v1/routes/users.py @@ -0,0 +1,22 @@ +"""User routes β€” thin handlers (≀50 lines), delegate to services.""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.schemas.user import UserResponse +from {{ cookiecutter.project_name }}.services.user_service import UserService + +router = APIRouter() + + +@router.get("", response_model=list[UserResponse]) +async def list_users() -> list[UserResponse]: + """List users β€” thin handler, business logic lives in the service.""" + users = await UserService.get_users() + return [UserResponse.model_validate(u) for u in users] + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user(user_id: int) -> UserResponse: + """Get a single user by id β€” thin handler.""" + user = await UserService.get_user(user_id) + return UserResponse.model_validate(user) \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/__init__.py new file mode 100644 index 0000000..48d9704 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/__init__.py @@ -0,0 +1,3 @@ +"""Configuration package β€” settings + logger.""" + +from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/logger.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/logger.py new file mode 100644 index 0000000..946777e --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/logger.py @@ -0,0 +1,17 @@ +"""Loguru logging setup.""" + +from __future__ import annotations + +import sys + +from loguru import logger + + +def setup_logging() -> None: + """Configure loguru sink β€” remove default handler, add stdout.""" + logger.remove() + logger.add( + sys.stdout, + level="INFO", + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", + ) \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/settings.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/settings.py new file mode 100644 index 0000000..fbaa901 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/config/settings.py @@ -0,0 +1,59 @@ +"""Application settings via pydantic-settings. + +Nested fields use the ``__`` separator (pydantic-settings convention): +``DATABASE__URL`` -> ``settings.database.url``. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Environment(StrEnum): + DEV = "dev" + STAGING = "staging" + PROD = "prod" + + +class ServerSettings(BaseSettings): + host: str = "0.0.0.0" + port: int = 8000 + + +{% if cookiecutter.use_db == "yes" %} +class DatabaseSettings(BaseSettings): + url: str = "sqlite://db.sqlite3" +{% endif %} + + +{% if cookiecutter.use_auth == "yes" %} +class JWTSettings(BaseSettings): + secret: str = "change-me-in-production" + algorithm: str = "HS256" + expire_minutes: int = 60 +{% endif %} + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + environment: Environment = Environment.DEV + server: ServerSettings = Field(default_factory=ServerSettings) +{% if cookiecutter.use_db == "yes" %} + database: DatabaseSettings = Field(default_factory=DatabaseSettings) +{% endif %} +{% if cookiecutter.use_auth == "yes" %} + jwt: JWTSettings = Field(default_factory=JWTSettings) +{% endif %} + ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"]) + + +settings = Settings() \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/__init__.py new file mode 100644 index 0000000..fa9e7e8 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/__init__.py @@ -0,0 +1,5 @@ +{% if cookiecutter.use_db == "yes" %}"""DB package β€” Tortoise ORM connection + models.""" + +from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401 +{% else %}"""DB package (disabled β€” use_db=no).""" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py new file mode 100644 index 0000000..5a11329 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py @@ -0,0 +1,29 @@ +"""Tortoise ORM connection setup. + +Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is +portable regardless of the working directory it is launched from. +""" + +from __future__ import annotations + +from pathlib import Path + +from tortoise import Tortoise + +from {{ cookiecutter.project_name }}.config.settings import settings + +_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" + + +async def init_db() -> None: + """Initialize Tortoise with generate_schemas (built-in, NOT Aerich).""" + await Tortoise.init( + db_url=settings.database.url, + modules={"models": [_MODELS_PATH]}, + ) + await Tortoise.generate_schemas(safe=True) + + +async def close_db() -> None: + """Close all Tortoise connections.""" + await Tortoise.close_connections() \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/__init__.py new file mode 100644 index 0000000..6f091ec --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/__init__.py @@ -0,0 +1,3 @@ +"""Tortoise ORM models.""" + +from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/user.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/user.py new file mode 100644 index 0000000..4e095c1 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/models/user.py @@ -0,0 +1,25 @@ +"""User model. + +When ``use_auth == "yes"`` the ``hashed_password`` field is present; +otherwise the model holds only the public profile fields. +""" + +from __future__ import annotations + +from tortoise import fields +from tortoise.models import Model + + +class User(Model): + id = fields.IntField(pk=True) + username = fields.CharField(max_length=64, unique=True) + email = fields.CharField(max_length=128, unique=True) +{% if cookiecutter.use_auth == "yes" %} + hashed_password = fields.CharField(max_length=128) +{% endif %} + + class Meta: + table = "users" + + def __str__(self) -> str: + return self.username \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/__init__.py new file mode 100644 index 0000000..2394fba --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/__init__.py @@ -0,0 +1,6 @@ +"""Pydantic schemas.""" + +from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401 +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/base.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/base.py new file mode 100644 index 0000000..c166d15 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/base.py @@ -0,0 +1,24 @@ +"""Base schemas β€” shared response envelopes.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class MetaResponse(BaseModel): + """Standard meta block for API responses.""" + + total: int + page: int = 1 + page_size: int = 20 + + +class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated envelope: ``{items, meta}``.""" + + items: list[T] + meta: MetaResponse \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/user.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/user.py new file mode 100644 index 0000000..cb6c2d8 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/schemas/user.py @@ -0,0 +1,49 @@ +"""User schemas. + +When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``, +``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput`` +are always present. +""" + +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class UserInput(BaseModel): + """Input payload for creating a user.""" + + username: str = Field(min_length=3, max_length=64) + email: EmailStr + + +class UserResponse(BaseModel): + """Public user representation (never leaks the password).""" + + id: int + username: str + email: EmailStr + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +class UserCreate(BaseModel): + """Registration payload β€” username, email, plain password.""" + + username: str = Field(min_length=3, max_length=64) + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class UserLogin(BaseModel): + """Login payload β€” username + plain password.""" + + username: str + password: str + + +class Token(BaseModel): + """JWT response envelope.""" + + access_token: str + token_type: str = "bearer" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/__init__.py new file mode 100644 index 0000000..438b949 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/__init__.py @@ -0,0 +1,8 @@ +"""Services package β€” business logic (Tortoise queries).""" + +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.services.user_service import UserService # noqa: F401 +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.services.auth_service import AuthService # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/auth_service.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/auth_service.py new file mode 100644 index 0000000..e56d77c --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/auth_service.py @@ -0,0 +1,68 @@ +"""Auth service β€” JWT issuance + verification (passlib[bcrypt] + pyjwt). + +Only rendered when ``use_auth == "yes"``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from fastapi import HTTPException, status +from passlib.context import CryptContext + +from {{ cookiecutter.project_name }}.config.settings import settings +from {{ cookiecutter.project_name }}.db.models.user import User + +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class AuthService: + """Stateless auth service β€” JWT + password hashing.""" + + @staticmethod + def hash_password(password: str) -> str: + return _pwd.hash(password) + + @staticmethod + def verify_password(plain: str, hashed: str) -> bool: + return _pwd.verify(plain, hashed) + + @staticmethod + def create_token(username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.jwt.expire_minutes, + ) + payload: dict[str, Any] = {"sub": username, "exp": expire} + return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm) + + @staticmethod + async def register(username: str, email: str, password: str) -> User: + hashed = AuthService.hash_password(password) + return await User.create( + username=username, email=email, hashed_password=hashed, + ) + + @staticmethod + async def login(username: str, password: str) -> str: + user = await User.get_or_none(username=username) + if user is None or not AuthService.verify_password(password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + return AuthService.create_token(username) + + @staticmethod + async def get_current_user(token: str) -> str: + try: + payload = jwt.decode( + token, settings.jwt.secret, algorithms=[settings.jwt.algorithm], + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from exc + return str(payload.get("sub", "")) \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/user_service.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/user_service.py new file mode 100644 index 0000000..1c004fa --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/services/user_service.py @@ -0,0 +1,37 @@ +"""User service β€” business logic (Tortoise queries). + +Fix from slaid098/templates: ``get_users`` returns a plain ``list`` of +User instances (the old implementation returned a tuple while every caller +expected a list). +""" + +from __future__ import annotations + +from fastapi import HTTPException, status + +from {{ cookiecutter.project_name }}.db.models.user import User + + +class UserService: + """Stateless user service β€” all methods are async classmethods.""" + + @staticmethod + async def get_users() -> list[User]: + """Return all users as a list (NOT a tuple β€” bug fixed).""" + return list(await User.all()) + + @staticmethod + async def get_user(user_id: int) -> User: + """Return a single user by id or raise 404.""" + user = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found", + ) + return user + + @staticmethod + async def create_user(username: str, email: str) -> User: + """Create a new user.""" + return await User.create(username=username, email=email) \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/__init__.py new file mode 100644 index 0000000..aad30da --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/__init__.py @@ -0,0 +1,7 @@ +"""Utils package. + +NOTE: ProjectMetadata lives ONLY in ``metadata.py`` β€” no duplication in +``__init__.py`` (fix from slaid098/templates). +""" + +from {{ cookiecutter.project_name }}.utils.metadata import ProjectMetadata # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/metadata.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/metadata.py new file mode 100644 index 0000000..f3470cc --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/utils/metadata.py @@ -0,0 +1,35 @@ +"""Project metadata read from ``pyproject.toml`` (single source of truth). + +No duplication β€” callers import from here, never re-declare the metadata. +Uses ``Path(__file__)`` to locate ``pyproject.toml`` regardless of CWD. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml" + + +@dataclass(frozen=True) +class ProjectMetadata: + name: str + version: str + description: str + + +def load_metadata() -> ProjectMetadata: + """Load metadata from ``pyproject.toml`` (single source of truth).""" + with _PYPROJECT.open("rb") as f: + data = tomllib.load(f) + project = data.get("project", {}) + return ProjectMetadata( + name=str(project.get("name", "")), + version=str(project.get("version", "")), + description=str(project.get("description", "")), + ) + + +metadata = load_metadata() \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/__init__.py new file mode 100644 index 0000000..d25a4be --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/__init__.py new file mode 100644 index 0000000..cba2c6c --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/__init__.py @@ -0,0 +1 @@ +"""API tests package.""" \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/test_users.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/test_users.py new file mode 100644 index 0000000..4e2950d --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/api/test_users.py @@ -0,0 +1,20 @@ +"""API tests for user routes (thin handlers β†’ services).""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_list_users(client) -> None: + """GET /api/v1/users returns a list of users.""" + response = await client.get("/api/v1/users") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_ip_whitelist_blocks(client) -> None: + """Non-whitelisted IPs must get 403.""" + response = await client.get("/api/v1/users", headers={"X-Forwarded-For": "10.0.0.1"}) + assert response.status_code == 403 \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/conftest.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/conftest.py new file mode 100644 index 0000000..d240427 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/conftest.py @@ -0,0 +1,57 @@ +"""Pytest configuration β€” fixtures shared across all tests.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +from {{ cookiecutter.project_name }}.config.settings import settings + + +@pytest.fixture(autouse=True) +def mock_settings(monkeypatch) -> None: + """Override settings with safe test defaults (no real DB/SMTP/etc).""" + monkeypatch.setattr(settings, "environment", "dev") + monkeypatch.setattr(settings, "ip_whitelist", ["127.0.0.1", "::1"]) + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncClient]: + """HTTPX AsyncClient bound to the FastAPI app (no network).""" + from main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +@pytest.fixture +async def create_user() -> Any: + """Factory: create a user in the test DB.""" + from {{ cookiecutter.project_name }}.db.models.user import User + from {{ cookiecutter.project_name }}.services.auth_service import AuthService + + async def _create(username: str = "tester", password: str = "password123") -> User: + hashed = AuthService.hash_password(password) + return await User.create(username=username, email=f"{username}@test.local", hashed_password=hashed) + + return _create + + +@pytest.fixture +async def auth_client(create_user) -> AsyncIterator[AsyncClient]: + """HTTPX client with a valid bearer token pre-set.""" + from main import app + + await create_user() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/api/v1/auth/login", json={"username": "tester", "password": "password123"}) + token = resp.json()["access_token"] + ac.headers["Authorization"] = f"Bearer {token}" + yield ac +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/__init__.py new file mode 100644 index 0000000..d431cdf --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/test_real_external.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/test_real_external.py new file mode 100644 index 0000000..11761d1 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/integration/test_real_external.py @@ -0,0 +1,24 @@ +"""Integration tests β€” skip by default unless real credentials are set. + +``pytestmark`` = [pytest.mark.integration, skipif no creds] β€” these only +run when an explicit env var is set (e.g. ``RUN_INTEGRATION=1``). +""" + +from __future__ import annotations + +import os + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.getenv("RUN_INTEGRATION"), + reason="no real external credentials β€” set RUN_INTEGRATION=1 to enable", + ), +] + + +async def test_real_external_placeholder() -> None: + """Placeholder β€” replace with a real external service call.""" + assert True \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/test_auth.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/test_auth.py new file mode 100644 index 0000000..e04441f --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/test_auth.py @@ -0,0 +1,26 @@ +"""Auth flow tests β€” /register, /login (only when use_auth=yes).""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_register(client) -> None: + """POST /api/v1/auth/register creates a user.""" + response = await client.post( + "/api/v1/auth/register", + json={"username": "newuser", "email": "new@test.local", "password": "password123"}, + ) + assert response.status_code == 201 + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_login_returns_token(client) -> None: + """POST /api/v1/auth/login returns a JWT.""" + response = await client.post( + "/api/v1/auth/login", + json={"username": "tester", "password": "password123"}, + ) + assert response.status_code == 200 + assert "access_token" in response.json() \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/__init__.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/__init__.py new file mode 100644 index 0000000..0b04822 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user.py new file mode 100644 index 0000000..07207a4 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user.py @@ -0,0 +1,31 @@ +"""Unit tests for the User model.""" + +from __future__ import annotations + +import pytest + +{% if cookiecutter.use_auth == "yes" %} +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + + +def test_hash_password_is_not_plain() -> None: + """hash_password must not store the plain text.""" + hashed = AuthService.hash_password("mypassword") + assert hashed != "mypassword" + assert hashed.startswith("$2") # bcrypt prefix + + +def test_verify_password_roundtrip() -> None: + """verify_password must accept the correct password.""" + hashed = AuthService.hash_password("mypassword") + assert AuthService.verify_password("mypassword", hashed) is True + assert AuthService.verify_password("wrong", hashed) is False +{% else %} + + +def test_user_model_table_name() -> None: + """User model must use the 'users' table.""" + from {{ cookiecutter.project_name }}.db.models.user import User + + assert User._meta.db_table == "users" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user_service.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user_service.py new file mode 100644 index 0000000..aa7c8c3 --- /dev/null +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/tests/unit/test_user_service.py @@ -0,0 +1,14 @@ +"""Unit tests for UserService (business logic, not HTTP).""" + +from __future__ import annotations + +import pytest + +from {{ cookiecutter.project_name }}.services.user_service import UserService + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_get_users_returns_list() -> None: + """get_users must return a list (NOT a tuple β€” bug fixed).""" + users = await UserService.get_users() + assert isinstance(users, list) \ No newline at end of file diff --git a/.opencode/templates/cli/cookiecutter.json b/.opencode/templates/cli/cookiecutter.json new file mode 100644 index 0000000..ac8dbe2 --- /dev/null +++ b/.opencode/templates/cli/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "project_name": "my-cli", + "project_type": "cli", + "description": "CLI tool description", + "use_auth": ["no", "yes"], + "use_db": ["yes", "no"], + "python_version": "3.13" +} \ No newline at end of file diff --git a/.opencode/templates/cli/hooks/post_gen_project.py b/.opencode/templates/cli/hooks/post_gen_project.py new file mode 100644 index 0000000..d14a0b3 --- /dev/null +++ b/.opencode/templates/cli/hooks/post_gen_project.py @@ -0,0 +1,31 @@ +"""Post-generation hook for the cli cookiecutter template. + +The cli template is unconditional (no use_auth/use_db flags affect it), +but the hook is kept for parity with backend/fullstack so the same +contract applies. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +PROJECT_DIR = Path.cwd() + + +def _remove(path: str) -> None: + """Remove a file or directory relative to the generated project root.""" + p = PROJECT_DIR / path + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + elif p.exists(): + p.unlink() + + +def main() -> None: + """No conditional files for the cli template yet β€” placeholder.""" + return None + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml b/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml new file mode 100644 index 0000000..68d50b7 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml b/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml new file mode 100644 index 0000000..1b30a39 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run ruff check . + - run: uv run ruff format --check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run mypy src tests + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run pytest + + build: + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv build \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore b/.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore new file mode 100644 index 0000000..a0d1e1e --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.env +.venv/ +venv/ \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/.pre-commit-config.yaml b/.opencode/templates/cli/{{cookiecutter.project_name}}/.pre-commit-config.yaml new file mode 100644 index 0000000..8f97d1c --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/.python-version b/.opencode/templates/cli/{{cookiecutter.project_name}}/.python-version new file mode 100644 index 0000000..bff1460 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/.python-version @@ -0,0 +1 @@ +{{ cookiecutter.python_version }} \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/LICENSE b/.opencode/templates/cli/{{cookiecutter.project_name}}/LICENSE new file mode 100644 index 0000000..3a27a77 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {% now 'utc', '%Y' %} slaid098 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/README.md b/.opencode/templates/cli/{{cookiecutter.project_name}}/README.md new file mode 100644 index 0000000..7ee5d14 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/README.md @@ -0,0 +1,65 @@ +# πŸš€ {{ cookiecutter.project_name }} + +> Language switcher: **[English](#-english)** | **[Русский](#-русский)** + +![Cover](assets/cover.png) + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + +## πŸ‡ΊπŸ‡Έ English + + +- Typer-based CLI with rich output +- Ruff + mypy strict + pytest +- Pre-commit hooks + + +### ⚑ Quick Start + +```bash +uv sync --extra dev +{{ cookiecutter.project_name }} --help +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** + +--- + +## πŸ‡·πŸ‡Ί Русский + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + + +- CLI Π½Π° Typer с rich-Π²Ρ‹Π²ΠΎΠ΄ΠΎΠΌ +- Ruff + mypy strict + pytest +- Pre-commit Ρ…ΡƒΠΊΠΈ + + +### ⚑ Быстрый старт + +```bash +uv sync --extra dev +{{ cookiecutter.project_name }} --help +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml b/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml new file mode 100644 index 0000000..53f8e73 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml @@ -0,0 +1,130 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ cookiecutter.project_name }}" +version = "0.1.0" +description = "{{ cookiecutter.description }}" +readme = "README.md" +license = "MIT" +requires-python = ">={{ cookiecutter.python_version }}" +authors = [{ name = "slaid098" }] +keywords = ["cli"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "typer>=0.12", + "rich", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-timeout>=2.2", + "mypy>=1.10", + "ruff>=0.5", + "pre-commit>=3.7", +] + +[project.scripts] +{{ cookiecutter.project_name }} = "{{ cookiecutter.project_name }}.__main__:app" + +[project.urls] +Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ── Ruff ────────────────────────────────────────────────────────────────── + +[tool.ruff] +target-version = "py313" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", + "F", + "I", + "B", + "UP", + "SIM", + "C90", + "PL", + "RUF", + "S", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S311", + "RUF001", + "RUF002", + "RUF003", + "TRY003", + "PLR2004", + "S106", +] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-returns = 5 +max-statements = 50 + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"] + +# ── mypy ────────────────────────────────────────────────────────────────── + +[tool.mypy] +python_version = "{{ cookiecutter.python_version }}" +strict = true +explicit_package_bases = true +warn_return_any = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +# ── pytest ──────────────────────────────────────────────────────────────── + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=src --cov-report=term-missing --timeout=120" + +# ── coverage ────────────────────────────────────────────────────────────── + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +# ── project-status ───────────────────────────────────────────────────────── + +[tool.project-status] +route_line_limit = 50 +min_test_count = 1 +require_branch_protection = false \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py new file mode 100644 index 0000000..36d6241 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__init__.py @@ -0,0 +1,3 @@ +"""{{ cookiecutter.project_name }} package.""" + +from {{ cookiecutter.project_name }}.core import __version__ # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__main__.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__main__.py new file mode 100644 index 0000000..a2afc25 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/__main__.py @@ -0,0 +1,6 @@ +"""CLI entry point β€” ``python -m {{ cookiecutter.project_name }}``.""" + +from {{ cookiecutter.project_name }}.cli import app + +if __name__ == "__main__": + app() \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/cli.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/cli.py new file mode 100644 index 0000000..ac29f88 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/cli.py @@ -0,0 +1,21 @@ +"""Typer CLI commands for {{ cookiecutter.project_name }}.""" + +from __future__ import annotations + +import typer + +from {{ cookiecutter.project_name }}.core import greet, __version__ + +app = typer.Typer(help="{{ cookiecutter.description }}", no_args_is_help=True) + + +@app.command() +def hello(name: str = typer.Argument("world", help="Name to greet")) -> None: + """Print a greeting for the given name.""" + typer.echo(greet(name)) + + +@app.command() +def version() -> None: + """Print the installed version.""" + typer.echo(__version__) \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/core.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/core.py new file mode 100644 index 0000000..d91f10d --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/core.py @@ -0,0 +1,16 @@ +"""Business logic for {{ cookiecutter.project_name }}. + +The CLI layer (``cli.py``) is thin β€” all logic lives here so it can be +unit-tested without invoking the Typer runner. +""" + +from __future__ import annotations + +__version__ = "0.1.0" + + +def greet(name: str) -> str: + """Return a greeting string for the given name.""" + if not name: + return "Hello, stranger!" + return f"Hello, {name}!" \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/__init__.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/__init__.py new file mode 100644 index 0000000..d25a4be --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/conftest.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/conftest.py new file mode 100644 index 0000000..ce8d5db --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/conftest.py @@ -0,0 +1,20 @@ +"""Pytest configuration for the CLI template.""" + +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from {{ cookiecutter.project_name }} import cli + + +@pytest.fixture +def runner() -> CliRunner: + """Typer CliRunner β€” invokes the app in-process.""" + return CliRunner() + + +@pytest.fixture +def app(): + """The Typer app instance.""" + return cli.app \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_cli.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_cli.py new file mode 100644 index 0000000..796c837 --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_cli.py @@ -0,0 +1,34 @@ +"""CLI tests β€” invoke the Typer app via CliRunner.""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from {{ cookiecutter.project_name }}.cli import app + + +def test_hello_default(runner: CliRunner) -> None: + """``hello`` with no arg greets the world.""" + result = runner.invoke(app, ["hello"]) + assert result.exit_code == 0 + assert "Hello, world!" in result.stdout + + +def test_hello_name(runner: CliRunner) -> None: + """``hello `` greets the given name.""" + result = runner.invoke(app, ["hello", "Alice"]) + assert result.exit_code == 0 + assert "Hello, Alice!" in result.stdout + + +def test_version(runner: CliRunner) -> None: + """``version`` prints the package version.""" + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.stdout + + +def test_no_args_shows_help(runner: CliRunner) -> None: + """No args β†’ help (no_args_is_help=True).""" + result = runner.invoke(app, []) + assert result.exit_code != 0 or "Usage" in result.stdout \ No newline at end of file diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_core.py b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_core.py new file mode 100644 index 0000000..20f87ef --- /dev/null +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/tests/test_core.py @@ -0,0 +1,21 @@ +"""Unit tests for core business logic (no Typer).""" + +from __future__ import annotations + +from {{ cookiecutter.project_name }}.core import __version__, greet + + +def test_greet_name() -> None: + """greet returns a personalized greeting.""" + assert greet("Alice") == "Hello, Alice!" + + +def test_greet_empty() -> None: + """greet with empty name falls back to stranger.""" + assert greet("") == "Hello, stranger!" + + +def test_version_is_string() -> None: + """__version__ is a non-empty string.""" + assert isinstance(__version__, str) + assert __version__ \ No newline at end of file diff --git a/.opencode/templates/fullstack/cookiecutter.json b/.opencode/templates/fullstack/cookiecutter.json new file mode 100644 index 0000000..ad8f1e8 --- /dev/null +++ b/.opencode/templates/fullstack/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "project_name": "my-fullstack", + "project_type": "fullstack", + "description": "Fullstack project description", + "use_auth": ["no", "yes"], + "use_db": ["yes", "no"], + "python_version": "3.13" +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/hooks/post_gen_project.py b/.opencode/templates/fullstack/hooks/post_gen_project.py new file mode 100644 index 0000000..b0a0d87 --- /dev/null +++ b/.opencode/templates/fullstack/hooks/post_gen_project.py @@ -0,0 +1,56 @@ +"""Post-generation hook for the fullstack cookiecutter template. + +Removes files conditional on ``use_auth`` / ``use_db`` from the backend +sub-tree (the frontend has no such flags). +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +PROJECT_DIR = Path.cwd() + + +def _remove(path: str) -> None: + """Remove a file or directory relative to the generated project root.""" + p = PROJECT_DIR / path + if p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + elif p.exists(): + p.unlink() + + +def main() -> None: + use_auth = "{{ cookiecutter.use_auth }}" + use_db = "{{ cookiecutter.use_db }}" + pkg = "{{ cookiecutter.project_name }}" + + if use_auth == "no": + _remove(f"backend/src/{pkg}/api/v1/routes/auth.py") + _remove(f"backend/src/{pkg}/services/auth_service.py") + _remove(f"backend/tests/test_auth.py") + + if use_db == "no": + # db is the root cause for the broken-conditional findings: files + # with unconditional ``from ...db.models.user import User`` must be + # stripped together with db/, otherwise the generated project + # fails to import (ImportError/NameError on startup). + _remove(f"backend/src/{pkg}/db") + _remove("backend/migrations") + _remove(f"backend/src/{pkg}/services/user_service.py") + _remove(f"backend/src/{pkg}/api/v1/routes/users.py") + _remove(f"backend/src/{pkg}/api/v1/dependencies.py") + _remove(f"backend/src/{pkg}/schemas/user.py") + _remove(f"backend/tests/unit/test_user.py") + _remove(f"backend/tests/unit/test_user_service.py") + _remove(f"backend/tests/api/test_users.py") + if use_auth == "yes": + # auth_service imports User; strip it and its wiring too. + _remove(f"backend/src/{pkg}/api/v1/routes/auth.py") + _remove(f"backend/src/{pkg}/services/auth_service.py") + _remove(f"backend/tests/test_auth.py") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml new file mode 100644 index 0000000..75685c9 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/backend" + schedule: + interval: weekly + - package-ecosystem: npm + directory: "/frontend" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml new file mode 100644 index 0000000..688653a --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + backend-lint: + runs-on: ubuntu-latest + defaults: { run: { working-directory: backend } } + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run ruff check . + - run: uv run ruff format --check . + + backend-typecheck: + runs-on: ubuntu-latest + defaults: { run: { working-directory: backend } } + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run mypy src tests + + backend-test: + runs-on: ubuntu-latest + defaults: { run: { working-directory: backend } } + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run pytest + + frontend-test: + runs-on: ubuntu-latest + defaults: { run: { working-directory: frontend } } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '20' } + - run: npm install + - run: npm run lint + - run: npm test + + build: + runs-on: ubuntu-latest + needs: [backend-lint, backend-typecheck, backend-test, frontend-test] + steps: + - uses: actions/checkout@v4 + - run: echo "All checks passed" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/LICENSE b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/LICENSE new file mode 100644 index 0000000..3a27a77 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {% now 'utc', '%Y' %} slaid098 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/README.md b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/README.md new file mode 100644 index 0000000..4d8cbd0 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/README.md @@ -0,0 +1,67 @@ +# πŸš€ {{ cookiecutter.project_name }} + +> Language switcher: **[English](#-english)** | **[Русский](#-русский)** + +![Cover](assets/cover.png) + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + +## πŸ‡ΊπŸ‡Έ English + + +- FastAPI backend (Tortoise ORM) + SvelteKit frontend (Svelte 5 runes) +- pydantic-settings configuration; JS frontend with Biome + Knip + Vitest + Playwright e2e +- Ruff + mypy strict + pytest (backend); SvelteKit + Vitest (frontend) +- Pre-commit hooks (backend); Biome + Knip (frontend) + + +### ⚑ Quick Start + +```bash +cd backend && uv sync --extra dev && uv run uvicorn main:app --reload +cd frontend && npm install && npm run dev +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** + +--- + +## πŸ‡·πŸ‡Ί Русский + + +{{ cookiecutter.description }} + + + +{{ cookiecutter.description }} + + + +- БэкСнд FastAPI (Tortoise ORM) + Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄ SvelteKit (Svelte 5 runes) +- ΠšΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡ Ρ‡Π΅Ρ€Π΅Π· pydantic-settings; JS-Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄ с Biome + Knip + Vitest + Playwright e2e +- Ruff + mypy strict + pytest (бэкСнд); SvelteKit + Vitest (Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄) +- Pre-commit Ρ…ΡƒΠΊΠΈ (бэкСнд); Biome + Knip (Ρ„Ρ€ΠΎΠ½Ρ‚Π΅Π½Π΄) + + +### ⚑ Быстрый старт + +```bash +cd backend && uv sync --extra dev && uv run uvicorn main:app --reload +cd frontend && npm install && npm run dev +``` + +--- + +## πŸ’¬ Support and contacts / ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° ΠΈ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ + +πŸ‘‰ **[slaid098.dev/support](https://slaid098.dev/support)** \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml new file mode 100644 index 0000000..68d50b7 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml new file mode 100644 index 0000000..1b30a39 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run ruff check . + - run: uv run ruff format --check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run mypy src tests + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv run pytest + + build: + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - run: uv sync --extra dev + - run: uv build \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.gitignore b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.gitignore new file mode 100644 index 0000000..f1f9a7a --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +.eggs/ +build/ +dist/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +*.sqlite3 +*.db +.env +.venv/ +venv/ \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.pre-commit-config.yaml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..12016d2 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: [pydantic-settings] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-added-large-files \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.python-version b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.python-version new file mode 100644 index 0000000..bff1460 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/.python-version @@ -0,0 +1 @@ +{{ cookiecutter.python_version }} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/LICENSE b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/LICENSE new file mode 100644 index 0000000..3a27a77 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) {% now 'utc', '%Y' %} slaid098 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/env.example b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/env.example new file mode 100644 index 0000000..f3fdbf8 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/env.example @@ -0,0 +1,18 @@ +# Application +APP__ENVIRONMENT=dev +SERVER__HOST=0.0.0.0 +SERVER__PORT=8000 + +# Database (nested via __ separator β€” pydantic-settings convention) +{% if cookiecutter.use_db == "yes" %} +DATABASE__URL=sqlite://db.sqlite3 +{% endif %} +{% if cookiecutter.use_auth == "yes" %} +# Auth +JWT__SECRET=change-me-in-production +JWT__ALGORITHM=HS256 +JWT__EXPIRE_MINUTES=60 +{% endif %} + +# IP whitelist (no hardcoded production IPs β€” override in production) +IP_WHITELIST=["127.0.0.1", "::1"] \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/main.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/main.py new file mode 100644 index 0000000..c3531e5 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/main.py @@ -0,0 +1,42 @@ +"""FastAPI application entry point for {{ cookiecutter.project_name }}.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from {{ cookiecutter.project_name }}.config.logger import setup_logging +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.db.connection import close_db, init_db +{% endif %} +from {{ cookiecutter.project_name }}.api.router import api_router + +_BASE_DIR = Path(__file__).resolve().parent + + +@asynccontextmanager +async def lifespan(app: FastAPI): + {% if cookiecutter.use_db == "yes" %}setup_logging() + await init_db() + try: + yield + finally: + await close_db(){% else %}setup_logging() + yield{% endif %} + + +app = FastAPI(lifespan=lifespan) + +static_dir = _BASE_DIR / "static" +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=static_dir), name="static") + +app.include_router(api_router, prefix="/api") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/migrations/README.md b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/migrations/README.md new file mode 100644 index 0000000..82137a6 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/migrations/README.md @@ -0,0 +1,8 @@ +# Tortoise migrations directory + +This directory holds migration files generated by the built-in Tortoise +migrator. Run: + + python -m tortoise.migrator makemigrations + +Generated files land here and are committed to git. \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml new file mode 100644 index 0000000..2c9544e --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml @@ -0,0 +1,139 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ cookiecutter.project_name }}" +version = "0.1.0" +description = "{{ cookiecutter.description }}" +readme = "README.md" +license = "MIT" +requires-python = ">={{ cookiecutter.python_version }}" +authors = [{ name = "slaid098" }] +keywords = [] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.13", +] + +dependencies = [ + "fastapi", + "uvicorn[standard]", + "pydantic-settings", + "loguru", +{% if cookiecutter.use_db == "yes" %} + "tortoise-orm", + "asyncpg", +{% endif %} +{% if cookiecutter.use_auth == "yes" %} + "passlib[bcrypt]", + "pyjwt", +{% endif %} +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-asyncio>=0.23", + "pytest-timeout>=2.2", + "httpx", + "mypy>=1.10", + "ruff>=0.5", + "pre-commit>=3.7", +] + +[project.urls] +Homepage = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" +Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +# ── Ruff ────────────────────────────────────────────────────────────────── + +[tool.ruff] +target-version = "py313" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", "W", + "F", + "I", + "B", + "UP", + "SIM", + "C90", + "PL", + "RUF", + "S", + "TRY", + "LOG", +] +ignore = [ + "S101", + "S311", + "RUF001", + "RUF002", + "RUF003", + "TRY003", + "PLR2004", + "S106", +] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.pylint] +max-args = 5 +max-branches = 12 +max-returns = 5 +max-statements = 50 + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "PLR2004", "S106", "S603", "S607", "PLR0913"] + +# ── mypy ────────────────────────────────────────────────────────────────── + +[tool.mypy] +python_version = "{{ cookiecutter.python_version }}" +strict = true +explicit_package_bases = true +warn_return_any = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +# ── pytest ──────────────────────────────────────────────────────────────── + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=src --cov-report=term-missing --timeout=120" + +# ── coverage ────────────────────────────────────────────────────────────── + +[tool.coverage.run] +source = ["src"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +# ── project-status ───────────────────────────────────────────────────────── + +[tool.project-status] +route_line_limit = 50 +min_test_count = 1 +require_branch_protection = false \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/__init__.py new file mode 100644 index 0000000..7172ae5 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/__init__.py @@ -0,0 +1 @@ +"""{{ cookiecutter.project_name }} package.""" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/__init__.py new file mode 100644 index 0000000..1f82bf8 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/__init__.py @@ -0,0 +1,3 @@ +"""API package for {{ cookiecutter.project_name }}.""" + +from {{ cookiecutter.project_name }}.api.router import api_router # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/router.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/router.py new file mode 100644 index 0000000..4a58c62 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/router.py @@ -0,0 +1,8 @@ +"""API router aggregation for {{ cookiecutter.project_name }}.""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.api.v1.router import v1_router + +api_router = APIRouter() +api_router.include_router(v1_router, prefix="/v1") \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/__init__.py new file mode 100644 index 0000000..a89c490 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/__init__.py @@ -0,0 +1,3 @@ +"""v1 API package.""" + +from {{ cookiecutter.project_name }}.api.v1.router import v1_router # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/dependencies.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/dependencies.py new file mode 100644 index 0000000..84b7a8d --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/dependencies.py @@ -0,0 +1,49 @@ +"""Shared dependencies for v1 routes. + +``check_ip_whitelist`` is always present; ``get_current_user`` is wired +only when ``use_auth == "yes``. +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, Request, status + +from {{ cookiecutter.project_name }}.config.settings import settings + +DEFAULT_WHITELIST = ["127.0.0.1", "::1"] + + +async def check_ip_whitelist(request: Request) -> None: + """Reject requests from non-whitelisted IPs. + + Defaults to ``["127.0.0.1", "::1"]`` (no hardcoded production IPs); + override via ``IP_WHITELIST`` in env. + """ + client = request.client.host if request.client else None + whitelist = settings.ip_whitelist or DEFAULT_WHITELIST + if client and client not in whitelist: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"IP {client} not allowed", + ) + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + +_bearer = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(_bearer), + auth: AuthService = Depends(AuthService), +) -> str: + """Resolve the current user from the bearer token (JWT). + + Returns the username/subject of the token. Only present when + ``use_auth == "yes"``. + """ + return await auth.get_current_user(credentials.credentials) +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/router.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/router.py new file mode 100644 index 0000000..71ce2d7 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/router.py @@ -0,0 +1,22 @@ +"""Versioned router (v1) for {{ cookiecutter.project_name }}.""" + +from fastapi import APIRouter +{% if cookiecutter.use_db == "yes" %} +from fastapi import Depends + +from {{ cookiecutter.project_name }}.api.v1.dependencies import check_ip_whitelist +from {{ cookiecutter.project_name }}.api.v1.routes import users +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import auth +{% endif %} + +{% if cookiecutter.use_db == "yes" %} +v1_router = APIRouter(dependencies=[Depends(check_ip_whitelist)]) +v1_router.include_router(users.router, prefix="/users", tags=["users"]) +{% else %} +v1_router = APIRouter() +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +v1_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py new file mode 100644 index 0000000..beaeccf --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/__init__.py @@ -0,0 +1,8 @@ +"""Routes package for v1.""" + +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import users # noqa: F401 +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.api.v1.routes import auth # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py new file mode 100644 index 0000000..d96d937 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/auth.py @@ -0,0 +1,22 @@ +"""Auth routes β€” /login, /register (only when use_auth=yes).""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.schemas.user import Token, UserCreate, UserLogin, UserResponse +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + +router = APIRouter() + + +@router.post("/register", response_model=UserResponse, status_code=201) +async def register(payload: UserCreate) -> UserResponse: + """Register a new user β€” returns the public profile.""" + user = await AuthService.register(payload.username, payload.email, payload.password) + return UserResponse(id=user.id, username=user.username, email=user.email) + + +@router.post("/login", response_model=Token) +async def login(payload: UserLogin) -> Token: + """Login with username + password β€” returns a JWT.""" + access_token = await AuthService.login(payload.username, payload.password) + return Token(access_token=access_token) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/users.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/users.py new file mode 100644 index 0000000..97600bb --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/api/v1/routes/users.py @@ -0,0 +1,22 @@ +"""User routes β€” thin handlers (≀50 lines), delegate to services.""" + +from fastapi import APIRouter + +from {{ cookiecutter.project_name }}.schemas.user import UserResponse +from {{ cookiecutter.project_name }}.services.user_service import UserService + +router = APIRouter() + + +@router.get("", response_model=list[UserResponse]) +async def list_users() -> list[UserResponse]: + """List users β€” thin handler, business logic lives in the service.""" + users = await UserService.get_users() + return [UserResponse.model_validate(u) for u in users] + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user(user_id: int) -> UserResponse: + """Get a single user by id β€” thin handler.""" + user = await UserService.get_user(user_id) + return UserResponse.model_validate(user) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/__init__.py new file mode 100644 index 0000000..48d9704 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/__init__.py @@ -0,0 +1,3 @@ +"""Configuration package β€” settings + logger.""" + +from {{ cookiecutter.project_name }}.config.settings import settings # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/logger.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/logger.py new file mode 100644 index 0000000..946777e --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/logger.py @@ -0,0 +1,17 @@ +"""Loguru logging setup.""" + +from __future__ import annotations + +import sys + +from loguru import logger + + +def setup_logging() -> None: + """Configure loguru sink β€” remove default handler, add stdout.""" + logger.remove() + logger.add( + sys.stdout, + level="INFO", + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", + ) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/settings.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/settings.py new file mode 100644 index 0000000..fbaa901 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/config/settings.py @@ -0,0 +1,59 @@ +"""Application settings via pydantic-settings. + +Nested fields use the ``__`` separator (pydantic-settings convention): +``DATABASE__URL`` -> ``settings.database.url``. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Environment(StrEnum): + DEV = "dev" + STAGING = "staging" + PROD = "prod" + + +class ServerSettings(BaseSettings): + host: str = "0.0.0.0" + port: int = 8000 + + +{% if cookiecutter.use_db == "yes" %} +class DatabaseSettings(BaseSettings): + url: str = "sqlite://db.sqlite3" +{% endif %} + + +{% if cookiecutter.use_auth == "yes" %} +class JWTSettings(BaseSettings): + secret: str = "change-me-in-production" + algorithm: str = "HS256" + expire_minutes: int = 60 +{% endif %} + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + env_nested_delimiter="__", + extra="ignore", + ) + + environment: Environment = Environment.DEV + server: ServerSettings = Field(default_factory=ServerSettings) +{% if cookiecutter.use_db == "yes" %} + database: DatabaseSettings = Field(default_factory=DatabaseSettings) +{% endif %} +{% if cookiecutter.use_auth == "yes" %} + jwt: JWTSettings = Field(default_factory=JWTSettings) +{% endif %} + ip_whitelist: list[str] = Field(default_factory=lambda: ["127.0.0.1", "::1"]) + + +settings = Settings() \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/__init__.py new file mode 100644 index 0000000..fa9e7e8 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/__init__.py @@ -0,0 +1,5 @@ +{% if cookiecutter.use_db == "yes" %}"""DB package β€” Tortoise ORM connection + models.""" + +from {{ cookiecutter.project_name }}.db.connection import close_db, init_db # noqa: F401 +{% else %}"""DB package (disabled β€” use_db=no).""" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py new file mode 100644 index 0000000..5a11329 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py @@ -0,0 +1,29 @@ +"""Tortoise ORM connection setup. + +Uses ``Path(__file__)``-relative paths (no CWD reliance) so the app is +portable regardless of the working directory it is launched from. +""" + +from __future__ import annotations + +from pathlib import Path + +from tortoise import Tortoise + +from {{ cookiecutter.project_name }}.config.settings import settings + +_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" + + +async def init_db() -> None: + """Initialize Tortoise with generate_schemas (built-in, NOT Aerich).""" + await Tortoise.init( + db_url=settings.database.url, + modules={"models": [_MODELS_PATH]}, + ) + await Tortoise.generate_schemas(safe=True) + + +async def close_db() -> None: + """Close all Tortoise connections.""" + await Tortoise.close_connections() \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/__init__.py new file mode 100644 index 0000000..6f091ec --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/__init__.py @@ -0,0 +1,3 @@ +"""Tortoise ORM models.""" + +from {{ cookiecutter.project_name }}.db.models.user import User # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/user.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/user.py new file mode 100644 index 0000000..4e095c1 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/models/user.py @@ -0,0 +1,25 @@ +"""User model. + +When ``use_auth == "yes"`` the ``hashed_password`` field is present; +otherwise the model holds only the public profile fields. +""" + +from __future__ import annotations + +from tortoise import fields +from tortoise.models import Model + + +class User(Model): + id = fields.IntField(pk=True) + username = fields.CharField(max_length=64, unique=True) + email = fields.CharField(max_length=128, unique=True) +{% if cookiecutter.use_auth == "yes" %} + hashed_password = fields.CharField(max_length=128) +{% endif %} + + class Meta: + table = "users" + + def __str__(self) -> str: + return self.username \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/__init__.py new file mode 100644 index 0000000..2394fba --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/__init__.py @@ -0,0 +1,6 @@ +"""Pydantic schemas.""" + +from {{ cookiecutter.project_name }}.schemas.base import MetaResponse, PaginatedResponse # noqa: F401 +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.schemas.user import UserResponse # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/base.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/base.py new file mode 100644 index 0000000..c166d15 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/base.py @@ -0,0 +1,24 @@ +"""Base schemas β€” shared response envelopes.""" + +from __future__ import annotations + +from typing import Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T") + + +class MetaResponse(BaseModel): + """Standard meta block for API responses.""" + + total: int + page: int = 1 + page_size: int = 20 + + +class PaginatedResponse(BaseModel, Generic[T]): + """Generic paginated envelope: ``{items, meta}``.""" + + items: list[T] + meta: MetaResponse \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/user.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/user.py new file mode 100644 index 0000000..cb6c2d8 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/schemas/user.py @@ -0,0 +1,49 @@ +"""User schemas. + +When ``use_auth == "yes"`` the auth-related schemas (``UserCreate``, +``UserLogin``, ``Token``) are added; ``UserResponse`` and ``UserInput`` +are always present. +""" + +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class UserInput(BaseModel): + """Input payload for creating a user.""" + + username: str = Field(min_length=3, max_length=64) + email: EmailStr + + +class UserResponse(BaseModel): + """Public user representation (never leaks the password).""" + + id: int + username: str + email: EmailStr + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +class UserCreate(BaseModel): + """Registration payload β€” username, email, plain password.""" + + username: str = Field(min_length=3, max_length=64) + email: EmailStr + password: str = Field(min_length=8, max_length=128) + + +class UserLogin(BaseModel): + """Login payload β€” username + plain password.""" + + username: str + password: str + + +class Token(BaseModel): + """JWT response envelope.""" + + access_token: str + token_type: str = "bearer" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/__init__.py new file mode 100644 index 0000000..438b949 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/__init__.py @@ -0,0 +1,8 @@ +"""Services package β€” business logic (Tortoise queries).""" + +{% if cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.services.user_service import UserService # noqa: F401 +{% endif %} +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +from {{ cookiecutter.project_name }}.services.auth_service import AuthService # noqa: F401 +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/auth_service.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/auth_service.py new file mode 100644 index 0000000..e56d77c --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/auth_service.py @@ -0,0 +1,68 @@ +"""Auth service β€” JWT issuance + verification (passlib[bcrypt] + pyjwt). + +Only rendered when ``use_auth == "yes"``. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from fastapi import HTTPException, status +from passlib.context import CryptContext + +from {{ cookiecutter.project_name }}.config.settings import settings +from {{ cookiecutter.project_name }}.db.models.user import User + +_pwd = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class AuthService: + """Stateless auth service β€” JWT + password hashing.""" + + @staticmethod + def hash_password(password: str) -> str: + return _pwd.hash(password) + + @staticmethod + def verify_password(plain: str, hashed: str) -> bool: + return _pwd.verify(plain, hashed) + + @staticmethod + def create_token(username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta( + minutes=settings.jwt.expire_minutes, + ) + payload: dict[str, Any] = {"sub": username, "exp": expire} + return jwt.encode(payload, settings.jwt.secret, algorithm=settings.jwt.algorithm) + + @staticmethod + async def register(username: str, email: str, password: str) -> User: + hashed = AuthService.hash_password(password) + return await User.create( + username=username, email=email, hashed_password=hashed, + ) + + @staticmethod + async def login(username: str, password: str) -> str: + user = await User.get_or_none(username=username) + if user is None or not AuthService.verify_password(password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + ) + return AuthService.create_token(username) + + @staticmethod + async def get_current_user(token: str) -> str: + try: + payload = jwt.decode( + token, settings.jwt.secret, algorithms=[settings.jwt.algorithm], + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) from exc + return str(payload.get("sub", "")) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/user_service.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/user_service.py new file mode 100644 index 0000000..1c004fa --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/services/user_service.py @@ -0,0 +1,37 @@ +"""User service β€” business logic (Tortoise queries). + +Fix from slaid098/templates: ``get_users`` returns a plain ``list`` of +User instances (the old implementation returned a tuple while every caller +expected a list). +""" + +from __future__ import annotations + +from fastapi import HTTPException, status + +from {{ cookiecutter.project_name }}.db.models.user import User + + +class UserService: + """Stateless user service β€” all methods are async classmethods.""" + + @staticmethod + async def get_users() -> list[User]: + """Return all users as a list (NOT a tuple β€” bug fixed).""" + return list(await User.all()) + + @staticmethod + async def get_user(user_id: int) -> User: + """Return a single user by id or raise 404.""" + user = await User.get_or_none(id=user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User {user_id} not found", + ) + return user + + @staticmethod + async def create_user(username: str, email: str) -> User: + """Create a new user.""" + return await User.create(username=username, email=email) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/__init__.py new file mode 100644 index 0000000..aad30da --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/__init__.py @@ -0,0 +1,7 @@ +"""Utils package. + +NOTE: ProjectMetadata lives ONLY in ``metadata.py`` β€” no duplication in +``__init__.py`` (fix from slaid098/templates). +""" + +from {{ cookiecutter.project_name }}.utils.metadata import ProjectMetadata # noqa: F401 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/metadata.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/metadata.py new file mode 100644 index 0000000..f3470cc --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/utils/metadata.py @@ -0,0 +1,35 @@ +"""Project metadata read from ``pyproject.toml`` (single source of truth). + +No duplication β€” callers import from here, never re-declare the metadata. +Uses ``Path(__file__)`` to locate ``pyproject.toml`` regardless of CWD. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass +from pathlib import Path + +_PYPROJECT = Path(__file__).resolve().parents[3] / "pyproject.toml" + + +@dataclass(frozen=True) +class ProjectMetadata: + name: str + version: str + description: str + + +def load_metadata() -> ProjectMetadata: + """Load metadata from ``pyproject.toml`` (single source of truth).""" + with _PYPROJECT.open("rb") as f: + data = tomllib.load(f) + project = data.get("project", {}) + return ProjectMetadata( + name=str(project.get("name", "")), + version=str(project.get("version", "")), + description=str(project.get("description", "")), + ) + + +metadata = load_metadata() \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/__init__.py new file mode 100644 index 0000000..d25a4be --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package.""" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/__init__.py new file mode 100644 index 0000000..cba2c6c --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/__init__.py @@ -0,0 +1 @@ +"""API tests package.""" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/test_users.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/test_users.py new file mode 100644 index 0000000..4e2950d --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/api/test_users.py @@ -0,0 +1,20 @@ +"""API tests for user routes (thin handlers β†’ services).""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_list_users(client) -> None: + """GET /api/v1/users returns a list of users.""" + response = await client.get("/api/v1/users") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_ip_whitelist_blocks(client) -> None: + """Non-whitelisted IPs must get 403.""" + response = await client.get("/api/v1/users", headers={"X-Forwarded-For": "10.0.0.1"}) + assert response.status_code == 403 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/conftest.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/conftest.py new file mode 100644 index 0000000..d240427 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/conftest.py @@ -0,0 +1,57 @@ +"""Pytest configuration β€” fixtures shared across all tests.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import pytest +from httpx import ASGITransport, AsyncClient + +from {{ cookiecutter.project_name }}.config.settings import settings + + +@pytest.fixture(autouse=True) +def mock_settings(monkeypatch) -> None: + """Override settings with safe test defaults (no real DB/SMTP/etc).""" + monkeypatch.setattr(settings, "environment", "dev") + monkeypatch.setattr(settings, "ip_whitelist", ["127.0.0.1", "::1"]) + + +@pytest.fixture +async def client() -> AsyncIterator[AsyncClient]: + """HTTPX AsyncClient bound to the FastAPI app (no network).""" + from main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +{% if cookiecutter.use_auth == "yes" and cookiecutter.use_db == "yes" %} +@pytest.fixture +async def create_user() -> Any: + """Factory: create a user in the test DB.""" + from {{ cookiecutter.project_name }}.db.models.user import User + from {{ cookiecutter.project_name }}.services.auth_service import AuthService + + async def _create(username: str = "tester", password: str = "password123") -> User: + hashed = AuthService.hash_password(password) + return await User.create(username=username, email=f"{username}@test.local", hashed_password=hashed) + + return _create + + +@pytest.fixture +async def auth_client(create_user) -> AsyncIterator[AsyncClient]: + """HTTPX client with a valid bearer token pre-set.""" + from main import app + + await create_user() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + resp = await ac.post("/api/v1/auth/login", json={"username": "tester", "password": "password123"}) + token = resp.json()["access_token"] + ac.headers["Authorization"] = f"Bearer {token}" + yield ac +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/__init__.py new file mode 100644 index 0000000..d431cdf --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests package.""" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/test_real_external.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/test_real_external.py new file mode 100644 index 0000000..11761d1 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/integration/test_real_external.py @@ -0,0 +1,24 @@ +"""Integration tests β€” skip by default unless real credentials are set. + +``pytestmark`` = [pytest.mark.integration, skipif no creds] β€” these only +run when an explicit env var is set (e.g. ``RUN_INTEGRATION=1``). +""" + +from __future__ import annotations + +import os + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.getenv("RUN_INTEGRATION"), + reason="no real external credentials β€” set RUN_INTEGRATION=1 to enable", + ), +] + + +async def test_real_external_placeholder() -> None: + """Placeholder β€” replace with a real external service call.""" + assert True \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/test_auth.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/test_auth.py new file mode 100644 index 0000000..e04441f --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/test_auth.py @@ -0,0 +1,26 @@ +"""Auth flow tests β€” /register, /login (only when use_auth=yes).""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_register(client) -> None: + """POST /api/v1/auth/register creates a user.""" + response = await client.post( + "/api/v1/auth/register", + json={"username": "newuser", "email": "new@test.local", "password": "password123"}, + ) + assert response.status_code == 201 + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_login_returns_token(client) -> None: + """POST /api/v1/auth/login returns a JWT.""" + response = await client.post( + "/api/v1/auth/login", + json={"username": "tester", "password": "password123"}, + ) + assert response.status_code == 200 + assert "access_token" in response.json() \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/__init__.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/__init__.py new file mode 100644 index 0000000..0b04822 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests package.""" \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user.py new file mode 100644 index 0000000..07207a4 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user.py @@ -0,0 +1,31 @@ +"""Unit tests for the User model.""" + +from __future__ import annotations + +import pytest + +{% if cookiecutter.use_auth == "yes" %} +from {{ cookiecutter.project_name }}.services.auth_service import AuthService + + +def test_hash_password_is_not_plain() -> None: + """hash_password must not store the plain text.""" + hashed = AuthService.hash_password("mypassword") + assert hashed != "mypassword" + assert hashed.startswith("$2") # bcrypt prefix + + +def test_verify_password_roundtrip() -> None: + """verify_password must accept the correct password.""" + hashed = AuthService.hash_password("mypassword") + assert AuthService.verify_password("mypassword", hashed) is True + assert AuthService.verify_password("wrong", hashed) is False +{% else %} + + +def test_user_model_table_name() -> None: + """User model must use the 'users' table.""" + from {{ cookiecutter.project_name }}.db.models.user import User + + assert User._meta.db_table == "users" +{% endif %} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user_service.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user_service.py new file mode 100644 index 0000000..aa7c8c3 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/tests/unit/test_user_service.py @@ -0,0 +1,14 @@ +"""Unit tests for UserService (business logic, not HTTP).""" + +from __future__ import annotations + +import pytest + +from {{ cookiecutter.project_name }}.services.user_service import UserService + + +@pytest.mark.skip(reason="requires DB fixture; wire up after issue #2 (project-status compat)") +async def test_get_users_returns_list() -> None: + """get_users must return a list (NOT a tuple β€” bug fixed).""" + users = await UserService.get_users() + assert isinstance(users, list) \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.env.example b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.env.example new file mode 100644 index 0000000..0c50bab --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.env.example @@ -0,0 +1,2 @@ +# Backend API URL (SvelteKit server-side proxy in dev) +PUBLIC_API_URL=http://localhost:8000 \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.gitignore b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.gitignore new file mode 100644 index 0000000..88604aa --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +.svelte-kit/ +.env +.DS_Store +*.log +playwright-report/ +test-results/ \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/biome.json b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/biome.json new file mode 100644 index 0000000..b226c35 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + }, + "files": { + "ignore": ["build", ".svelte-kit", "playwright-report"] + } +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/jsconfig.json b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/jsconfig.json new file mode 100644 index 0000000..50a0836 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/jsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "checkJs": true, + "allowJs": true, + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "resolveJsonModule": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "verbatimModuleSyntax": false, + "baseUrl": ".", + "paths": { + "$lib": ["src/lib"], + "$lib/*": ["src/lib/*"], + "$components": ["src/lib/components"], + "$components/*": ["src/lib/components/*"] + } + }, + "include": ["src/**/*.js", "src/**/*.svelte", "vite.config.js", "vitest.config.js"] +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/knip.json b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/knip.json new file mode 100644 index 0000000..2a45a62 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/knip.json @@ -0,0 +1,7 @@ +{ + "entry": ["src/**/*.{js,svelte}", "!src/**/*.test.js"], + "project": ["src/**/*.{js,svelte}"], + "ignore": ["build", ".svelte-kit"], + "ignoreBinaries": true, + "ignoreDependencies": false +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json new file mode 100644 index 0000000..e5c9c75 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "{{ cookiecutter.project_name }}-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", + "lint": "biome check .", + "format": "biome format --write .", + "knip": "knip" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@biomejs/biome": "^1.9.0", + "knip": "^5.30.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "vite": "^5.0.0", + "vitest": "^2.0.0", + "@playwright/test": "^1.48.0" + } +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js new file mode 100644 index 0000000..d2d3bfb --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/playwright.config.js @@ -0,0 +1,21 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: 'html', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'npm run preview', + url: 'http://localhost:4173', + reuseExistingServer: !process.env.CI, + }, +}); \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.css b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.css new file mode 100644 index 0000000..896926e --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.css @@ -0,0 +1,23 @@ +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --accent: #646cff; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --fg: #ffffff; + } +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: system-ui, sans-serif; +} + +a { + color: var(--accent); +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html new file mode 100644 index 0000000..277886b --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/hooks.server.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/hooks.server.js new file mode 100644 index 0000000..47ed1f5 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/hooks.server.js @@ -0,0 +1,14 @@ +import { env } from '$env/dynamic/public'; + +/** @type {import('@sveltejs/kit').Handle} */ +export async function handle({ event, resolve }) { + // Attach the backend API URL to locals for server-side fetches. + event.locals.api_url = env.PUBLIC_API_URL || 'http://localhost:8000'; + return resolve(event); +} + +/** @type {import('@sveltejs/kit').HandleServerError} */ +export function handleError({ error }) { + console.error('Server error:', error); + return { message: 'Internal error' }; +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/api/client.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/api/client.js new file mode 100644 index 0000000..4489d76 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/api/client.js @@ -0,0 +1,32 @@ +/** + * Thin API client β€” wraps fetch with the backend base URL. + * Used from server-side load functions (hooks.server.js sets locals.api_url). + */ + +/** + * @param {string} baseUrl + * @returns {ApiClient} + */ + +/** + * @typedef {Object} ApiClient + * @property {(path: string) => Promise} get + * @property {(path: string, body: any) => Promise} post + */ + +export function apiClient(baseUrl) { + async function request(path, options) { + const resp = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { 'Content-Type': 'application/json', ...options?.headers }, + }); + if (!resp.ok) { + throw new Error(`API ${resp.status}: ${await resp.text()}`); + } + return resp.json(); + } + return { + get: (path) => request(path, { method: 'GET' }), + post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }), + }; +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/components/Header.svelte b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/components/Header.svelte new file mode 100644 index 0000000..a15f41d --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/components/Header.svelte @@ -0,0 +1,23 @@ + + +
+

{title}

+ {@render children?.()} +
+ + \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.svelte.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.svelte.js new file mode 100644 index 0000000..21a30f4 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.svelte.js @@ -0,0 +1,22 @@ +/** + * Counter store β€” Svelte 5 runes ($state) in a *.svelte.js module. + * Import the store and use `.count` and `.increment()`. + */ + +export function createCounter(initial = 0) { + let count = $state(initial); + return { + get count() { + return count; + }, + increment() { + count += 1; + }, + decrement() { + count -= 1; + }, + reset() { + count = initial; + }, + }; +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.test.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.test.js new file mode 100644 index 0000000..1b031e6 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/stores/counter.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { createCounter } from './counter.svelte.js'; + +describe('createCounter', () => { + it('starts at the initial value', () => { + const c = createCounter(5); + expect(c.count).toBe(5); + }); + + it('increments', () => { + const c = createCounter(0); + c.increment(); + expect(c.count).toBe(1); + }); + + it('decrements', () => { + const c = createCounter(3); + c.decrement(); + expect(c.count).toBe(2); + }); + + it('resets to initial', () => { + const c = createCounter(7); + c.increment(); + c.increment(); + c.reset(); + expect(c.count).toBe(7); + }); +}); \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.js new file mode 100644 index 0000000..b0635aa --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.js @@ -0,0 +1,11 @@ +/** + * Misc utility helpers. + */ + +export function formatDate(date) { + return new Date(date).toLocaleDateString(); +} + +export function truncate(str, n) { + return str.length > n ? `${str.slice(0, n)}…` : str; +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.test.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.test.js new file mode 100644 index 0000000..de25021 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/lib/utils/format.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { formatDate, truncate } from './format.js'; + +describe('formatDate', () => { + it('formats a date string', () => { + const out = formatDate('2024-01-15'); + expect(typeof out).toBe('string'); + expect(out.length).toBeGreaterThan(0); + }); +}); + +describe('truncate', () => { + it('returns the string when shorter than n', () => { + expect(truncate('abc', 10)).toBe('abc'); + }); + + it('truncates with ellipsis when longer', () => { + expect(truncate('abcdef', 3)).toBe('abc…'); + }); +}); \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+error.svelte b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+error.svelte new file mode 100644 index 0000000..c142e42 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+error.svelte @@ -0,0 +1,10 @@ + + + + {page.status} β€” {{ cookiecutter.project_name }} + + +

{page.status}

+

{page.error?.message ?? 'Unexpected error'}

\ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.js b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.js new file mode 100644 index 0000000..3d58f98 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.js @@ -0,0 +1,4 @@ +/** @type {import('./$types').PageLoad} */ +export function load() { + return { title: '{{ cookiecutter.project_name }}' }; +} \ No newline at end of file diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.svelte b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..1e34cf2 --- /dev/null +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/frontend/src/routes/+page.svelte @@ -0,0 +1,31 @@ + + + + {data.title} + + +
+

{{ cookiecutter.description }}

+
+ +
+

Counter: {counter.count}

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