} 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}
+
+
+
+
+
+ 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"