test: add comprehensive unit test suite for detector and service generator
- 14 unit tests for DjangoProjectDetector (modular, traditional, flat layouts, venv, .env) - 11 unit tests for SystemdServiceGenerator (umask, workers, security, paths)
This commit is contained in:
2
tests/__init__.py
Normal file
2
tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests package."""
|
||||
273
tests/test_detector.py
Normal file
273
tests/test_detector.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Unit tests for ``core.detector.DjangoProjectDetector``.
|
||||
|
||||
Tests both modern modular layout (``config/wsgi.py``) and traditional
|
||||
layout (``<project>/wsgi.py``) to ensure the auto-detection engine works
|
||||
correctly for all Django project structures.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root is importable
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from core.detector import DjangoProjectDetector
|
||||
from core.models import DatabaseEngine, ProjectLayout
|
||||
|
||||
|
||||
class TestDjangoProjectDetector(unittest.TestCase):
|
||||
"""Test suite for the Django project detector."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.detector = DjangoProjectDetector()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Layout detection
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_modular_layout_detected(self) -> None:
|
||||
"""config/wsgi.py should be detected as MODULAR layout."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text(
|
||||
'import os\n'
|
||||
'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")\n'
|
||||
'from django.core.wsgi import get_wsgi_application\n'
|
||||
'application = get_wsgi_application()\n'
|
||||
)
|
||||
(web_dir / "config" / "settings.py").write_text(
|
||||
'STATIC_ROOT = BASE_DIR / "staticfiles"\n'
|
||||
'DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}}\n'
|
||||
)
|
||||
# Create a fake venv
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("#!/usr/bin/env python3\n")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
|
||||
self.assertEqual(info.layout, ProjectLayout.MODULAR)
|
||||
self.assertEqual(info.wsgi_module, "config.wsgi:application")
|
||||
self.assertEqual(info.settings_module, "config.settings")
|
||||
self.assertTrue(info.is_valid)
|
||||
|
||||
def test_traditional_layout_detected(self) -> None:
|
||||
"""<project>/wsgi.py should be detected as TRADITIONAL layout."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "myproject").mkdir()
|
||||
(web_dir / "myproject" / "wsgi.py").write_text(
|
||||
'import os\n'
|
||||
'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")\n'
|
||||
'from django.core.wsgi import get_wsgi_application\n'
|
||||
'application = get_wsgi_application()\n'
|
||||
)
|
||||
(web_dir / "myproject" / "settings.py").write_text(
|
||||
'STATIC_ROOT = BASE_DIR / "static"\n'
|
||||
'DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql"}}\n'
|
||||
)
|
||||
# Create a fake venv
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("#!/usr/bin/env python3\n")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
|
||||
self.assertEqual(info.layout, ProjectLayout.TRADITIONAL)
|
||||
self.assertEqual(info.wsgi_module, "myproject.wsgi:application")
|
||||
self.assertTrue(info.is_valid)
|
||||
|
||||
def test_traditional_layout_with_hint(self) -> None:
|
||||
"""When a project hint is provided, it should be preferred."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "zamzam").mkdir()
|
||||
(web_dir / "zamzam" / "wsgi.py").write_text("application = None\n")
|
||||
(web_dir / "zamzam" / "settings.py").write_text("")
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir, project_hint="zamzam")
|
||||
|
||||
self.assertEqual(info.layout, ProjectLayout.TRADITIONAL)
|
||||
self.assertEqual(info.wsgi_module, "zamzam.wsgi:application")
|
||||
|
||||
def test_flat_layout_detected(self) -> None:
|
||||
"""wsgi.py at root should be detected as FLAT layout."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "wsgi.py").write_text(
|
||||
'import os\n'
|
||||
'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")\n'
|
||||
'from django.core.wsgi import get_wsgi_application\n'
|
||||
'application = get_wsgi_application()\n'
|
||||
)
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
|
||||
self.assertEqual(info.layout, ProjectLayout.FLAT)
|
||||
self.assertEqual(info.wsgi_module, "wsgi:application")
|
||||
|
||||
def test_no_wsgi_returns_unknown(self) -> None:
|
||||
"""Missing wsgi.py should result in UNKNOWN layout with errors."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
info = self.detector.detect(web_dir)
|
||||
|
||||
self.assertEqual(info.layout, ProjectLayout.UNKNOWN)
|
||||
self.assertFalse(info.is_valid)
|
||||
self.assertTrue(len(info.errors) > 0)
|
||||
|
||||
def test_modular_takes_priority_over_traditional(self) -> None:
|
||||
"""If both config/wsgi.py and myproject/wsgi.py exist, MODULAR wins."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("application = None\n")
|
||||
(web_dir / "config" / "settings.py").write_text("")
|
||||
(web_dir / "myproject").mkdir()
|
||||
(web_dir / "myproject" / "wsgi.py").write_text("application = None\n")
|
||||
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertEqual(info.layout, ProjectLayout.MODULAR)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Virtual environment detection
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_venv_in_public_html(self) -> None:
|
||||
"""Should find venv inside public_html."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertIsNotNone(info.venv_path)
|
||||
self.assertEqual(info.venv_path.name, "venv")
|
||||
|
||||
def test_dotvenv_detected(self) -> None:
|
||||
"""Should find .venv as an alternative."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
venv_bin = web_dir / ".venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertIsNotNone(info.venv_path)
|
||||
self.assertEqual(info.venv_path.name, ".venv")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Settings parsing
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_static_root_extracted(self) -> None:
|
||||
"""STATIC_ROOT value should be extracted from settings.py."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
(web_dir / "config" / "settings.py").write_text(
|
||||
"STATIC_ROOT = BASE_DIR / 'my_static_output'\n"
|
||||
)
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertEqual(info.static_root_name, "my_static_output")
|
||||
|
||||
def test_database_engine_sqlite(self) -> None:
|
||||
"""Should detect SQLite database engine."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
(web_dir / "config" / "settings.py").write_text(
|
||||
"DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3'}}\n"
|
||||
)
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertEqual(info.database_engine, DatabaseEngine.SQLITE)
|
||||
|
||||
def test_database_engine_postgresql(self) -> None:
|
||||
"""Should detect PostgreSQL database engine."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
(web_dir / "config" / "settings.py").write_text(
|
||||
"DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql'}}\n"
|
||||
)
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertEqual(info.database_engine, DatabaseEngine.POSTGRESQL)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Dot-env and requirements detection
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_dotenv_detected(self) -> None:
|
||||
"""Should detect presence of .env file."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
(web_dir / ".env").write_text("DEBUG=True\n")
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertTrue(info.has_dot_env)
|
||||
|
||||
def test_requirements_detected(self) -> None:
|
||||
"""Should detect presence of requirements.txt."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
web_dir = Path(tmpdir)
|
||||
(web_dir / "config").mkdir()
|
||||
(web_dir / "config" / "wsgi.py").write_text("")
|
||||
(web_dir / "requirements.txt").write_text("django\ngunicorn\n")
|
||||
venv_bin = web_dir / "venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(venv_bin / "python").write_text("")
|
||||
|
||||
info = self.detector.detect(web_dir)
|
||||
self.assertTrue(info.has_requirements)
|
||||
|
||||
def test_nonexistent_directory(self) -> None:
|
||||
"""Should handle nonexistent directory gracefully."""
|
||||
info = self.detector.detect(Path("/tmp/does_not_exist_xyz"))
|
||||
self.assertFalse(info.is_valid)
|
||||
self.assertTrue(len(info.errors) > 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
146
tests/test_service_generator.py
Normal file
146
tests/test_service_generator.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Unit tests for ``core.service_generator.SystemdServiceGenerator``.
|
||||
|
||||
Validates that the generated systemd service content includes:
|
||||
- Correct WSGI module reference
|
||||
- ``--umask 007`` flag
|
||||
- Dynamic worker count
|
||||
- EnvironmentFile for .env projects
|
||||
- Security hardening directives
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from core.models import (
|
||||
DatabaseEngine,
|
||||
DjangoProjectInfo,
|
||||
DomainConfig,
|
||||
ProjectLayout,
|
||||
ServiceConfig,
|
||||
)
|
||||
from core.service_generator import SystemdServiceGenerator
|
||||
|
||||
|
||||
class TestSystemdServiceGenerator(unittest.TestCase):
|
||||
"""Test suite for the systemd service generator."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.generator = SystemdServiceGenerator()
|
||||
self.dc = DomainConfig(domain="example.com", user="amin")
|
||||
|
||||
def _make_project_info(self, **kwargs) -> DjangoProjectInfo:
|
||||
defaults = dict(
|
||||
layout=ProjectLayout.MODULAR,
|
||||
wsgi_module="config.wsgi:application",
|
||||
venv_path=Path("/home/amin/web/example.com/public_html/venv"),
|
||||
settings_module="config.settings",
|
||||
static_root_name="staticfiles",
|
||||
database_engine=DatabaseEngine.SQLITE,
|
||||
has_dot_env=False,
|
||||
has_requirements=True,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return DjangoProjectInfo(**defaults)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# Content validation
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_contains_wsgi_module(self) -> None:
|
||||
"""Output should contain the correct WSGI module."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("config.wsgi:application", content)
|
||||
|
||||
def test_contains_umask(self) -> None:
|
||||
"""Output must include --umask 007."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("--umask 007", content)
|
||||
|
||||
def test_contains_protect_system(self) -> None:
|
||||
"""Output should include systemd security hardening."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("ProtectSystem=full", content)
|
||||
self.assertIn("PrivateTmp=true", content)
|
||||
self.assertIn("NoNewPrivileges=true", content)
|
||||
|
||||
def test_dynamic_worker_count(self) -> None:
|
||||
"""Workers should be (2 * CPU) + 1 when not specified."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
expected_workers = (2 * (os.cpu_count() or 1)) + 1
|
||||
self.assertEqual(config.workers, expected_workers)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn(f"--workers {expected_workers}", content)
|
||||
|
||||
def test_custom_worker_count(self) -> None:
|
||||
"""Explicit worker count should override dynamic calculation."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi, workers=4)
|
||||
self.assertEqual(config.workers, 4)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("--workers 4", content)
|
||||
|
||||
def test_env_file_included_when_present(self) -> None:
|
||||
"""EnvironmentFile should be set if .env exists."""
|
||||
pi = self._make_project_info(has_dot_env=True)
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("EnvironmentFile=", content)
|
||||
|
||||
def test_env_file_commented_when_absent(self) -> None:
|
||||
"""EnvironmentFile should be commented out if .env is missing."""
|
||||
pi = self._make_project_info(has_dot_env=False)
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("# No .env found", content)
|
||||
self.assertNotIn("EnvironmentFile=", content)
|
||||
|
||||
def test_traditional_layout_wsgi(self) -> None:
|
||||
"""Traditional layout should use <project>.wsgi:application."""
|
||||
pi = self._make_project_info(
|
||||
layout=ProjectLayout.TRADITIONAL,
|
||||
wsgi_module="zamzam.wsgi:application",
|
||||
)
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("zamzam.wsgi:application", content)
|
||||
|
||||
def test_socket_path_in_output(self) -> None:
|
||||
"""Socket path should match the canonical path from DomainConfig."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn(str(self.dc.socket_path), content)
|
||||
|
||||
def test_domain_in_description(self) -> None:
|
||||
"""Service description should include the domain name."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("example.com", content)
|
||||
|
||||
def test_correct_user_and_group(self) -> None:
|
||||
"""User and Group should be correctly set."""
|
||||
pi = self._make_project_info()
|
||||
config = ServiceConfig(domain_config=self.dc, project_info=pi)
|
||||
content = self.generator.generate(config)
|
||||
self.assertIn("User=amin", content)
|
||||
self.assertIn("Group=www-data", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user