diff --git a/.vscode/settings.json b/.vscode/settings.json index e2ed885..7dd2244 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,6 +25,7 @@ "elif", "excinfo", "Facepunch", + "falsey", "Feedly", "filterwarnings", "forloop", @@ -63,6 +64,7 @@ "regularuser", "rewardcampaign", "runserver", + "s3cret", "sendgrid", "sitelinks", "sitewide", @@ -73,6 +75,7 @@ "thelovinator", "timebaseddrop", "tqdm", + "trixie", "ttvdrops", "twid", "twitchgamedata", diff --git a/Dockerfile b/Dockerfile index 73fbd15..66aaa1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,10 +15,16 @@ RUN chmod +x /app/start.sh ENV PATH="/app/.venv/bin:$PATH" +RUN groupadd -g 1000 ttvdrops \ + && useradd -m -u 1000 -g 1000 -d /home/ttvdrops -s /bin/sh ttvdrops \ + && mkdir -p /home/ttvdrops/.local/share/TTVDrops \ + && chown -R ttvdrops:ttvdrops /home/ttvdrops /app + HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8000/ || exit 1 -VOLUME ["/root/.local/share/TTVDrops"] +VOLUME ["/home/ttvdrops/.local/share/TTVDrops"] EXPOSE 8000 +USER ttvdrops ENTRYPOINT [ "/app/start.sh" ] CMD ["uv", "run", "gunicorn", "config.wsgi:application", "--workers", "27", "--bind", "0.0.0.0:8000"] diff --git a/config/tests/test_settings.py b/config/tests/test_settings.py index 896d5b5..2e55df6 100644 --- a/config/tests/test_settings.py +++ b/config/tests/test_settings.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib import os +import sys from contextlib import contextmanager from typing import TYPE_CHECKING @@ -50,12 +51,21 @@ def reload_settings_module() -> Generator[Callable[..., ModuleType]]: env[key] = value with temporary_env(env): - return importlib.reload(settings) + # If another test removed the module from sys.modules (we do that in a + # few tests to simulate startup failure), importlib.reload() will + # raise. Import afresh in that case so the fixture is resilient. + if "config.settings" in sys.modules: + return importlib.reload(sys.modules["config.settings"]) + return importlib.import_module("config.settings") yield _reload with temporary_env(original_env): - importlib.reload(settings) + # Ensure `config.settings` is restored even if some tests removed the + # module from sys.modules (several tests intentionally pop it). Use + # importlib.import_module to (re)import and then reload to execute + # module-level initialization under the original environment. + importlib.reload(importlib.import_module("config.settings")) def test_env_bool_truthy_values(monkeypatch: pytest.MonkeyPatch) -> None: @@ -134,3 +144,149 @@ def test_sessions_app_installed() -> None: def test_session_table_exists() -> None: """The sessions table should be available in the database.""" Session.objects.count() + + +def test_env_bool_falsey_values(monkeypatch: pytest.MonkeyPatch) -> None: + """env_bool should treat common falsey strings as False.""" + falsy_values: list[str] = ["0", "false", "no", "n", "off", "", " "] + for value in falsy_values: + monkeypatch.setenv("FEATURE_FLAG", value) + assert settings.env_bool("FEATURE_FLAG") is False + + +def test_env_int_invalid_raises_value_error(monkeypatch: pytest.MonkeyPatch) -> None: + """env_int should raise ValueError for non-integer strings.""" + monkeypatch.setenv("MAX_COUNT", "not-an-int") + with pytest.raises(ValueError, match="invalid literal for int"): + settings.env_int("MAX_COUNT", 1) + + +def test_testing_true_when_sys_argv_contains_test( + monkeypatch: pytest.MonkeyPatch, + reload_settings_module: Callable[..., ModuleType], +) -> None: + """TESTING should be true when 'test' is present in sys.argv.""" + monkeypatch.setattr("sys.argv", ["manage.py", "test"]) + reloaded: ModuleType = reload_settings_module() + + assert reloaded.TESTING is True + + +def test_testing_true_when_pytest_version_set(reload_settings_module: Callable[..., ModuleType]) -> None: + """TESTING should be true when PYTEST_VERSION is set in the env.""" + reloaded: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0") + + assert reloaded.TESTING is True + + +def test_media_and_static_directories_created_on_import( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + reload_settings_module: Callable[..., ModuleType], +) -> None: + """Importing settings should create MEDIA_ROOT and STATIC_ROOT under DATA_DIR.""" + fake_dir: Path = tmp_path / "app_data" + + def fake_user_data_dir(**_: str) -> str: + fake_dir.mkdir(parents=True, exist_ok=True) + return str(fake_dir) + + monkeypatch.setattr("platformdirs.user_data_dir", fake_user_data_dir) + reloaded: ModuleType = reload_settings_module() + + assert fake_dir == reloaded.DATA_DIR + assert reloaded.MEDIA_ROOT.exists() is True + assert reloaded.STATIC_ROOT.exists() is True + + +def test_missing_secret_key_causes_system_exit(monkeypatch: pytest.MonkeyPatch) -> None: + """Missing DJANGO_SECRET_KEY should abort startup with SystemExit.""" + monkeypatch.delenv("DJANGO_SECRET_KEY", raising=False) + # Prevent load_dotenv from repopulating values from the repository .env file + monkeypatch.setattr("dotenv.load_dotenv", lambda *a, **k: None) + + # Remove the cached module so the import executes module-level code freshly + sys.modules.pop("config.settings", None) + + with pytest.raises(SystemExit): + __import__("config.settings") + + +def test_email_settings_from_env(reload_settings_module: Callable[..., ModuleType]) -> None: + """EMAIL_* values should be read from the environment and cast correctly.""" + reloaded: ModuleType = reload_settings_module( + EMAIL_HOST="smtp.example.com", + EMAIL_PORT="1025", + EMAIL_USE_TLS="0", + EMAIL_USE_SSL="1", + EMAIL_HOST_USER="me@example.com", + EMAIL_HOST_PASSWORD="s3cret", + EMAIL_TIMEOUT="3", + ) + + assert reloaded.EMAIL_HOST == "smtp.example.com" + assert reloaded.EMAIL_PORT == 1025 + assert reloaded.EMAIL_USE_TLS is False + assert reloaded.EMAIL_USE_SSL is True + assert reloaded.EMAIL_HOST_USER == "me@example.com" + assert reloaded.EMAIL_HOST_PASSWORD == "s3cret" + assert reloaded.EMAIL_TIMEOUT == 3 + # DEFAULT_FROM_EMAIL and SERVER_EMAIL mirror EMAIL_HOST_USER in settings.py + assert reloaded.DEFAULT_FROM_EMAIL == "me@example.com" + assert reloaded.SERVER_EMAIL == "me@example.com" + + +def test_database_settings_when_not_testing( + monkeypatch: pytest.MonkeyPatch, + reload_settings_module: Callable[..., ModuleType], +) -> None: + """When not running tests, DATABASES should use the Postgres configuration.""" + # Ensure the module believes it's not running tests + monkeypatch.setattr("sys.argv", ["manage.py", "runserver"]) + monkeypatch.delenv("PYTEST_VERSION", raising=False) + + reloaded: ModuleType = reload_settings_module( + POSTGRES_DB="prod_db", + POSTGRES_USER="prod_user", + POSTGRES_PASSWORD="secret", + POSTGRES_HOST="db.host", + POSTGRES_PORT="5433", + CONN_MAX_AGE="120", + CONN_HEALTH_CHECKS="0", + ) + + assert reloaded.TESTING is False + db_cfg = reloaded.DATABASES["default"] + assert db_cfg["ENGINE"] == "django.db.backends.postgresql" + assert db_cfg["NAME"] == "prod_db" + assert db_cfg["USER"] == "prod_user" + assert db_cfg["PASSWORD"] == "secret" + assert db_cfg["HOST"] == "db.host" + assert db_cfg["PORT"] == 5433 + assert db_cfg["CONN_MAX_AGE"] == 120 + assert db_cfg["CONN_HEALTH_CHECKS"] is False + + +def test_debug_tools_installed_only_when_not_testing( + monkeypatch: pytest.MonkeyPatch, + reload_settings_module: Callable[..., ModuleType], +) -> None: + """`debug_toolbar` and `silk` are only added when not TESTING.""" + # Not testing -> tools should be present + monkeypatch.setattr("sys.argv", ["manage.py", "runserver"]) + monkeypatch.delenv("PYTEST_VERSION", raising=False) + not_testing: ModuleType = reload_settings_module() + assert "debug_toolbar" in not_testing.INSTALLED_APPS + assert "silk" in not_testing.INSTALLED_APPS + + # Testing -> tools should not be present + testing: ModuleType = reload_settings_module(PYTEST_VERSION="7.0.0") + assert "debug_toolbar" not in testing.INSTALLED_APPS + assert "silk" not in testing.INSTALLED_APPS + + +def test_logging_configuration_structure() -> None: + """Basic logging structure and levels should be present in LOGGING.""" + assert settings.LOGGING["handlers"]["console"]["class"] == "logging.StreamHandler" + assert settings.LOGGING["loggers"]["ttvdrops"]["level"] == "DEBUG" + assert settings.LOGGING["loggers"][""]["level"] == "INFO" diff --git a/config/tests/test_urls.py b/config/tests/test_urls.py new file mode 100644 index 0000000..dca6fc9 --- /dev/null +++ b/config/tests/test_urls.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +from django.test.utils import override_settings +from django.urls import reverse + +if TYPE_CHECKING: + from collections.abc import Iterable + from types import ModuleType + + +def _pattern_strings(module: ModuleType) -> Iterable[str]: + """Return string representations of the URL patterns' route/regex.""" + return (str(p.pattern) for p in module.urlpatterns) + + +def _reload_urls_with(**overrides) -> ModuleType: + """Reload `config.urls` while temporarily overriding Django settings. + + Returns: + ModuleType: The `config.urls` module as imported under the + overridden settings. The real `config.urls` module is reloaded + back to the test-default settings before this function returns. + """ + # Import under overridden settings + with override_settings(**overrides): + mod = importlib.reload(importlib.import_module("config.urls")) + + # Restore to the normal test settings to avoid leaking changes to other tests + importlib.reload(importlib.import_module("config.urls")) + return mod + + +def test_top_level_named_routes_available() -> None: + """Top-level routes defined in `config.urls` are reversible.""" + assert reverse("sitemap") == "/sitemap.xml" + assert reverse("robots") == "/robots.txt" + # ensure the included `twitch` namespace is present + assert reverse("twitch:dashboard") == "/" + + +def test_debug_tools_not_present_while_testing() -> None: + """`silk` and Django Debug Toolbar URL patterns are not present while running tests.""" + # Default test environment should *not* expose debug routes. + mod = _reload_urls_with(TESTING=True) + patterns = list(_pattern_strings(mod)) + assert not any("silk" in p for p in patterns) + assert not any("__debug__" in p or "debug" in p for p in patterns) diff --git a/docker-compose.yml b/docker-compose.yml index 4aebe6c..f755f88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,7 +73,7 @@ services: - POSTGRES_HOST=ttvdrops_postgres - POSTGRES_PORT=5432 volumes: - - /mnt/Docker/Data/ttvdrops/data:/root/.local/share/TTVDrops + - /mnt/Docker/Data/ttvdrops/data:/home/ttvdrops/.local/share/TTVDrops restart: unless-stopped networks: - web