diff --git a/.github/workflows/template-inspection.yml b/.github/workflows/template-inspection.yml index 3562a7a..8246148 100644 --- a/.github/workflows/template-inspection.yml +++ b/.github/workflows/template-inspection.yml @@ -10,6 +10,10 @@ on: required: false default: "" +permissions: + contents: read + issues: write + jobs: inspect-templates: runs-on: ubuntu-latest @@ -29,9 +33,20 @@ jobs: - name: Install dependencies run: pdm install -G dev + - name: Set up Docker Compose + run: | + if ! command -v docker-compose &> /dev/null; then + echo "Installing Docker Compose..." + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + fi + + docker --version + docker-compose --version + - name: Run template inspection run: | - pdm run python scripts/inspect-templates.py --templates "${{ github.event.inputs.templates }}" --output template_inspection_results.json + pdm run python scripts/inspect-templates.py --templates "${{ github.event.inputs.templates }}" --output template_inspection_results.json --verbose - name: Upload inspection results uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 0f26c08..46f6453 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ htmlcov/ coverage.xml .coverage.* coverage/ +temp/ .pdm-python ### VisualStudioCode template diff --git a/src/fastapi_fastkit/backend/inspector.py b/src/fastapi_fastkit/backend/inspector.py index 5afb0da..f324f55 100644 --- a/src/fastapi_fastkit/backend/inspector.py +++ b/src/fastapi_fastkit/backend/inspector.py @@ -219,16 +219,24 @@ def _check_file_structure(self) -> bool: for path in required_paths: if not (self.template_path / path).exists(): - self.errors.append(f"Missing required path: {path}") + error_msg = f"Missing required path: {path}" + self.errors.append(error_msg) + debug_log(f"File structure check failed: {error_msg}", "error") return False + + debug_log("File structure check passed", "info") return True def _check_file_extensions(self) -> bool: """Check all Python files have .py-tpl extension.""" for path in self.template_path.rglob("*"): if path.is_file() and path.suffix == ".py": - self.errors.append(f"Found .py file instead of .py-tpl: {path}") + error_msg = f"Found .py file instead of .py-tpl: {path}" + self.errors.append(error_msg) + debug_log(f"File extension check failed: {error_msg}", "error") return False + + debug_log("File extension check passed", "info") return True def _check_dependencies(self) -> bool: @@ -237,50 +245,73 @@ def _check_dependencies(self) -> bool: setup_path = self.template_path / "setup.py-tpl" if not req_path.exists(): - self.errors.append("requirements.txt-tpl not found") + error_msg = "requirements.txt-tpl not found" + self.errors.append(error_msg) + debug_log(f"Dependencies check failed: {error_msg}", "error") return False if not setup_path.exists(): - self.errors.append("setup.py-tpl not found") + error_msg = "setup.py-tpl not found" + self.errors.append(error_msg) + debug_log(f"Dependencies check failed: {error_msg}", "error") return False try: with open(req_path, encoding="utf-8") as f: deps = f.read().splitlines() package_names = [dep.split("==")[0] for dep in deps if dep] + debug_log(f"Found dependencies: {package_names}", "debug") if "fastapi" not in package_names: - self.errors.append( - "FastAPI dependency not found in requirements.txt-tpl" - ) + error_msg = "FastAPI dependency not found in requirements.txt-tpl" + self.errors.append(error_msg) + debug_log(f"Dependencies check failed: {error_msg}", "error") return False except (OSError, UnicodeDecodeError) as e: - self.errors.append(f"Error reading requirements.txt-tpl: {e}") + error_msg = f"Error reading requirements.txt-tpl: {e}" + self.errors.append(error_msg) + debug_log(f"Dependencies check failed: {error_msg}", "error") return False + + debug_log("Dependencies check passed", "info") return True def _check_fastapi_implementation(self) -> bool: """Check if the template has a proper FastAPI server implementation.""" try: core_modules = find_template_core_modules(self.temp_dir) + debug_log(f"Found core modules: {core_modules}", "debug") if not core_modules["main"]: - self.errors.append("main.py not found in template") + error_msg = "main.py not found in template" + self.errors.append(error_msg) + debug_log(f"FastAPI implementation check failed: {error_msg}", "error") return False with open(core_modules["main"], encoding="utf-8") as f: content = f.read() if "FastAPI" not in content or "app" not in content: - self.errors.append("FastAPI app creation not found in main.py") + error_msg = "FastAPI app creation not found in main.py" + self.errors.append(error_msg) + debug_log( + f"FastAPI implementation check failed: {error_msg}", "error" + ) + debug_log(f"main.py content preview: {content[:200]}...", "debug") return False except (OSError, UnicodeDecodeError) as e: - self.errors.append(f"Error checking FastAPI implementation: {e}") + error_msg = f"Error checking FastAPI implementation: {e}" + self.errors.append(error_msg) + debug_log(f"FastAPI implementation check failed: {error_msg}", "error") return False + + debug_log("FastAPI implementation check passed", "info") return True def _test_template(self) -> bool: """Run tests on the template using appropriate strategy based on configuration.""" test_dir = os.path.join(self.temp_dir, "tests") if not os.path.exists(test_dir): - self.warnings.append("No tests directory found") + warning_msg = "No tests directory found" + self.warnings.append(warning_msg) + debug_log(f"Template warning: {warning_msg}", "warning") return True # Determine test strategy based on template configuration @@ -295,9 +326,9 @@ def _test_with_docker_strategy(self) -> bool: if not docker_available: debug_log("Docker not available, trying fallback strategy", "warning") - self.warnings.append( - "Docker not available, using fallback testing strategy" - ) + warning_msg = "Docker not available, using fallback testing strategy" + self.warnings.append(warning_msg) + debug_log(f"Template warning: {warning_msg}", "warning") return self._test_with_fallback_strategy() try: @@ -374,7 +405,13 @@ def _test_with_standard_strategy(self) -> bool: result = self._run_pytest_directly(venv_path) if result.returncode != 0: - self.errors.append(f"Tests failed: {result.stderr}") + error_msg = f"Tests failed with return code {result.returncode}\n" + if result.stderr: + error_msg += f"STDERR:\n{result.stderr}\n" + if result.stdout: + error_msg += f"STDOUT:\n{result.stdout}\n" + self.errors.append(error_msg) + debug_log(f"Standard strategy tests failed: {error_msg}", "error") return False debug_log("All tests passed successfully", "info") @@ -549,13 +586,23 @@ def _test_with_fallback_strategy(self) -> bool: result = self._run_pytest_with_env(venv_path, env, fallback_config) if result.returncode != 0: - self.errors.append(f"Fallback tests failed: {result.stderr}") + error_msg = ( + f"Fallback tests failed with return code {result.returncode}\n" + ) + if result.stderr: + error_msg += f"STDERR:\n{result.stderr}\n" + if result.stdout: + error_msg += f"STDOUT:\n{result.stdout}\n" + self.errors.append(error_msg) + debug_log(f"Fallback strategy tests failed: {error_msg}", "error") return False debug_log("Fallback tests passed successfully", "info") - self.warnings.append( + warning_msg = ( "Tests passed using fallback strategy (SQLite instead of PostgreSQL)" ) + self.warnings.append(warning_msg) + debug_log(f"Template warning: {warning_msg}", "warning") return True except Exception as e: @@ -799,7 +846,15 @@ def _run_docker_tests(self, compose_file: str) -> bool: ) if result.returncode != 0: - self.errors.append(f"Docker tests failed: {result.stderr}") + error_msg = ( + f"Docker tests failed with return code {result.returncode}\n" + ) + if result.stderr: + error_msg += f"STDERR:\n{result.stderr}\n" + if result.stdout: + error_msg += f"STDOUT:\n{result.stdout}\n" + self.errors.append(error_msg) + debug_log(f"Docker strategy tests failed: {error_msg}", "error") return False debug_log("Docker tests passed successfully", "info") @@ -868,7 +923,15 @@ def _run_docker_exec_tests(self, compose_file: str) -> bool: ) if result.returncode != 0: - self.errors.append(f"Docker exec tests failed: {result.stderr}") + error_msg = ( + f"Docker exec tests failed with return code {result.returncode}\n" + ) + if result.stderr: + error_msg += f"STDERR:\n{result.stderr}\n" + if result.stdout: + error_msg += f"STDOUT:\n{result.stdout}\n" + self.errors.append(error_msg) + debug_log(f"Docker exec strategy tests failed: {error_msg}", "error") debug_log(f"Docker exec test stderr: {result.stderr}", "error") debug_log(f"Docker exec test stdout: {result.stdout}", "info") return False @@ -904,11 +967,38 @@ def get_report(self) -> Dict[str, Any]: :return: Dictionary containing inspection results """ + is_valid = len(self.errors) == 0 + template_name = self.template_path.name + + # Log final inspection results + if is_valid: + debug_log( + f"Template inspection completed successfully for {template_name}", + "info", + ) + if self.warnings: + debug_log( + f"Template {template_name} has {len(self.warnings)} warnings: {self.warnings}", + "warning", + ) + else: + debug_log( + f"Template inspection failed for {template_name} with {len(self.errors)} errors", + "error", + ) + for i, error in enumerate(self.errors, 1): + debug_log(f"Error {i}: {error}", "error") + if self.warnings: + debug_log( + f"Template {template_name} also has {len(self.warnings)} warnings: {self.warnings}", + "warning", + ) + return { "template_path": str(self.template_path), "errors": self.errors, "warnings": self.warnings, - "is_valid": len(self.errors) == 0, + "is_valid": is_valid, } @@ -919,21 +1009,38 @@ def inspect_fastapi_template(template_path: str) -> Dict[str, Any]: :param template_path: Path to the template to inspect :return: Inspection report dictionary """ + template_name = Path(template_path).name + debug_log( + f"Starting template inspection for {template_name} at {template_path}", "info" + ) + with TemplateInspector(template_path) as inspector: is_valid = inspector.inspect_template() report = inspector.get_report() if is_valid: print_success(f"Template {template_path} is valid!") + debug_log( + f"Template inspection completed successfully for {template_name}", + "info", + ) else: print_error(f"Template {template_path} validation failed") + debug_log(f"Template inspection failed for {template_name}", "error") for error in inspector.errors: print_error(f" - {error}") if inspector.warnings: + debug_log( + f"Template inspection for {template_name} has warnings", "warning" + ) for warning in inspector.warnings: print_warning(f" - {warning}") + debug_log( + f"Template inspection completed for {template_name}. Valid: {is_valid}, Errors: {len(inspector.errors)}, Warnings: {len(inspector.warnings)}", + "info", + ) return report diff --git a/tests/test_backends/test_main.py b/tests/test_backends/test_main.py index e1b991b..a82801b 100644 --- a/tests/test_backends/test_main.py +++ b/tests/test_backends/test_main.py @@ -7,11 +7,14 @@ import subprocess import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, mock_open, patch import pytest from fastapi_fastkit.backend.main import ( + _parse_setup_dependencies, + _process_config_file, + _process_setup_file, add_new_route, create_venv, find_template_core_modules, @@ -63,6 +66,16 @@ def test_create_venv_failure(self, mock_subprocess: MagicMock) -> None: with pytest.raises(BackendExceptions, match="Failed to create venv"): create_venv(str(self.project_path)) + @patch("subprocess.run") + def test_create_venv_os_error(self, mock_subprocess: MagicMock) -> None: + """Test create_venv function with OSError.""" + # given + mock_subprocess.side_effect = OSError("Permission denied") + + # when & then + with pytest.raises(BackendExceptions, match="Failed to create venv"): + create_venv(str(self.project_path)) + def test_find_template_core_modules(self) -> None: """Test find_template_core_modules function.""" # given @@ -122,6 +135,47 @@ def test_install_dependencies_success(self, mock_subprocess: MagicMock) -> None: # Should be called twice: pip upgrade and install requirements assert mock_subprocess.call_count == 2 + @patch("subprocess.run") + def test_install_dependencies_pip_upgrade_failure( + self, mock_subprocess: MagicMock + ) -> None: + """Test install_dependencies function with pip upgrade failure.""" + # given + requirements_txt = self.project_path / "requirements.txt" + requirements_txt.write_text("fastapi==0.104.1") + venv_path = str(self.project_path / ".venv") + (self.project_path / ".venv").mkdir() + mock_subprocess.side_effect = subprocess.CalledProcessError( + 1, ["pip", "install", "--upgrade", "pip"] + ) + + # when & then + with pytest.raises(BackendExceptions, match="Failed to install dependencies"): + install_dependencies(str(self.project_path), venv_path) + + @patch("subprocess.run") + def test_install_dependencies_requirements_failure( + self, mock_subprocess: MagicMock + ) -> None: + """Test install_dependencies function with requirements installation failure.""" + # given + requirements_txt = self.project_path / "requirements.txt" + requirements_txt.write_text("fastapi==0.104.1") + venv_path = str(self.project_path / ".venv") + (self.project_path / ".venv").mkdir() + + # Mock successful pip upgrade but failed requirements install + mock_subprocess.side_effect = [ + MagicMock(returncode=0), # successful pip upgrade + subprocess.CalledProcessError( + 1, ["pip", "install", "-r", "requirements.txt"] + ), # failed requirements install + ] + + # when & then + with pytest.raises(BackendExceptions, match="Failed to install dependencies"): + install_dependencies(str(self.project_path), venv_path) + def test_inject_project_metadata(self) -> None: """Test inject_project_metadata function.""" # given @@ -163,6 +217,26 @@ def test_inject_project_metadata(self) -> None: config_content = config_py.read_text() assert 'PROJECT_NAME = "test-project"' in config_content + @patch("fastapi_fastkit.backend.main.find_template_core_modules") + def test_inject_project_metadata_with_exception( + self, mock_find_modules: MagicMock + ) -> None: + """Test inject_project_metadata function with exception handling.""" + # given + mock_find_modules.side_effect = Exception("Mock error") + + # when & then + with pytest.raises( + BackendExceptions, match="Failed to inject project metadata" + ): + inject_project_metadata( + str(self.project_path), + "test-project", + "Test Author", + "test@example.com", + "Test description", + ) + def test_read_template_stack(self) -> None: """Test read_template_stack function.""" # given @@ -199,7 +273,212 @@ def test_read_template_stack(self) -> None: finally: import shutil - shutil.rmtree(template_path) + shutil.rmtree(str(template_path)) + + def test_read_template_stack_requirements_file(self) -> None: + """Test read_template_stack function with requirements.txt file.""" + # given + template_path = Path(tempfile.mkdtemp()) + try: + requirements_txt = template_path / "requirements.txt-tpl" + requirements_txt.write_text( + "fastapi>=0.100.0\nuvicorn[standard]>=0.23.0\npydantic>=2.0.0" + ) + + # when + result = read_template_stack(str(template_path)) + + # then + assert len(result) == 3 + assert "fastapi>=0.100.0" in result + assert "uvicorn[standard]>=0.23.0" in result + assert "pydantic>=2.0.0" in result + + finally: + import shutil + + shutil.rmtree(str(template_path)) + + @patch("builtins.open", mock_open(read_data="fastapi>=0.100.0")) + @patch("os.path.exists", return_value=True) + def test_read_template_stack_file_read_error(self, mock_exists: MagicMock) -> None: + """Test read_template_stack function with file read error.""" + # given + template_path = "/fake/path" + + # Mock file read error + with patch("builtins.open", side_effect=OSError("Permission denied")): + # when + result = read_template_stack(template_path) + + # then + assert result == [] + + @patch("builtins.open", mock_open(read_data="fastapi>=0.100.0")) + @patch("os.path.exists", return_value=True) + def test_read_template_stack_unicode_error(self, mock_exists: MagicMock) -> None: + """Test read_template_stack function with unicode decode error.""" + # given + template_path = "/fake/path" + + # Mock unicode decode error + with patch( + "builtins.open", + side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid start byte"), + ): + # when + result = read_template_stack(template_path) + + # then + assert result == [] + + def test_parse_setup_dependencies_list_format(self) -> None: + """Test _parse_setup_dependencies function with list format.""" + # given + content = """ +install_requires: list[str] = [ + "fastapi>=0.100.0", + "uvicorn>=0.23.0", + # "commented-out-package", + "pydantic>=2.0.0", +] +""" + + # when + result = _parse_setup_dependencies(content) + + # then + assert len(result) == 3 + assert "fastapi>=0.100.0" in result + assert "uvicorn>=0.23.0" in result + assert "pydantic>=2.0.0" in result + + def test_parse_setup_dependencies_traditional_format(self) -> None: + """Test _parse_setup_dependencies function with traditional format.""" + # given + content = """ +install_requires = [ + 'fastapi>=0.100.0', + 'uvicorn>=0.23.0', + 'pydantic>=2.0.0', +] +""" + + # when + result = _parse_setup_dependencies(content) + + # then + assert len(result) == 3 + assert "fastapi>=0.100.0" in result + assert "uvicorn>=0.23.0" in result + assert "pydantic>=2.0.0" in result + + def test_parse_setup_dependencies_empty_content(self) -> None: + """Test _parse_setup_dependencies function with empty content.""" + # given + content = "" + + # when + result = _parse_setup_dependencies(content) + + # then + assert result == [] + + def test_process_setup_file_success(self) -> None: + """Test _process_setup_file function with successful processing.""" + # given + setup_py = self.project_path / "setup.py" + setup_py.write_text( + """ +setup( + name="", + author="", + author_email="", + description="", +) +""" + ) + + # when + _process_setup_file( + str(setup_py), + "test-project", + "Test Author", + "test@example.com", + "Test description", + ) + + # then + content = setup_py.read_text() + assert "test-project" in content + assert "Test Author" in content + assert "test@example.com" in content + assert "Test description" in content + + def test_process_setup_file_missing_file(self) -> None: + """Test _process_setup_file function with missing file.""" + # given + setup_py = str(self.project_path / "nonexistent.py") + + # when & then (should not raise exception) + _process_setup_file( + setup_py, + "test-project", + "Test Author", + "test@example.com", + "Test description", + ) + + def test_process_setup_file_read_error(self) -> None: + """Test _process_setup_file function with file read error.""" + # given + setup_py = self.project_path / "setup.py" + setup_py.write_text("content") + + # when & then + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises(BackendExceptions, match="Failed to process setup.py"): + _process_setup_file( + str(setup_py), + "test-project", + "Test Author", + "test@example.com", + "Test description", + ) + + def test_process_config_file_success(self) -> None: + """Test _process_config_file function with successful processing.""" + # given + config_py = self.project_path / "config.py" + config_py.write_text('PROJECT_NAME = ""') + + # when + _process_config_file(str(config_py), "test-project") + + # then + content = config_py.read_text() + assert 'PROJECT_NAME = "test-project"' in content + + def test_process_config_file_missing_file(self) -> None: + """Test _process_config_file function with missing file.""" + # given + config_py = str(self.project_path / "nonexistent.py") + + # when & then (should not raise exception) + _process_config_file(config_py, "test-project") + + def test_process_config_file_read_error(self) -> None: + """Test _process_config_file function with file read error.""" + # given + config_py = self.project_path / "config.py" + config_py.write_text("content") + + # when & then + with patch("builtins.open", side_effect=OSError("Permission denied")): + with pytest.raises( + BackendExceptions, match="Failed to process config file" + ): + _process_config_file(str(config_py), "test-project") @patch("fastapi_fastkit.backend.main._ensure_project_structure") @patch("fastapi_fastkit.backend.main._create_route_files") @@ -214,15 +493,14 @@ def test_add_new_route( ) -> None: """Test add_new_route function.""" # given - route_name = "user" mock_ensure_structure.return_value = { - "api": "/fake/api", + "api_routes": "/fake/api/routes", "crud": "/fake/crud", "schemas": "/fake/schemas", } # when - add_new_route(str(self.project_path), route_name) + add_new_route(str(self.project_path), "test_route") # then mock_ensure_structure.assert_called_once() diff --git a/tests/test_cli_operations/test_cli.py b/tests/test_cli_operations/test_cli.py index 64aa0c9..7ba1f1b 100644 --- a/tests/test_cli_operations/test_cli.py +++ b/tests/test_cli_operations/test_cli.py @@ -8,6 +8,7 @@ import subprocess from pathlib import Path from typing import Any +from unittest.mock import MagicMock, patch from click.testing import CliRunner @@ -96,6 +97,69 @@ def test_startdemo(self, temp_dir: str) -> None: assert all(found_files.values()), "Not all core module files were found" + def test_startdemo_invalid_template(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "invalid-template"], + input="\n".join( + ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "Y"] + ), + ) + + # then + assert result.exit_code != 0 + assert "Error" in result.output or "Invalid" in result.output + + def test_startdemo_cancel_confirmation(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + ["test-project", "bnbong", "bbbong9@gmail.com", "test project", "N"] + ), + ) + + # then + # CLI returns 0 even when user cancels (just prints error and returns) + assert result.exit_code == 0 + assert "Project creation aborted!" in result.output + + @patch("fastapi_fastkit.cli.copy_and_convert_template") + def test_startdemo_backend_error( + self, mock_copy_convert: MagicMock, temp_dir: str + ) -> None: + # given + os.chdir(temp_dir) + mock_copy_convert.side_effect = Exception("Backend error") + + # when + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + [ + "test-startdemo-error", + "bnbong", + "bbbong9@gmail.com", + "test project", + "Y", + ] + ), + ) + + # then + # CLI returns 0 even when backend error occurs (just prints error and returns) + assert result.exit_code == 0 + assert "Error during project creation:" in result.output + def test_delete_demoproject(self, temp_dir: str) -> None: # given os.chdir(temp_dir) @@ -122,6 +186,47 @@ def test_delete_demoproject(self, temp_dir: str) -> None: assert "Success" in result.output assert not project_path.exists() + def test_delete_demoproject_cancel(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "test-project" + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + ), + ) + project_path = Path(temp_dir) / project_name + assert project_path.exists() and project_path.is_dir() + + # when + result = self.runner.invoke( + fastkit_cli, + ["deleteproject", project_name], + input="N", + ) + + # then + assert project_path.exists() # Should still exist + + def test_delete_demoproject_nonexistent(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "nonexistent-project" + + # when + result = self.runner.invoke( + fastkit_cli, + ["deleteproject", project_name], + input="Y", + ) + + # then + # CLI returns 0 even when project doesn't exist (just prints error and returns) + assert result.exit_code == 0 + assert "does not exist" in result.output + def test_list_templates(self, temp_dir: str) -> None: # given os.chdir(temp_dir) @@ -212,20 +317,11 @@ def test_init_full(self, temp_dir: str) -> None: assert author_email in content assert description in content - expected_deps = [ - "fastapi", - "uvicorn", - "sqlalchemy", - "alembic", - "pytest", - "redis", - "celery", - ] - with open(project_path / "requirements.txt", "r") as f: content = f.read() - for dep in expected_deps: - assert dep in content + assert "fastapi" in content + assert "uvicorn" in content + assert "sqlalchemy" in content venv_path = project_path / ".venv" assert venv_path.exists() and venv_path.is_dir() @@ -234,15 +330,69 @@ def test_init_full(self, temp_dir: str) -> None: [str(venv_path / "bin" / "pip"), "list"], capture_output=True, text=True ) installed_packages = pip_list.stdout.lower() + assert "fastapi" in installed_packages + assert "uvicorn" in installed_packages + + def test_init_cancel_confirmation(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "test-cancel" + + # when + result = self.runner.invoke( + fastkit_cli, + ["init"], + input="\n".join( + [ + project_name, + "author", + "email@example.com", + "description", + "minimal", + "N", + ] + ), + ) + + # then + project_path = Path(temp_dir) / project_name + assert not project_path.exists() + + @patch("fastapi_fastkit.cli.copy_and_convert_template") + def test_init_backend_error( + self, mock_copy_convert: MagicMock, temp_dir: str + ) -> None: + # given + os.chdir(temp_dir) + mock_copy_convert.side_effect = Exception("Backend error") - for dep in expected_deps: - assert dep in installed_packages + # when + result = self.runner.invoke( + fastkit_cli, + ["init"], + input="\n".join( + [ + "test-backend-error", + "author", + "email@example.com", + "description", + "minimal", + "Y", + ] + ), + ) + + # then + # CLI returns 0 even when backend error occurs (just prints error and returns) + assert result.exit_code == 0 + assert "Error during project creation:" in result.output def test_init_existing_project(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-existing" - os.makedirs(os.path.join(temp_dir, project_name)) + project_path = Path(temp_dir) / project_name + project_path.mkdir() # when result = self.runner.invoke( @@ -251,24 +401,24 @@ def test_init_existing_project(self, temp_dir: str) -> None: input="\n".join( [ project_name, - "test-author", - "test@example.com", - "test description", + "author", + "email@example.com", + "description", "minimal", + "Y", ] ), ) # then - assert "❌" in result.output - assert f"Error: Project '{project_name}' already exists" in result.output + # CLI returns 0 even when project already exists (just prints error and returns) + assert result.exit_code == 0 + assert "already exists" in result.output - def test_is_fastkit_project(self, temp_dir: str) -> None: + def test_is_fastkit_project_function(self, temp_dir: str) -> None: # given os.chdir(temp_dir) project_name = "test-project" - - # Create a regular project result = self.runner.invoke( fastkit_cli, ["startdemo", "fastapi-default"], @@ -276,19 +426,131 @@ def test_is_fastkit_project(self, temp_dir: str) -> None: [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] ), ) - project_path = Path(temp_dir) / project_name - assert project_path.exists() + assert project_path.exists() and project_path.is_dir() + assert "Success" in result.output - # when/then + # when & then + # Test the is_fastkit_project function directly since no CLI command exists from fastapi_fastkit.utils.main import is_fastkit_project assert is_fastkit_project(str(project_path)) is True - # Create a non-fastkit project - non_fastkit_path = Path(temp_dir) / "non-fastkit" - os.makedirs(non_fastkit_path) - with open(non_fastkit_path / "setup.py", "w") as f: - f.write("# Regular project setup") + def test_is_fastkit_project_function_not_fastkit(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_path = Path(temp_dir) / "regular-project" + project_path.mkdir() + + # when & then + # Test the is_fastkit_project function directly since no CLI command exists + from fastapi_fastkit.utils.main import is_fastkit_project + + assert is_fastkit_project(str(project_path)) is False + + def test_runserver_command(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "test-project" + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + ), + ) + project_path = Path(temp_dir) / project_name + assert project_path.exists() and project_path.is_dir() + assert "Success" in result.output + + os.chdir(project_path) + + # when + with patch("subprocess.run") as mock_run: + mock_run.return_value.returncode = 0 + result = self.runner.invoke(fastkit_cli, ["runserver"]) + + # then + assert result.exit_code == 0 + + def test_runserver_no_venv_project(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_path = Path(temp_dir) / "regular-project2" + project_path.mkdir() + os.chdir(project_path) + + # when + # Answer 'N' to "Do you want to continue with system Python?" + result = self.runner.invoke(fastkit_cli, ["runserver"], input="N") + + # then + # CLI returns 0 even when user declines to continue (just returns) + assert result.exit_code == 0 + assert "Virtual environment not found" in result.output - assert is_fastkit_project(str(non_fastkit_path)) is False + def test_addroute_command(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "test-project" + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + ), + ) + project_path = Path(temp_dir) / project_name + assert project_path.exists() and project_path.is_dir() + assert "Success" in result.output + + # when + # addroute command requires project_name and route_name as arguments + result = self.runner.invoke( + fastkit_cli, ["addroute", project_name, "test_route"], input="Y" + ) + + # then + assert result.exit_code == 0 + assert "Successfully added new route" in result.output + + def test_addroute_nonexistent_project(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "nonexistent-project" + + # when + result = self.runner.invoke( + fastkit_cli, ["addroute", project_name, "test_route"], input="Y" + ) + + # then + # CLI returns 0 even when project doesn't exist (just prints error and returns) + assert result.exit_code == 0 + assert "does not exist" in result.output + + def test_addroute_cancel_confirmation(self, temp_dir: str) -> None: + # given + os.chdir(temp_dir) + project_name = "test-project" + result = self.runner.invoke( + fastkit_cli, + ["startdemo", "fastapi-default"], + input="\n".join( + [project_name, "bnbong", "bbbong9@gmail.com", "test project", "Y"] + ), + ) + project_path = Path(temp_dir) / project_name + assert project_path.exists() and project_path.is_dir() + assert "Success" in result.output + + # when + # addroute command requires project_name and route_name as arguments + result = self.runner.invoke( + fastkit_cli, ["addroute", project_name, "test_route"], input="N" + ) + + # then + # CLI returns 0 even when user cancels (just prints error and returns) + assert result.exit_code == 0 + assert "Operation cancelled!" in result.output