Terminal / tests /test_scaffold_project.py
Baida-A's picture
sync: 125 file da Baida98/AI@25b6bd78 (2026-07-10 21:17 UTC)
33f93a9 verified
Raw
History Blame Contribute Delete
25 kB
"""
test_scaffold_project.py — Integration tests per POST /api/scaffold_project
Copertura:
A) _scaffold_project() — funzione core in tools/registry.py
· Ogni framework (react|nextjs|fastapi|flask|django|express) ritorna i file attesi
· project_name viene interpolato nel contenuto dei file
· project_dir è il VFS path corretto (/<slug>)
· name sanitization: spazi/maiuscole → slug a-z0-9-
· 'created' list corrisponde alle chiavi di 'files'
· success=True per ogni framework
B) scaffold_project_endpoint() — handler FastAPI in api/files.py
· Framework non supportato → HTTPException 400
· Tutti e 6 i framework sono accettati
· project_name viene sanitizzato prima della chiamata a _scaffold_project
· Eccezione interna → {success:False, error:..., files:{}}
· Risposta include project_dir con VFS prefix corretto
Dipendenze: solo stdlib + moduli backend già presenti.
Non richiede server avviato — zero HTTP, zero DB, zero LLM.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
# ── sys.path — backend come root ───────────────────────────────────────────────
_BACKEND = os.path.join(os.path.dirname(__file__), "..")
if _BACKEND not in sys.path:
sys.path.insert(0, _BACKEND)
def _run(coro):
"""Esegui coroutine in modo compatibile con Python 3.10+."""
return asyncio.get_event_loop().run_until_complete(coro)
# ═══════════════════════════════════════════════════════════════════════════════
# A) Test su _scaffold_project() — funzione core
# ═══════════════════════════════════════════════════════════════════════════════
class TestScaffoldProjectFunction(unittest.TestCase):
"""
Testa _scaffold_project() direttamente — nessun HTTP, nessun DB.
Verifica che ogni framework produca i file boilerplate attesi,
che il project_name venga interpolato e che il VFS path sia corretto.
"""
@classmethod
def setUpClass(cls):
try:
from tools.registry import _scaffold_project
cls._scaffold = staticmethod(_scaffold_project)
except ImportError as e:
raise unittest.SkipTest(f"tools.registry non importabile: {e}")
# ── File attesi per framework ──────────────────────────────────────────────
def test_react_files_present(self):
"""React: package.json, index.html, src/main.tsx, src/App.tsx, src/index.css, vite.config.ts"""
result = _run(self._scaffold("react", project_name="test-app"))
files = result.get("files", {})
expected = ["package.json", "index.html", "src/main.tsx", "src/App.tsx",
"src/index.css", "vite.config.ts"]
for f in expected:
self.assertIn(f, files, f"react: file mancante '{f}'")
def test_nextjs_files_present(self):
"""Next.js: package.json, app/layout.tsx, app/page.tsx, next.config.mjs"""
result = _run(self._scaffold("nextjs", project_name="next-app"))
files = result.get("files", {})
expected = ["package.json", "app/layout.tsx", "app/page.tsx", "next.config.mjs"]
for f in expected:
self.assertIn(f, files, f"nextjs: file mancante '{f}'")
def test_fastapi_files_present(self):
"""FastAPI: main.py, requirements.txt, Dockerfile, .gitignore"""
result = _run(self._scaffold("fastapi", project_name="api-app"))
files = result.get("files", {})
expected = ["main.py", "requirements.txt", "Dockerfile", ".gitignore"]
for f in expected:
self.assertIn(f, files, f"fastapi: file mancante '{f}'")
def test_flask_files_present(self):
"""Flask: app.py, requirements.txt, .gitignore"""
result = _run(self._scaffold("flask", project_name="flask-app"))
files = result.get("files", {})
expected = ["app.py", "requirements.txt", ".gitignore"]
for f in expected:
self.assertIn(f, files, f"flask: file mancante '{f}'")
def test_django_files_present(self):
"""Django: manage.py, requirements.txt, config/settings.py, config/urls.py, api/views.py"""
result = _run(self._scaffold("django", project_name="django-app"))
files = result.get("files", {})
expected = [
"manage.py", "requirements.txt",
"config/__init__.py", "config/settings.py", "config/urls.py",
"api/__init__.py", "api/views.py", "api/urls.py",
]
for f in expected:
self.assertIn(f, files, f"django: file mancante '{f}'")
def test_express_files_present(self):
"""Express: package.json, src/index.js, .gitignore"""
result = _run(self._scaffold("express", project_name="express-app"))
files = result.get("files", {})
expected = ["package.json", "src/index.js", ".gitignore"]
for f in expected:
self.assertIn(f, files, f"express: file mancante '{f}'")
# ── Contenuto dei file ─────────────────────────────────────────────────────
def test_project_name_interpolated_in_react_package_json(self):
"""react: package.json contiene il project_name sanitizzato."""
result = _run(self._scaffold("react", project_name="my-cool-app"))
pkg = result.get("files", {}).get("package.json", "")
self.assertIn("my-cool-app", pkg,
"package.json non contiene il project_name")
def test_project_name_interpolated_in_fastapi_main(self):
"""fastapi: main.py contiene il project_name nel titolo FastAPI."""
result = _run(self._scaffold("fastapi", project_name="my-api"))
main_py = result.get("files", {}).get("main.py", "")
self.assertIn("my-api", main_py,
"main.py non contiene il project_name nel titolo FastAPI")
def test_fastapi_requirements_has_fastapi(self):
"""fastapi: requirements.txt include fastapi e uvicorn."""
result = _run(self._scaffold("fastapi", project_name="test"))
req = result.get("files", {}).get("requirements.txt", "")
self.assertIn("fastapi", req.lower())
self.assertIn("uvicorn", req.lower())
def test_react_package_json_has_vite_scripts(self):
"""react: package.json ha gli script dev e build."""
result = _run(self._scaffold("react", project_name="test"))
pkg = result.get("files", {}).get("package.json", "")
self.assertIn('"dev"', pkg)
self.assertIn('"build"', pkg)
def test_express_src_index_has_express_import(self):
"""express: src/index.js importa express."""
result = _run(self._scaffold("express", project_name="my-svc"))
idx = result.get("files", {}).get("src/index.js", "")
self.assertIn("express", idx)
# ── VFS path (project_dir) ────────────────────────────────────────────────
def test_project_dir_is_slash_prefixed(self):
"""project_dir deve iniziare con '/' — è il percorso VFS root."""
result = _run(self._scaffold("react", project_name="my-app"))
project_dir = result.get("project_dir", "")
self.assertTrue(project_dir.startswith("/"),
f"project_dir deve iniziare con '/': got '{project_dir}'")
def test_project_dir_matches_sanitized_name(self):
"""project_dir deve contenere il nome sanitizzato del progetto."""
result = _run(self._scaffold("react", project_name="MyApp"))
project_dir = result.get("project_dir", "")
# sanitized: "MyApp" → "myapp" o "my-app" depending on impl
# Verifica che sia lowercase
self.assertEqual(project_dir, project_dir.lower(),
f"project_dir non è lowercase: '{project_dir}'")
def test_project_dir_no_special_chars(self):
"""project_dir non deve contenere spazi o caratteri speciali oltre a - e /."""
result = _run(self._scaffold("flask", project_name="My Flask App!"))
project_dir = result.get("project_dir", "")
# Ammesso solo: /a-z0-9-
self.assertRegex(project_dir, r"^/[a-z0-9\-]+$",
f"project_dir ha caratteri non validi: '{project_dir}'")
# ── Name sanitization ─────────────────────────────────────────────────────
def test_name_sanitization_uppercase(self):
"""Maiuscole vengono convertite in minuscolo nel project_dir."""
result = _run(self._scaffold("react", project_name="MYAPP"))
self.assertNotIn("MYAPP", result.get("project_dir", ""))
def test_name_sanitization_spaces(self):
"""Spazi vengono convertiti in trattini (o rimossi)."""
result = _run(self._scaffold("react", project_name="my app"))
project_dir = result.get("project_dir", "")
self.assertNotIn(" ", project_dir,
f"project_dir contiene spazi: '{project_dir}'")
def test_name_max_length(self):
"""project_name viene troncato a max 30 caratteri."""
long_name = "a" * 50
result = _run(self._scaffold("react", project_name=long_name))
# Il nome nel project_dir non deve superare 30 char (escluso il /)
slug = result.get("project_dir", "/").lstrip("/")
self.assertLessEqual(len(slug), 30,
f"slug supera 30 caratteri: '{slug}' ({len(slug)} chars)")
# ── Shape del risultato ───────────────────────────────────────────────────
def test_success_true_for_all_frameworks(self):
"""success=True per tutti e 6 i framework."""
for fw in ("react", "nextjs", "fastapi", "flask", "django", "express"):
with self.subTest(framework=fw):
result = _run(self._scaffold(fw, project_name="test"))
self.assertTrue(result.get("success"),
f"success!=True per framework '{fw}'")
def test_files_dict_nonempty_for_all_frameworks(self):
"""files dict non è mai vuoto per nessun framework."""
for fw in ("react", "nextjs", "fastapi", "flask", "django", "express"):
with self.subTest(framework=fw):
result = _run(self._scaffold(fw, project_name="test"))
files = result.get("files", {})
self.assertGreater(len(files), 0,
f"files vuoto per framework '{fw}'")
def test_created_list_matches_files_keys(self):
"""'created' list contiene esattamente le stesse chiavi di 'files'."""
result = _run(self._scaffold("react", project_name="test"))
files = result.get("files", {})
created = result.get("created", [])
self.assertEqual(sorted(files.keys()), sorted(created),
"'created' e 'files' hanno chiavi diverse")
def test_framework_field_in_result(self):
"""Il risultato include il campo 'framework' con il nome normalizzato."""
result = _run(self._scaffold("REACT", project_name="test"))
self.assertIn("framework", result)
self.assertEqual(result["framework"], "react")
def test_output_is_nonempty_string(self):
"""'output' è una stringa non vuota con info sul progetto."""
result = _run(self._scaffold("fastapi", project_name="test"))
output = result.get("output", "")
self.assertIsInstance(output, str)
self.assertGreater(len(output), 10,
"'output' troppo corto o vuoto")
def test_files_content_are_strings(self):
"""Ogni valore in 'files' è una stringa (non bytes, non None)."""
result = _run(self._scaffold("react", project_name="test"))
for path, content in result.get("files", {}).items():
with self.subTest(file=path):
self.assertIsInstance(content, str,
f"files['{path}'] non è una stringa")
self.assertGreater(len(content), 0,
f"files['{path}'] è una stringa vuota")
# ═══════════════════════════════════════════════════════════════════════════════
# B) Test su scaffold_project_endpoint() — handler FastAPI
# ═══════════════════════════════════════════════════════════════════════════════
class TestScaffoldProjectEndpoint(unittest.TestCase):
"""
Testa la logica dell'endpoint POST /api/scaffold_project in api/files.py.
Usa mock di _scaffold_project per testare il comportamento del wrapper
indipendentemente dall'implementazione core.
"""
@classmethod
def setUpClass(cls):
try:
import api.files as _files_mod
cls._files_mod = _files_mod
except ImportError as e:
raise unittest.SkipTest(f"api.files non importabile: {e}")
def _make_scaffold_mock(self, **override):
"""Crea un AsyncMock di _scaffold_project con risposta di default."""
default = {
"success": True,
"output": "Scaffold react per test — 6 file creati",
"files": {"package.json": '{"name":"test"}', "src/App.tsx": "export default () => null;"},
"framework": "react",
"project_name": "test",
"project_dir": "/test",
"created": ["package.json", "src/App.tsx"],
}
default.update(override)
return AsyncMock(return_value=default)
# ── Validation framework ───────────────────────────────────────────────────
def test_unsupported_framework_raises_400(self):
"""Framework sconosciuto deve sollevare HTTPException con status 400."""
from fastapi import HTTPException
async def _call():
# Chiama la funzione con un framework non valido
body = {"framework": "rails", "project_name": "test"}
# Patches necessari per isolare il router
with patch("api.files._scaffold_project", self._make_scaffold_mock()):
return await self._files_mod.scaffold_project_endpoint(body)
with self.assertRaises(HTTPException) as ctx:
_run(_call())
self.assertEqual(ctx.exception.status_code, 400,
f"Status code atteso 400, got {ctx.exception.status_code}")
self.assertIn("rails", str(ctx.exception.detail).lower(),
"detail non menziona il framework non supportato")
def test_all_six_frameworks_accepted(self):
"""Tutti i 6 framework supportati non sollevano eccezioni."""
from fastapi import HTTPException
for fw in ("react", "nextjs", "fastapi", "flask", "django", "express"):
with self.subTest(framework=fw):
async def _call(framework=fw):
body = {"framework": framework, "project_name": "test"}
mock = self._make_scaffold_mock(framework=framework)
with patch("api.files._scaffold_project", mock):
with patch("tools.registry._scaffold_project", mock):
return await self._files_mod.scaffold_project_endpoint(body)
try:
result = _run(_call())
# Deve ritornare dict, non sollevare 400
self.assertIsInstance(result, dict,
f"framework '{fw}' ha restituito non-dict")
except Exception as e:
# Accettiamo ImportError / ModuleNotFoundError (env senza dipendenze)
# ma non HTTPException 400
from fastapi import HTTPException as _HE
if isinstance(e, _HE) and e.status_code == 400:
self.fail(f"framework '{fw}' ha ricevuto 400: {e.detail}")
# ── Sanitization nel wrapper ───────────────────────────────────────────────
def test_project_name_sanitized_before_scaffold(self):
"""project_name con caratteri speciali viene sanitizzato prima della chiamata."""
captured = {}
async def _mock_scaffold(framework, project_name, target_dir="/tmp"):
captured["project_name"] = project_name
return {
"success": True, "output": "ok", "files": {"f.txt": "x"},
"framework": framework, "project_name": project_name,
"project_dir": f"/{project_name}", "created": ["f.txt"],
}
async def _call():
body = {"framework": "react", "project_name": "My App 2024!"}
with patch("api.files._scaffold_project", _mock_scaffold):
with patch("tools.registry._scaffold_project", _mock_scaffold):
return await self._files_mod.scaffold_project_endpoint(body)
try:
_run(_call())
name = captured.get("project_name", "")
# Deve essere lowercase, nessuno spazio, nessun !
self.assertNotIn(" ", name, f"spazio non rimosso: '{name}'")
self.assertNotIn("!", name, f"! non rimosso: '{name}'")
self.assertNotIn("M", name, f"maiuscola non rimossa: '{name}'")
except Exception:
# Se non riesce a patchare (env senza deps), skip gracefully
pass
# ── Gestione eccezione interna ─────────────────────────────────────────────
def test_internal_exception_returns_error_dict(self):
"""Se _scaffold_project solleva un'eccezione, l'endpoint ritorna {success:False, error:...}."""
async def _boom(framework, project_name, target_dir="/tmp"):
raise RuntimeError("disk full")
async def _call():
body = {"framework": "react", "project_name": "test"}
with patch("api.files._scaffold_project", _boom):
with patch("tools.registry._scaffold_project", _boom):
return await self._files_mod.scaffold_project_endpoint(body)
try:
result = _run(_call())
self.assertFalse(result.get("success"),
"success deve essere False in caso di eccezione")
self.assertIn("error", result,
"deve essere presente il campo 'error'")
self.assertIn("files", result,
"deve essere presente il campo 'files' (dict vuoto)")
self.assertIsInstance(result["files"], dict)
except Exception:
pass # se non riesce a importare/patchare: skip gracefully
# ── Risposta ben formata ───────────────────────────────────────────────────
def test_response_includes_project_dir(self):
"""La risposta deve includere project_dir con VFS prefix /."""
async def _call():
body = {"framework": "react", "project_name": "hello"}
mock = self._make_scaffold_mock(project_dir="/hello", project_name="hello")
with patch("api.files._scaffold_project", mock):
with patch("tools.registry._scaffold_project", mock):
return await self._files_mod.scaffold_project_endpoint(body)
try:
result = _run(_call())
project_dir = result.get("project_dir", "")
self.assertTrue(project_dir.startswith("/"),
f"project_dir non inizia con '/': '{project_dir}'")
except Exception:
pass
def test_missing_framework_defaults_to_react(self):
"""Se framework è omesso nel body, il default è 'react' (non 400)."""
from fastapi import HTTPException
async def _call():
body = {"project_name": "test"} # framework assente
mock = self._make_scaffold_mock()
with patch("api.files._scaffold_project", mock):
with patch("tools.registry._scaffold_project", mock):
return await self._files_mod.scaffold_project_endpoint(body)
try:
result = _run(_call())
# Non deve sollevare 400
self.assertIsInstance(result, dict)
except HTTPException as e:
if e.status_code == 400:
self.fail(f"body senza framework ha sollevato 400: {e.detail}")
except Exception:
pass
# ═══════════════════════════════════════════════════════════════════════════════
# C) Cross-framework smoke test — verifica invarianti su tutti i 6 template
# ═══════════════════════════════════════════════════════════════════════════════
class TestScaffoldProjectSmoke(unittest.TestCase):
"""
Smoke test: chiama _scaffold_project per ogni framework e verifica le
invarianti minime (success, files non-vuoto, project_dir VFS-safe).
Fallisce veloce se il template di un framework è rotto.
"""
@classmethod
def setUpClass(cls):
try:
from tools.registry import _scaffold_project
cls._scaffold = staticmethod(_scaffold_project)
except ImportError as e:
raise unittest.SkipTest(f"tools.registry non importabile: {e}")
_EXPECTED_FILES: dict[str, list[str]] = {
"react": ["package.json", "src/App.tsx"],
"nextjs": ["package.json", "app/page.tsx"],
"fastapi": ["main.py", "requirements.txt"],
"flask": ["app.py", "requirements.txt"],
"django": ["manage.py", "requirements.txt"],
"express": ["package.json", "src/index.js"],
}
def test_all_frameworks_smoke(self):
"""
Per ogni framework: success=True, files non-vuoto, key file presenti,
project_dir inizia con /, files sono stringhe non vuote.
"""
for fw, must_have in self._EXPECTED_FILES.items():
with self.subTest(framework=fw):
result = _run(self._scaffold(framework=fw, project_name=f"smoke-{fw}"))
# 1. success
self.assertTrue(result.get("success"),
f"[{fw}] success != True")
# 2. files non-vuoto
files = result.get("files", {})
self.assertGreater(len(files), 0,
f"[{fw}] files dict vuoto")
# 3. file chiave presenti
for expected_file in must_have:
self.assertIn(expected_file, files,
f"[{fw}] file mancante: '{expected_file}'")
# 4. project_dir VFS-safe
project_dir = result.get("project_dir", "")
self.assertRegex(
project_dir,
r"^/[a-z0-9\-]{1,30}$",
f"[{fw}] project_dir non VFS-safe: '{project_dir}'"
)
# 5. contenuto stringa (può essere vuoto per __init__.py)
for path, content in files.items():
self.assertIsInstance(content, str,
f"[{fw}] files['{path}'] non è str")
if __name__ == "__main__":
unittest.main(verbosity=2)