Spaces:
Running
Running
| """ | |
| 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. | |
| """ | |
| 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. | |
| """ | |
| 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. | |
| """ | |
| 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) | |