|
| 1 | +# -------------------------------------------------------------------------- |
| 2 | +# End-to-end coverage for the fastapi-domain-starter template + the |
| 3 | +# pyproject-first contract introduced in v1.3.0. |
| 4 | +# |
| 5 | +# These tests are regression guards for issue #46: they fail fast when |
| 6 | +# preset-specific generated output drifts (broken layout, missing |
| 7 | +# identity markers, or a generated app that fails to import / serve). |
| 8 | +# |
| 9 | +# Scope on purpose stays narrow: |
| 10 | +# - run the inspector contract checks against the real shipped template |
| 11 | +# directory, no synthetic fixture; |
| 12 | +# - exercise the full ``fastkit startdemo fastapi-domain-starter`` flow |
| 13 | +# once and pin down what the generated artefact must contain; |
| 14 | +# - import the generated app inside its own ``.venv`` and verify |
| 15 | +# ``GET /api/v1/health`` actually responds 200. |
| 16 | +# |
| 17 | +# @author bnbong bbbong9@gmail.com |
| 18 | +# -------------------------------------------------------------------------- |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import os |
| 22 | +import subprocess |
| 23 | +import sys |
| 24 | +import tomllib |
| 25 | +from pathlib import Path |
| 26 | +from typing import Iterator |
| 27 | + |
| 28 | +import pytest |
| 29 | +from click.testing import CliRunner |
| 30 | + |
| 31 | +from fastapi_fastkit.backend.inspector import TemplateInspector |
| 32 | +from fastapi_fastkit.cli import fastkit_cli |
| 33 | +from fastapi_fastkit.core.settings import FastkitConfig |
| 34 | +from fastapi_fastkit.utils.main import is_fastkit_project |
| 35 | + |
| 36 | +_TEMPLATE_NAME = "fastapi-domain-starter" |
| 37 | + |
| 38 | + |
| 39 | +def _domain_starter_template_path() -> Path: |
| 40 | + """Return the absolute path to the shipped fastapi-domain-starter template.""" |
| 41 | + settings = FastkitConfig() |
| 42 | + template_root = Path(settings.FASTKIT_TEMPLATE_ROOT) |
| 43 | + return template_root / _TEMPLATE_NAME |
| 44 | + |
| 45 | + |
| 46 | +@pytest.fixture() |
| 47 | +def runner() -> CliRunner: |
| 48 | + return CliRunner() |
| 49 | + |
| 50 | + |
| 51 | +@pytest.fixture() |
| 52 | +def isolated_workspace(tmp_path: Path) -> Iterator[Path]: |
| 53 | + """Provide an isolated workspace + cwd for the duration of a test.""" |
| 54 | + original_cwd = os.getcwd() |
| 55 | + os.chdir(tmp_path) |
| 56 | + try: |
| 57 | + yield tmp_path |
| 58 | + finally: |
| 59 | + os.chdir(original_cwd) |
| 60 | + |
| 61 | + |
| 62 | +def _generate_domain_starter_project( |
| 63 | + runner: CliRunner, |
| 64 | + workspace: Path, |
| 65 | + project_name: str, |
| 66 | + description: str = "Domain starter E2E test", |
| 67 | +) -> Path: |
| 68 | + """Run ``fastkit startdemo fastapi-domain-starter`` and return the project dir.""" |
| 69 | + result = runner.invoke( |
| 70 | + fastkit_cli, |
| 71 | + ["startdemo", _TEMPLATE_NAME], |
| 72 | + input="\n".join( |
| 73 | + [ |
| 74 | + project_name, |
| 75 | + "E2E Tester", |
| 76 | + "e2e@example.com", |
| 77 | + description, |
| 78 | + "uv", # package manager |
| 79 | + "Y", # proceed with creation |
| 80 | + "Y", # create new project folder |
| 81 | + ] |
| 82 | + ), |
| 83 | + ) |
| 84 | + assert result.exit_code == 0, f"startdemo exited non-zero. output:\n{result.output}" |
| 85 | + project_path = workspace / project_name |
| 86 | + assert ( |
| 87 | + project_path.exists() and project_path.is_dir() |
| 88 | + ), f"Expected project directory not created. output:\n{result.output}" |
| 89 | + return project_path |
| 90 | + |
| 91 | + |
| 92 | +# -------------------------------------------------------------------------- |
| 93 | +# 1. Contract-level: real shipping template still satisfies the |
| 94 | +# pyproject-first inspector contract. |
| 95 | +# -------------------------------------------------------------------------- |
| 96 | + |
| 97 | + |
| 98 | +class TestRealTemplatePyprojectFirstContract: |
| 99 | + """Inspector contract checks against the real on-disk template. |
| 100 | +
|
| 101 | + The synthetic fixtures in ``test_inspector.py`` exercise the contract |
| 102 | + code paths; this class is the production-side regression guard that |
| 103 | + pins ``fastapi-domain-starter``'s shipped layout to those same checks. |
| 104 | + """ |
| 105 | + |
| 106 | + @pytest.fixture() |
| 107 | + def inspector(self, tmp_path: Path) -> TemplateInspector: |
| 108 | + # Skip the context manager (which copies the template to a temp |
| 109 | + # dir and installs deps) — the four contract checks only need the |
| 110 | + # static path to read files from. |
| 111 | + return TemplateInspector( |
| 112 | + str(_domain_starter_template_path()), |
| 113 | + temp_base_dir=str(tmp_path), |
| 114 | + ) |
| 115 | + |
| 116 | + def test_file_structure_passes(self, inspector: TemplateInspector) -> None: |
| 117 | + assert inspector._check_file_structure() is True |
| 118 | + assert inspector.errors == [] |
| 119 | + |
| 120 | + def test_file_extensions_pass(self, inspector: TemplateInspector) -> None: |
| 121 | + assert inspector._check_file_extensions() is True |
| 122 | + assert inspector.errors == [] |
| 123 | + |
| 124 | + def test_dependencies_pass(self, inspector: TemplateInspector) -> None: |
| 125 | + assert inspector._check_dependencies() is True |
| 126 | + assert inspector.errors == [] |
| 127 | + |
| 128 | + def test_template_ships_no_setup_py(self) -> None: |
| 129 | + """``fastapi-domain-starter`` is the canonical pyproject-only template. |
| 130 | +
|
| 131 | + If a future change adds a ``setup.py-tpl`` here, the pyproject-first |
| 132 | + coverage story regresses — surface that loudly. |
| 133 | + """ |
| 134 | + template_path = _domain_starter_template_path() |
| 135 | + assert (template_path / "pyproject.toml-tpl").exists() |
| 136 | + assert not (template_path / "setup.py-tpl").exists(), ( |
| 137 | + "fastapi-domain-starter must remain pyproject-only; remove the " |
| 138 | + "setup.py-tpl shim or update this regression guard." |
| 139 | + ) |
| 140 | + |
| 141 | + def test_template_pyproject_carries_identity_markers(self) -> None: |
| 142 | + """The shipped pyproject-tpl must declare both identity markers. |
| 143 | +
|
| 144 | + Generated projects inherit them via metadata injection, but they |
| 145 | + have to start in the template — otherwise ``is_fastkit_project()`` |
| 146 | + cannot tell a generated project apart from an unrelated FastAPI |
| 147 | + project before the user runs anything. |
| 148 | + """ |
| 149 | + pyproject_tpl = _domain_starter_template_path() / "pyproject.toml-tpl" |
| 150 | + text = pyproject_tpl.read_text() |
| 151 | + assert "[FastAPI-fastkit templated]" in text |
| 152 | + assert "[tool.fastapi-fastkit]" in text |
| 153 | + assert "managed = true" in text |
| 154 | + |
| 155 | + |
| 156 | +# -------------------------------------------------------------------------- |
| 157 | +# 2. End-to-end ``startdemo`` flow: pyproject markers survive injection. |
| 158 | +# -------------------------------------------------------------------------- |
| 159 | + |
| 160 | + |
| 161 | +class TestStartdemoGeneratedPyproject: |
| 162 | + """``fastkit startdemo fastapi-domain-starter`` must produce a project |
| 163 | + whose pyproject.toml carries the canonical FastAPI-fastkit identity |
| 164 | + markers (post placeholder substitution + post tool-section injection). |
| 165 | + """ |
| 166 | + |
| 167 | + def test_generated_pyproject_is_marked_as_fastkit_managed( |
| 168 | + self, runner: CliRunner, isolated_workspace: Path |
| 169 | + ) -> None: |
| 170 | + project_name = "marker-check" |
| 171 | + |
| 172 | + project_path = _generate_domain_starter_project( |
| 173 | + runner, isolated_workspace, project_name |
| 174 | + ) |
| 175 | + |
| 176 | + pyproject = project_path / "pyproject.toml" |
| 177 | + assert pyproject.exists(), "generated project missing pyproject.toml" |
| 178 | + |
| 179 | + data = tomllib.loads(pyproject.read_text()) |
| 180 | + |
| 181 | + # Description marker survives placeholder substitution. |
| 182 | + description = data["project"]["description"] |
| 183 | + assert ( |
| 184 | + "[FastAPI-fastkit templated]" in description |
| 185 | + ), f"description missing identity marker; got: {description!r}" |
| 186 | + |
| 187 | + # Tool section carries the machine-readable marker. |
| 188 | + tool_section = data.get("tool", {}).get("fastapi-fastkit", {}) |
| 189 | + assert ( |
| 190 | + tool_section.get("managed") is True |
| 191 | + ), f"[tool.fastapi-fastkit].managed must be True; got: {tool_section!r}" |
| 192 | + |
| 193 | + # The detection helper must agree. |
| 194 | + assert is_fastkit_project(str(project_path)) is True |
| 195 | + |
| 196 | + |
| 197 | +# -------------------------------------------------------------------------- |
| 198 | +# 3. End-to-end ``startdemo`` flow: generated app imports + ``/health`` 200. |
| 199 | +# -------------------------------------------------------------------------- |
| 200 | + |
| 201 | + |
| 202 | +class TestStartdemoGeneratedAppRuns: |
| 203 | + """Full E2E: generate a project, then use its own venv to verify the |
| 204 | + FastAPI app actually imports cleanly and serves the health endpoint. |
| 205 | +
|
| 206 | + This is the only test in the suite that exercises the generated |
| 207 | + ``src/app/main.py`` against a live ``TestClient`` — everything else |
| 208 | + only checks for file existence / content. Without this, regressions |
| 209 | + that produce syntactically-valid but functionally-broken main.py |
| 210 | + files (e.g. wrong import paths after a refactor) would slip through. |
| 211 | + """ |
| 212 | + |
| 213 | + @pytest.mark.skipif( |
| 214 | + sys.platform == "win32", |
| 215 | + reason="venv binary path differs on Windows; the rest of the " |
| 216 | + "domain-starter flow is already covered there by other tests.", |
| 217 | + ) |
| 218 | + def test_generated_app_serves_health_endpoint( |
| 219 | + self, runner: CliRunner, isolated_workspace: Path |
| 220 | + ) -> None: |
| 221 | + project_name = "health-check-e2e" |
| 222 | + project_path = _generate_domain_starter_project( |
| 223 | + runner, isolated_workspace, project_name |
| 224 | + ) |
| 225 | + |
| 226 | + # The startdemo flow uses uv to provision a venv with the |
| 227 | + # template's deps installed. That's what we want to drive the |
| 228 | + # TestClient with — fastapi/httpx are not installed in the dev |
| 229 | + # environment that runs this test suite. |
| 230 | + venv_python = project_path / ".venv" / "bin" / "python" |
| 231 | + assert venv_python.exists(), ( |
| 232 | + f"Generated venv python missing at {venv_python}. " |
| 233 | + f"startdemo did not provision a uv venv." |
| 234 | + ) |
| 235 | + |
| 236 | + # Tiny driver script: import the app, hit /api/v1/health, write |
| 237 | + # status code + body to stdout. Keep this to one process so we |
| 238 | + # don't have to bring up an HTTP server. |
| 239 | + driver = ( |
| 240 | + "import sys\n" |
| 241 | + "from fastapi.testclient import TestClient\n" |
| 242 | + "from src.app.main import app\n" |
| 243 | + "from src.app.core.config import settings\n" |
| 244 | + "client = TestClient(app)\n" |
| 245 | + "r = client.get(f'{settings.API_V1_PREFIX}/health')\n" |
| 246 | + "sys.stdout.write(f'{r.status_code}|{r.text}')\n" |
| 247 | + ) |
| 248 | + |
| 249 | + completed = subprocess.run( |
| 250 | + [str(venv_python), "-c", driver], |
| 251 | + cwd=project_path, |
| 252 | + capture_output=True, |
| 253 | + text=True, |
| 254 | + timeout=60, |
| 255 | + ) |
| 256 | + |
| 257 | + assert completed.returncode == 0, ( |
| 258 | + f"Driver script failed.\nstdout:\n{completed.stdout}\n" |
| 259 | + f"stderr:\n{completed.stderr}" |
| 260 | + ) |
| 261 | + |
| 262 | + status_str, _, body = completed.stdout.partition("|") |
| 263 | + assert ( |
| 264 | + status_str == "200" |
| 265 | + ), f"/api/v1/health returned {status_str!r}; body: {body!r}" |
| 266 | + assert "ok" in body, f"/api/v1/health body must mention 'ok'; got: {body!r}" |
| 267 | + |
| 268 | + |
| 269 | +# -------------------------------------------------------------------------- |
| 270 | +# 4. Smoke check: list-templates surfaces the new template. |
| 271 | +# -------------------------------------------------------------------------- |
| 272 | + |
| 273 | + |
| 274 | +class TestDiscoverability: |
| 275 | + """``fastkit list-templates`` must show the domain-starter template |
| 276 | + with its descriptive title (not the raw <project_name> placeholder). |
| 277 | + """ |
| 278 | + |
| 279 | + def test_list_templates_shows_domain_starter( |
| 280 | + self, runner: CliRunner, isolated_workspace: Path |
| 281 | + ) -> None: |
| 282 | + result = runner.invoke(fastkit_cli, ["list-templates"]) |
| 283 | + |
| 284 | + assert result.exit_code == 0 |
| 285 | + # Both the id and the descriptive heading must appear. |
| 286 | + assert ( |
| 287 | + _TEMPLATE_NAME in result.output |
| 288 | + ), f"list-templates output missing '{_TEMPLATE_NAME}':\n{result.output}" |
| 289 | + assert "FastAPI Domain Starter" in result.output, ( |
| 290 | + "list-templates must show the template's descriptive heading " |
| 291 | + "from README.md-tpl, not the <project_name> placeholder.\n" |
| 292 | + f"output:\n{result.output}" |
| 293 | + ) |
0 commit comments