Skip to content

Commit cf63fb6

Browse files
committed
[TEST] Add e2e coverage for architecture presets&domain starter
1 parent 83f0e75 commit cf63fb6

2 files changed

Lines changed: 343 additions & 0 deletions

File tree

tests/test_cli_operations/test_cli_interactive_integration.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,56 @@ def _mock_subprocess(*args: Any, **kwargs: Any) -> MagicMock:
514514
f"output:\n{result.output}"
515515
)
516516

517+
# The confirmation summary must surface the architecture preset
518+
# row regardless of which preset the user picked. Issue #46
519+
# acceptance: "Tests fail if preset-specific output regresses in
520+
# key files OR DIRECTORIES" — the preset row in the summary table
521+
# is the single user-facing artefact that ties the choice to the
522+
# generated layout.
523+
#
524+
# Be careful with the slicing. The bare label "Architecture
525+
# Preset" appears earlier in the captured output (the prompt
526+
# header), and the chosen preset id + em-dash also appear LATER
527+
# in the captured output (e.g. inside the "Preset compatibility"
528+
# warning text for preserve-main presets). To isolate just the
529+
# summary table content, bound the section at the table title on
530+
# one side and at the "Total dependencies to install" line that
531+
# ``confirm_selections`` always prints right after the table on
532+
# the other.
533+
title_marker = "Project Configuration Summary"
534+
end_marker = "Total dependencies to install"
535+
assert title_marker in result.output, (
536+
f"[{suffix}] confirmation summary table missing entirely.\n"
537+
f"output:\n{result.output}"
538+
)
539+
assert end_marker in result.output, (
540+
f"[{suffix}] 'Total dependencies' marker missing — "
541+
f"summary table never closed.\n"
542+
f"output:\n{result.output}"
543+
)
544+
summary_section = result.output.split(title_marker, 1)[1].split(end_marker, 1)[
545+
0
546+
]
547+
548+
# All three summary-specific signals must appear inside the
549+
# bounded section: the row label, the chosen preset id, and the
550+
# em-dash separator from the cell's "<id> — <description>"
551+
# format. Neither the prompt header nor any later CLI output
552+
# provides all three at once.
553+
assert "Architecture Preset" in summary_section, (
554+
f"[{suffix}] summary section missing 'Architecture Preset' row.\n"
555+
f"summary section:\n{summary_section}"
556+
)
557+
assert suffix in summary_section, (
558+
f"[{suffix}] chosen preset id missing from summary section.\n"
559+
f"summary section:\n{summary_section}"
560+
)
561+
assert "—" in summary_section, (
562+
f"[{suffix}] em-dash separator missing from summary section "
563+
f"(expected '<id> — <description>' format).\n"
564+
f"summary section:\n{summary_section}"
565+
)
566+
517567
# The base template's signature file ends up in the project, which
518568
# is how we tell that the strategist picked the right template.
519569
main_py = project_path / main_relpath
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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

Comments
 (0)