""" 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 (/) · 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)