diff --git a/pyproject.toml b/pyproject.toml index 9ce26fcb1..e926e39a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,14 @@ main = [ "validators>=0.34", ] pre-commit = ["pre-commit>=4.0"] -test = ["pytest-cov>=6.1", "pytest>=8.3"] -type-check = ["django-stubs[compatible-mypy]>=5.1", "mypy>=1.13", "types-beautifulsoup4>=4.12"] +test = ["pytest-cov>=6.1.1", { include-group = "test-core" }] +test-core = ["gitpython>=3.1.44", "pytest>=8.3"] +type-check = [ + "django-stubs[compatible-mypy]>=5.1", + "mypy>=1.13", + "types-beautifulsoup4>=4.12", + { include-group = "test-core" }, +] [project] # TODO: Remove [project] table once https://github.com/astral-sh/uv/issues/8582 is completed name = "TeX-Bot-Py-V2" @@ -168,7 +174,7 @@ banned-aliases = { "regex" = [ banned-from = ["abc", "re", "regex"] [tool.ruff.lint.per-file-ignores] -"tests/**/test_*.py" = ["S101"] +"tests/**/test_*.py" = ["S101", "S311", "SLF001"] [tool.ruff.lint.flake8-self] extend-ignore-names = ["_base_manager", "_default_manager", "_get_wrap_line_width", "_meta"] @@ -207,13 +213,11 @@ parametrize-values-type = "tuple" [tool.ruff.lint.pyupgrade] keep-runtime-typing = true - [tool.coverage.report] exclude_also = ["if TYPE_CHECKING:"] skip_covered = true sort = "cover" - [tool.pymarkdown] extensions.front-matter.enabled = true mode.strict-config = true diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..0925f6813 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,3013 @@ +"""Automated test suite for the `Settings` class & related functions within `config.py`.""" + +import functools +import itertools +import json +import logging +import os +import random +import re +import string +from collections.abc import Iterable +from datetime import timedelta +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import TYPE_CHECKING + +import pytest + +import config +from config import Settings +from exceptions import ( + ImproperlyConfiguredError, + MessagesJSONFileMissingKeyError, + MessagesJSONFileValueError, +) +from utils import ( + EnvVariableDeleter, + FileTemporaryDeleter, + RandomDiscordBotTokenGenerator, + RandomDiscordGuildIDGenerator, + RandomDiscordLogChannelWebhookURLGenerator, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from typing import IO, Final, TextIO + + from _pytest._code import ExceptionInfo + from _pytest.logging import LogCaptureFixture + + +class TestSettings: + """Test case to unit-test the `Settings` class & its instances.""" + + @classmethod + def replace_setup_methods( + cls, + ignore_methods: Iterable[str] | None = None, + replacement_method: "Callable[[str], None] | None" = None, + ) -> type[Settings]: + """Return a new runtime version of the `Settings` class, with replaced methods.""" + if ignore_methods is None: + ignore_methods = set() + + def empty_setup_method() -> None: + pass + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + SETUP_METHOD_NAMES: Final[Iterable[str]] = { + setup_method_name + for setup_method_name in dir(RuntimeSettings) + if ( + setup_method_name.startswith("_setup_") + and setup_method_name not in ignore_methods + ) + } + + if not SETUP_METHOD_NAMES: # type: ignore[truthy-iterable] + NO_SETUP_METHOD_NAMES_MESSAGE: Final[str] = "No setup methods" + raise RuntimeError(NO_SETUP_METHOD_NAMES_MESSAGE) + + setup_method_name: str + for setup_method_name in SETUP_METHOD_NAMES: + setattr( + RuntimeSettings, + setup_method_name, + ( + functools.partial(replacement_method, setup_method_name) + if replacement_method is not None + else empty_setup_method + ), + ) + + return RuntimeSettings + + @pytest.mark.parametrize("test_item_name", ("ITEM_1",)) + @pytest.mark.parametrize("test_item_value", ("value_1",)) + def test_getattr_success(self, test_item_name: str, test_item_value: str) -> None: + """Test that retrieving a settings variable by attr-lookup returns the set value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._settings[test_item_name] = test_item_value + RuntimeSettings._is_env_variables_setup = True + + assert getattr(RuntimeSettings(), test_item_name) == test_item_value + + @pytest.mark.parametrize("missing_item_name", ("ITEM",)) + def test_getattr_missing_item(self, missing_item_name: str) -> None: + """ + Test that requesting a missing settings variable by attribute-lookup raises an error. + + A missing settings variable is one that has a valid name, + but does not exist within the `_settings` dict (i.e. has not been set). + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._is_env_variables_setup = True + + with pytest.raises( + AttributeError, match=f"'{missing_item_name}' is not a valid settings key." + ): + assert getattr(RuntimeSettings(), missing_item_name) + + @pytest.mark.parametrize("invalid_item_name", ("item_1", "ITEM__1", "!ITEM_1")) + def test_getattr_invalid_name(self, invalid_item_name: str) -> None: + """Test that requesting an invalid settings variable by attr-lookup raises an error.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._is_env_variables_setup = True + + with pytest.raises(AttributeError, match=f"no attribute {invalid_item_name!r}"): + assert getattr(RuntimeSettings(), invalid_item_name) + + @pytest.mark.parametrize("test_item_name", ("ITEM_1",)) + @pytest.mark.parametrize("test_item_value", ("value_1",)) + def test_getattr_sets_up_env_variables( + self, test_item_name: str, test_item_value: str + ) -> None: + """ + Test that requesting a settings variable sets them all up if they have not been. + + This test requests the settings variable by attribute-lookup. + """ + is_env_variables_setup: bool = False + + def set_is_env_variables_setup(_instance: Settings | None = None) -> None: + nonlocal is_env_variables_setup + is_env_variables_setup = True + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._settings[test_item_name] = test_item_value + RuntimeSettings._setup_env_variables = set_is_env_variables_setup # type: ignore[method-assign] + + getattr(RuntimeSettings(), test_item_name) + + assert is_env_variables_setup is True + + @pytest.mark.parametrize("test_item_name", ("ITEM_1",)) + @pytest.mark.parametrize("test_item_value", ("value_1",)) + def test_getitem_success(self, test_item_name: str, test_item_value: str) -> None: + """Test that retrieving a settings variable by key-lookup returns the set value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._settings[test_item_name] = test_item_value + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()[test_item_name] == test_item_value + + @pytest.mark.parametrize("missing_item_name", ("ITEM",)) + def test_getitem_missing_item(self, missing_item_name: str) -> None: + """ + Test that requesting a missing settings variable by key-lookup raises an error. + + A missing settings variable is one that has a valid name, + but does not exist within the `_settings` dict (i.e. has not been set). + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._is_env_variables_setup = True + + with pytest.raises( + KeyError, match=f"'{missing_item_name}' is not a valid settings key." + ): + assert RuntimeSettings()[missing_item_name] + + @pytest.mark.parametrize("invalid_item_name", ("item_1", "ITEM__1", "!ITEM_1")) + def test_getitem_invalid_name(self, invalid_item_name: str) -> None: + """Test that requesting an invalid settings variable by key-lookup raises an error.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._is_env_variables_setup = True + + with pytest.raises(KeyError, match=str(KeyError(invalid_item_name))): + assert RuntimeSettings()[invalid_item_name] + + @pytest.mark.parametrize("test_item_name", ("ITEM_1",)) + @pytest.mark.parametrize("test_item_value", ("value_1",)) + def test_getitem_sets_up_env_variables( + self, + test_item_name: str, + test_item_value: str, + ) -> None: + """ + Test that requesting a settings variable sets them all up if they have not been. + + This test requests the settings variable by key-lookup. + """ + is_env_variables_setup: bool = False + + def set_is_env_variables_setup(_instance: Settings | None = None) -> None: + nonlocal is_env_variables_setup + is_env_variables_setup = True + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._settings[test_item_name] = test_item_value + RuntimeSettings._setup_env_variables = set_is_env_variables_setup # type: ignore[method-assign] + + RuntimeSettings().__getitem__(test_item_name) + + assert is_env_variables_setup is True + + def test_is_env_variables_setup_made_true(self) -> None: + """Test calling `_setup_env_variables()` sets `_is_env_variables_setup` to True.""" + RuntimeSettings: Final[type[Settings]] = self.replace_setup_methods( + ignore_methods=("_setup_env_variables",), + ) + + assert RuntimeSettings._is_env_variables_setup is False + + RuntimeSettings._setup_env_variables() + + assert RuntimeSettings._is_env_variables_setup is True + + def test_every_setup_method_called(self) -> None: + """Test that calling `_setup_env_variables()` sets up all Env Variables.""" + CALLED_SETUP_METHODS: Final[set[str]] = set() + + def add_called_setup_method(setup_method_name: str) -> None: + nonlocal CALLED_SETUP_METHODS + CALLED_SETUP_METHODS.add(setup_method_name) + + RuntimeSettings: Final[type[Settings]] = self.replace_setup_methods( + ignore_methods=("_setup_env_variables",), + replacement_method=add_called_setup_method, + ) + + RuntimeSettings._setup_env_variables() + + assert ( + CALLED_SETUP_METHODS # noqa: SIM300 + == { + setup_method_name + for setup_method_name in dir(RuntimeSettings) + if ( + setup_method_name.startswith("_setup_") + and setup_method_name != "_setup_env_variables" + ) + } + ) + + @pytest.mark.parametrize("test_item_name", ("ITEM_1",)) + @pytest.mark.parametrize("test_item_value", ("value_1",)) + def test_cannot_setup_more_than_once( + self, + caplog: "LogCaptureFixture", + test_item_name: str, + test_item_value: str, + ) -> None: + """Test that the Env Variables cannot be set more than once.""" + RuntimeSettings: Final[type[Settings]] = self.replace_setup_methods( + ignore_methods=("_setup_env_variables",), + ) + + RuntimeSettings._setup_env_variables() + RuntimeSettings._settings[test_item_name] = test_item_value + + PREVIOUS_SETTINGS: Final[dict[str, object]] = RuntimeSettings._settings.copy() + + assert not caplog.text + + RuntimeSettings._setup_env_variables() + + assert RuntimeSettings._settings == PREVIOUS_SETTINGS + assert "already" in caplog.text + assert "set up" in caplog.text + + def test_module_level_settings_object(self) -> None: + """Test that the auto-instantiated module-level settings object is correct.""" + assert isinstance(config.settings, Settings) + + def test_settings_class_factory(self) -> None: + """Test that the settings class factory produces valid & separate settings classes.""" + assert issubclass(config._settings_class_factory(), Settings) + + assert config._settings_class_factory()._is_env_variables_setup is False + assert not config._settings_class_factory()._settings + + assert config._settings_class_factory() != config._settings_class_factory() + + +class TestSetupConsoleLogging: + """Test case to unit-test the `_setup_console_logging()` function.""" + + @pytest.mark.parametrize("test_log_level", config.LOG_LEVEL_CHOICES) + def test_setup_console_logging_successful(self, test_log_level: str) -> None: + """Test that the given `CONSOLE_LOG_LEVEL` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CONSOLE_LOG_LEVEL"): + os.environ["CONSOLE_LOG_LEVEL"] = test_log_level + + RuntimeSettings._setup_console_logging() + + assert "TeX-Bot" in set(logging.root.manager.loggerDict) + assert logging.getLogger("TeX-Bot").getEffectiveLevel() == getattr( + logging, test_log_level + ) + + def test_default_console_log_level(self) -> None: + """Test that a default value is used when no `CONSOLE_LOG_LEVEL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CONSOLE_LOG_LEVEL"): + RuntimeSettings._setup_console_logging() + + assert "TeX-Bot" in set(logging.root.manager.loggerDict) + + @pytest.mark.parametrize( + "invalid_log_level", + ( + "invalid_log_level", + "", + " ", + "".join( + random.choices( + string.ascii_letters + string.digits + string.punctuation, + k=18, + ), + ), + ), + ids=[f"case_{i}" for i in range(4)], + ) + def test_invalid_console_log_level(self, invalid_log_level: str) -> None: + """Test that an error is raised when an invalid `CONSOLE_LOG_LEVEL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CONSOLE_LOG_LEVEL"): + os.environ["CONSOLE_LOG_LEVEL"] = invalid_log_level + + with pytest.raises(ImproperlyConfiguredError, match="LOG_LEVEL must be one of"): + RuntimeSettings._setup_console_logging() + + @pytest.mark.parametrize("lower_case_log_level", ("info", "debug", "warning")) + def test_valid_lowercase_console_log_level(self, lower_case_log_level: str) -> None: + """Test that the provided `CONSOLE_LOG_LEVEL` is fixed & used if it is in lowercase.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CONSOLE_LOG_LEVEL"): + os.environ["CONSOLE_LOG_LEVEL"] = lower_case_log_level + + RuntimeSettings._setup_console_logging() + + +class TestSetupDiscordApiLogging: + """Test case to unit-test the `_setup_discord_log_level()` function.""" + + @pytest.mark.parametrize("test_log_level", config.LOG_LEVEL_CHOICES) + def test_setup_discord_api_logging_successful(self, test_log_level: str) -> None: + """Test that the given `DISCORD_LOG_LEVEL` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_LEVEL"): + os.environ["DISCORD_LOG_LEVEL"] = test_log_level + + RuntimeSettings._setup_discord_log_level() + + assert "discord" in set(logging.root.manager.loggerDict) + assert logging.getLogger("discord").getEffectiveLevel() == getattr( + logging, test_log_level + ) + + def test_default_discord_log_level(self) -> None: + """Test that a default value is used when no `DISCORD_LOG_LEVEL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_LEVEL"): + RuntimeSettings._setup_discord_log_level() + + assert "discord" in set(logging.root.manager.loggerDict) + assert logging.getLogger("discord").getEffectiveLevel() == logging.CRITICAL + + @pytest.mark.parametrize( + "invalid_log_level", + ( + "invalid_log_level", + "lol what is this", + "clearly not a log level", + "5", + "34", + "-1", + "`./|=_+*^%$#@!~", + ), + ) + def test_invalid_discord_log_level(self, invalid_log_level: str) -> None: + """Test that an error is raised when an invalid `DISCORD_LOG_LEVEL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_LEVEL"): + os.environ["DISCORD_LOG_LEVEL"] = invalid_log_level + + with pytest.raises(ImproperlyConfiguredError, match="LOG_LEVEL must be one of"): + RuntimeSettings._setup_discord_log_level() + + +class TestSetupDiscordBotToken: + """Test case to unit-test the `_setup_discord_bot_token()` function.""" + + @pytest.mark.parametrize( + "test_discord_bot_token", + itertools.chain( + RandomDiscordBotTokenGenerator.multiple_values(), + (f" {RandomDiscordBotTokenGenerator.single_value()} ",), + ), + ids=[f"case_{i}" for i in range(6)], + ) + def test_setup_discord_bot_token_successful(self, test_discord_bot_token: str) -> None: + """Test that the given `DISCORD_BOT_TOKEN` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_BOT_TOKEN"): + os.environ["DISCORD_BOT_TOKEN"] = test_discord_bot_token + + RuntimeSettings._setup_discord_bot_token() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["DISCORD_BOT_TOKEN"] == test_discord_bot_token.strip() + + def test_missing_discord_bot_token(self) -> None: + """Test that an error is raised when no `DISCORD_BOT_TOKEN` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_BOT_TOKEN"): # noqa: SIM117 + with pytest.raises( + ImproperlyConfiguredError, match=r"DISCORD_BOT_TOKEN.*valid.*Discord bot token" + ): + RuntimeSettings._setup_discord_bot_token() + + @pytest.mark.parametrize( + "invalid_discord_bot_token", + ( + "invalid_discord_bot_token", + "", + " ", + "".join( + random.choices( + string.ascii_letters + string.digits + string.punctuation, + k=18, + ), + ), + re.sub( + r"\A[A-Za-z0-9]{24,26}\.", + f"{''.join(random.choices(string.ascii_letters + string.digits, k=2))}.", + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\A[A-Za-z0-9]{24,26}\.", + f"{''.join(random.choices(string.ascii_letters + string.digits, k=50))}.", + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\A[A-Za-z0-9]{24,26}\.", + ( + f"{''.join(random.choices(string.ascii_letters + string.digits, k=12))}>{ + ''.join(random.choices(string.ascii_letters + string.digits, k=12)) + }." + ), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9]{6}\.", + f".{''.join(random.choices(string.ascii_letters + string.digits, k=2))}.", + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9]{6}\.", + (f".{''.join(random.choices(string.ascii_letters + string.digits, k=50))}."), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9]{6}\.", + ( + f".{''.join(random.choices(string.ascii_letters + string.digits, k=3))}>{ + ''.join(random.choices(string.ascii_letters + string.digits, k=2)) + }." + ), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9_-]{27,38}\Z", + ( + f".{ + ''.join( + random.choices(string.ascii_letters + string.digits + '_-', k=2) + ) + }" + ), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9_-]{27,38}\Z", + ( + f".{ + ''.join( + random.choices(string.ascii_letters + string.digits + '_-', k=50) + ) + }" + ), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + re.sub( + r"\.[A-Za-z0-9_-]{27,38}\Z", + ( + f".{ + ''.join( + random.choices(string.ascii_letters + string.digits + '_-', k=16) + ) + }>{ + ''.join( + random.choices(string.ascii_letters + string.digits + '_-', k=16) + ) + }" + ), + string=RandomDiscordBotTokenGenerator.single_value(), + count=1, + ), + ), + ids=[f"case_{i}" for i in range(13)], + ) + def test_invalid_discord_bot_token(self, invalid_discord_bot_token: str) -> None: + """Test that an error is raised when an invalid `DISCORD_BOT_TOKEN` is provided.""" + INVALID_DISCORD_BOT_TOKEN_MESSAGE: Final[str] = ( + "DISCORD_BOT_TOKEN must be set to a valid Discord bot token " # noqa: S105 + "(see https://discord.com/developers/docs/topics/oauth2#bot-vs-user-accounts)." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_BOT_TOKEN"): + os.environ["DISCORD_BOT_TOKEN"] = invalid_discord_bot_token + + with pytest.raises( + ImproperlyConfiguredError, match=re.escape(INVALID_DISCORD_BOT_TOKEN_MESSAGE) + ): + RuntimeSettings._setup_discord_bot_token() + + +class TestSetupDiscordLogChannelWebhookURL: + """Test case to unit-test the `_setup_discord_log_channel_webhook()` function.""" + + @pytest.mark.parametrize( + "test_discord_log_channel_webhook_url", + itertools.chain( + RandomDiscordLogChannelWebhookURLGenerator.multiple_values( + with_trailing_slash=False, + ), + RandomDiscordLogChannelWebhookURLGenerator.multiple_values( + count=1, + with_trailing_slash=True, + ), + (f" {RandomDiscordLogChannelWebhookURLGenerator.single_value()} ",), + ), + ids=[f"case_{i}" for i in range(7)], + ) + def test_setup_discord_log_channel_webhook_successful( + self, + test_discord_log_channel_webhook_url: str, + ) -> None: + """ + Test that the given `DISCORD_LOG_CHANNEL_WEBHOOK_URL` is used when provided. + + In this test, the provided `DISCORD_LOG_CHANNEL_WEBHOOK_URL` is valid + and so must be saved successfully. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_CHANNEL_WEBHOOK_URL"): + os.environ["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = ( + test_discord_log_channel_webhook_url + ) + + RuntimeSettings._setup_discord_log_channel_webhook() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] == ( + test_discord_log_channel_webhook_url.strip() + ) + + def test_missing_discord_log_channel_webhook_url(self) -> None: + """Test that no error occurs when no `DISCORD_LOG_CHANNEL_WEBHOOK_URL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_CHANNEL_WEBHOOK_URL"): + RuntimeSettings._setup_discord_log_channel_webhook() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] + + @pytest.mark.parametrize( + "invalid_discord_log_channel_url", + ( + "invalid_discord_log_channel_url", + re.sub( + r"/\d{17,20}/", + ( + f"/{''.join(random.choices(string.ascii_letters + string.digits, k=9))}>" + f"{''.join(random.choices(string.ascii_letters + string.digits, k=9))}/" + ), + string=RandomDiscordLogChannelWebhookURLGenerator.single_value(), + count=1, + ), + re.sub( + r"/[a-zA-Z\d]{60,90}", + ( + f"/{''.join(random.choices(string.ascii_letters + string.digits, k=37))}>" + f"{''.join(random.choices(string.ascii_letters + string.digits, k=37))}" + ), + string=RandomDiscordLogChannelWebhookURLGenerator.single_value(), + count=1, + ), + ), + ids=[f"case_{i}" for i in range(3)], + ) + def test_invalid_discord_log_channel_webhook_url( + self, + invalid_discord_log_channel_url: str, + ) -> None: + """Test that an error occurs when the `DISCORD_LOG_CHANNEL_WEBHOOK_URL` is invalid.""" + INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE: Final[str] = ( + "DISCORD_LOG_CHANNEL_WEBHOOK_URL must be a valid webhook URL " + "that points to a discord channel where logs should be displayed." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_LOG_CHANNEL_WEBHOOK_URL"): + os.environ["DISCORD_LOG_CHANNEL_WEBHOOK_URL"] = invalid_discord_log_channel_url + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_DISCORD_LOG_CHANNEL_WEBHOOK_URL_MESSAGE, + ): + RuntimeSettings._setup_discord_log_channel_webhook() + + +class TestSetupDiscordGuildID: + """Test case to unit-test the `_setup_discord_guild_id()` function.""" + + @pytest.mark.parametrize( + "test_discord_guild_id", + itertools.chain( + RandomDiscordGuildIDGenerator.multiple_values(), + (f" {RandomDiscordGuildIDGenerator.single_value()} ",), + ), + ids=[f"case_{i}" for i in range(6)], + ) + def test_setup_discord_guild_id_successful(self, test_discord_guild_id: str) -> None: + """Test the given `_DISCORD_MAIN_GUILD_ID` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_GUILD_ID"): + os.environ["DISCORD_GUILD_ID"] = test_discord_guild_id + + RuntimeSettings._setup_discord_guild_id() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["_DISCORD_MAIN_GUILD_ID"] == int( + test_discord_guild_id.strip() + ) + + def test_missing_discord_guild_id(self) -> None: + """Test that an error is raised when no `DISCORD_GUILD_ID` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with ( + EnvVariableDeleter("DISCORD_GUILD_ID"), + pytest.raises( + ImproperlyConfiguredError, match=r"DISCORD_GUILD_ID.*valid.*Discord guild ID" + ), + ): + RuntimeSettings._setup_discord_guild_id() + + @pytest.mark.parametrize( + "invalid_discord_guild_id", + ( + "invalid_discord_guild_id", + "", + " ", + "".join( + random.choices( + string.ascii_letters + string.digits + string.punctuation, + k=18, + ), + ), + "".join(random.choices(string.digits, k=2)), + "".join(random.choices(string.digits, k=50)), + ), + ids=[f"case_{i}" for i in range(6)], + ) + def test_invalid_discord_guild_id(self, invalid_discord_guild_id: str) -> None: + """Test that an error is raised when an invalid `DISCORD_GUILD_ID` is provided.""" + INVALID_DISCORD_GUILD_ID_MESSAGE: Final[str] = ( + "DISCORD_GUILD_ID must be a valid Discord guild ID" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("DISCORD_GUILD_ID"): + os.environ["DISCORD_GUILD_ID"] = invalid_discord_guild_id + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_DISCORD_GUILD_ID_MESSAGE + ): + RuntimeSettings._setup_discord_guild_id() + + +class TestSetupGroupFullName: + """Test case to unit-test the `_setup_group_full_name()` function.""" + + @pytest.mark.parametrize( + "test_group_full_name", + ( + "Computer Science Society", + "Arts & Crafts Soc", + "3Bugs Fringe Theatre Society", + "Burn FM.com", + "Dental Society", + "Devil's Advocate Society", + "KASE: Knowledge And Skills Exchange", + "Law for Non-Law", + " Computer Science Society ", + "Computer Science Society?", + "Computer Science Society!", + ), + ) + def test_setup_group_full_name_successful(self, test_group_full_name: str) -> None: + """Test that the given `GROUP_NAME` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_NAME"): + os.environ["GROUP_NAME"] = test_group_full_name + + RuntimeSettings._setup_group_full_name() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["_GROUP_FULL_NAME"] == test_group_full_name.strip().translate( + { + ord(unicode_char): ascii_char + for unicode_char, ascii_char in zip("‘’´“”–-", "''`\"\"--", strict=True) # noqa: RUF001 + }, + ) + + def test_missing_group_full_name(self) -> None: + """Test that no error occurs when no `GROUP_NAME` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_NAME"): + RuntimeSettings._setup_group_full_name() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["_GROUP_FULL_NAME"] + + @pytest.mark.parametrize( + "invalid_group_full_name", + ( + "Computer Science$Society", + "Computer Science£Society", + "Computer Science*Society", + ), + ids=[f"case_{i}" for i in range(3)], + ) + def test_invalid_group_full_name(self, invalid_group_full_name: str) -> None: + """Test that an error is raised when an invalid `GROUP_NAME` is provided.""" + INVALID_GROUP_NAME_MESSAGE: Final[str] = ( + "GROUP_NAME must not contain any invalid characters" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_NAME"): + os.environ["GROUP_NAME"] = invalid_group_full_name + + with pytest.raises(ImproperlyConfiguredError, match=INVALID_GROUP_NAME_MESSAGE): + RuntimeSettings._setup_group_full_name() + + +class TestSetupGroupShortName: + """Test case to unit-test the `_setup_group_short_name()` function.""" + + @pytest.mark.parametrize( + "test_group_short_name", + ( + "CSS", + "ArtSoc", + "3Bugs", + "BurnFM.com", + "L4N-L", + " CSS ", + "CSS?", + "CSS!", + ), + ) + def test_setup_group_short_name_successful(self, test_group_short_name: str) -> None: + """Test that the given `GROUP_SHORT_NAME` is used when a valid one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_SHORT_NAME"): + os.environ["GROUP_SHORT_NAME"] = test_group_short_name + + RuntimeSettings._setup_group_short_name() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["_GROUP_SHORT_NAME"] == ( + test_group_short_name.strip().translate( + { + ord(unicode_char): ascii_char + for unicode_char, ascii_char in zip("‘’´“”–-", "''`\"\"--", strict=True) # noqa: RUF001 + }, + ) + ) + + def test_missing_group_short_name_without_group_full_name(self) -> None: + """Test that no error occurs when no `GROUP_SHORT_NAME` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_SHORT_NAME"), EnvVariableDeleter("GROUP_NAME"): + RuntimeSettings._setup_group_full_name() + assert RuntimeSettings._settings["_GROUP_FULL_NAME"] is None + + RuntimeSettings._setup_group_short_name() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["_GROUP_SHORT_NAME"] + + @pytest.mark.parametrize( + "invalid_group_short_name", + ( + "C S S", + "CS$S", + "CS£S", + ), + ids=[f"case_{i}" for i in range(3)], + ) + def test_invalid_group_short_name(self, invalid_group_short_name: str) -> None: + """Test that an error is raised when an invalid `GROUP_SHORT_NAME` is provided.""" + INVALID_GROUP_SHORT_NAME_MESSAGE: Final[str] = ( + "GROUP_SHORT_NAME must not contain any invalid characters" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("GROUP_SHORT_NAME"): + os.environ["GROUP_SHORT_NAME"] = invalid_group_short_name + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_GROUP_SHORT_NAME_MESSAGE + ): + RuntimeSettings._setup_group_short_name() + + +class TestSetupPurchaseMembershipURL: + """Test case to unit-test the `_setup_purchase_membership_url()` function.""" + + @pytest.mark.parametrize( + "test_purchase_membership_url", + ("https://google.com", "www.google.com/", " https://google.com "), + ) + def test_setup_purchase_membership_url_successful( + self, test_purchase_membership_url: str + ) -> None: + """Test that the given valid `PURCHASE_MEMBERSHIP_URL` is used when one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PURCHASE_MEMBERSHIP_URL"): + os.environ["PURCHASE_MEMBERSHIP_URL"] = test_purchase_membership_url + + RuntimeSettings._setup_purchase_membership_url() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["PURCHASE_MEMBERSHIP_URL"] == ( + f"https://{test_purchase_membership_url.strip()}" + if "://" not in test_purchase_membership_url + else test_purchase_membership_url.strip() + ) + + def test_missing_purchase_membership_url(self) -> None: + """Test that no error occurs when no `PURCHASE_MEMBERSHIP_URL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PURCHASE_MEMBERSHIP_URL"): + RuntimeSettings._setup_purchase_membership_url() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["PURCHASE_MEMBERSHIP_URL"] + + def test_invalid_protocol_purchase_membership_url(self) -> None: + """Test that an error occurs when `PURCHASE_MEMBERSHIP_URL` is not https.""" + INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( + "Only HTTPS is supported as a protocol for PURCHASE_MEMBERSHIP_URL." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PURCHASE_MEMBERSHIP_URL"): + os.environ["PURCHASE_MEMBERSHIP_URL"] = "ftp://google.com" + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE + ): + RuntimeSettings._setup_purchase_membership_url() + + @pytest.mark.parametrize( + "invalid_purchase_membership_url", + ("invalid_purchase_membership_url", "www.google..com/"), + ) + def test_invalid_purchase_membership_url( + self, invalid_purchase_membership_url: str + ) -> None: + """Test that an error occurs when the provided `PURCHASE_MEMBERSHIP_URL` is invalid.""" + INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE: Final[str] = ( + "PURCHASE_MEMBERSHIP_URL must be a valid URL" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PURCHASE_MEMBERSHIP_URL"): + os.environ["PURCHASE_MEMBERSHIP_URL"] = invalid_purchase_membership_url + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_PURCHASE_MEMBERSHIP_URL_MESSAGE + ): + RuntimeSettings._setup_purchase_membership_url() + + +class TestSetupMembershipPerksURL: + """Test case to unit-test the `_setup_membership_perks_url()` function.""" + + @pytest.mark.parametrize( + "test_membership_perks_url", + ("https://google.com", "www.google.com/", " https://google.com "), + ) + def test_setup_membership_perks_url_successful( + self, test_membership_perks_url: str + ) -> None: + """Test that the given valid `MEMBERSHIP_PERKS_URL` is used when one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_PERKS_URL"): + os.environ["MEMBERSHIP_PERKS_URL"] = test_membership_perks_url + + RuntimeSettings._setup_membership_perks_url() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["MEMBERSHIP_PERKS_URL"] == ( + f"https://{test_membership_perks_url.strip()}" + if "://" not in test_membership_perks_url.strip() + else test_membership_perks_url.strip() + ) + + def test_missing_membership_perks_url(self) -> None: + """Test that no error occurs when no `MEMBERSHIP_PERKS_URL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_PERKS_URL"): + RuntimeSettings._setup_membership_perks_url() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["MEMBERSHIP_PERKS_URL"] + + def test_invalid_protocol_membership_perks_url(self) -> None: + """Test that an error occurs when `MEMBERSHIP_PERKS_URL` is not https.""" + INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( + "Only HTTPS is supported as a protocol for MEMBERSHIP_PERKS_URL." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_PERKS_URL"): + os.environ["MEMBERSHIP_PERKS_URL"] = "ftp://google.com" + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_MEMBERSHIP_PERKS_URL_MESSAGE + ): + RuntimeSettings._setup_membership_perks_url() + + @pytest.mark.parametrize( + "invalid_membership_perks_url", + ("invalid_membership_perks_url", "www.google..com/"), + ) + def test_invalid_membership_perks_url(self, invalid_membership_perks_url: str) -> None: + """Test that an error occurs when the provided `MEMBERSHIP_PERKS_URL` is invalid.""" + INVALID_MEMBERSHIP_PERKS_URL_MESSAGE: Final[str] = ( + "MEMBERSHIP_PERKS_URL must be a valid URL" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_PERKS_URL"): + os.environ["MEMBERSHIP_PERKS_URL"] = invalid_membership_perks_url + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_MEMBERSHIP_PERKS_URL_MESSAGE + ): + RuntimeSettings._setup_membership_perks_url() + + +class TestSetupPingCommandEasterEggProbability: + """Test case to unit-test the `_setup_ping_command_easter_egg_probability()` function.""" + + @pytest.mark.parametrize( + "test_ping_command_easter_egg_probability", + ("1", "0", "0.5", " 0.5 "), + ) + def test_setup_ping_command_easter_egg_probability_successful( + self, test_ping_command_easter_egg_probability: str + ) -> None: + """ + Test that the given `PING_COMMAND_EASTER_EGG_PROBABILITY` is used when provided. + + In this test, the provided `PING_COMMAND_EASTER_EGG_PROBABILITY` is valid + and so must be saved successfully. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PING_COMMAND_EASTER_EGG_PROBABILITY"): + os.environ["PING_COMMAND_EASTER_EGG_PROBABILITY"] = ( + test_ping_command_easter_egg_probability + ) + + RuntimeSettings._setup_ping_command_easter_egg_probability() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["PING_COMMAND_EASTER_EGG_PROBABILITY"] == 100 * float( + test_ping_command_easter_egg_probability.strip(), + ) + + def test_default_ping_command_easter_egg_probability(self) -> None: + """Test that a default value is used if no `PING_COMMAND_EASTER_EGG_PROBABILITY`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PING_COMMAND_EASTER_EGG_PROBABILITY"): + RuntimeSettings._setup_ping_command_easter_egg_probability() + + RuntimeSettings._is_env_variables_setup = True + + assert isinstance( + RuntimeSettings()["PING_COMMAND_EASTER_EGG_PROBABILITY"], float | int + ) + assert 0 <= RuntimeSettings()["PING_COMMAND_EASTER_EGG_PROBABILITY"] <= 100 + + @pytest.mark.parametrize( + "invalid_ping_command_easter_egg_probability", + ("invalid_ping_command_easter_egg_probability", "-5", "1.1", "5", "-0.01"), + ) + def test_invalid_ping_command_easter_egg_probability( + self, invalid_ping_command_easter_egg_probability: str + ) -> None: + """Test that errors when provided `PING_COMMAND_EASTER_EGG_PROBABILITY` is invalid.""" + INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE: Final[str] = ( + "PING_COMMAND_EASTER_EGG_PROBABILITY must be a float between & including 0 to 1." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("PING_COMMAND_EASTER_EGG_PROBABILITY"): + os.environ["PING_COMMAND_EASTER_EGG_PROBABILITY"] = ( + invalid_ping_command_easter_egg_probability + ) + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_PING_COMMAND_EASTER_EGG_PROBABILITY_MESSAGE, + ): + RuntimeSettings._setup_ping_command_easter_egg_probability() + + +class TestSetupMessagesFile: + """Test case to unit-test all functions that use/relate to the messages JSON file.""" + + @pytest.mark.parametrize( + "raw_invalid_messages_file_path", + ("messages.json.invalid", " "), + ) + def test_get_messages_dict_with_invalid_messages_file_path( + self, raw_invalid_messages_file_path: str + ) -> None: + """Test that an error occurs when the provided `messages_file_path` is invalid.""" + INVALID_MESSAGES_FILE_PATH: Path = Path(raw_invalid_messages_file_path.strip()) + + with FileTemporaryDeleter(INVALID_MESSAGES_FILE_PATH): + INVALID_MESSAGES_FILE_PATH_MESSAGE: Final[str] = ( + "MESSAGES_FILE_PATH must be a path to a file that exists" + ) + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_MESSAGES_FILE_PATH_MESSAGE + ): + Settings._get_messages_dict(raw_invalid_messages_file_path) + + @pytest.mark.parametrize("test_messages_dict", ({"welcome_messages": ["Welcome!"]},)) + def test_get_messages_dict_with_no_messages_file_path( + self, test_messages_dict: "Mapping[str, object]" + ) -> None: + """Test that the default value is used when no `messages_file_path` is provided.""" + DEFAULT_MESSAGES_FILE_PATH: Path = config.PROJECT_ROOT / "messages.json" + + with FileTemporaryDeleter(DEFAULT_MESSAGES_FILE_PATH): + default_messages_file: TextIO + with DEFAULT_MESSAGES_FILE_PATH.open("w") as default_messages_file: + json.dump(test_messages_dict, fp=default_messages_file) + + assert ( + Settings._get_messages_dict(raw_messages_file_path=None) == test_messages_dict + ) + + DEFAULT_MESSAGES_FILE_PATH.unlink() + + @pytest.mark.parametrize("test_messages_dict", ({"welcome_messages": ["Welcome!"]},)) + def test_get_messages_dict_successful( + self, test_messages_dict: "Mapping[str, object]" + ) -> None: + """Test that the given path is used when a `messages_file_path` is provided.""" + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(test_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + assert ( + Settings._get_messages_dict( + raw_messages_file_path=temporary_messages_file.name, + ) + == test_messages_dict + ) + + assert ( + Settings._get_messages_dict( + raw_messages_file_path=f" {temporary_messages_file.name} ", + ) + == test_messages_dict + ) + + @pytest.mark.parametrize( + "invalid_messages_json", + ( + '{"welcome_messages": ["Welcome!"}', + "[]", + '["Welcome!"]', + '"Welcome!"', + "99", + "null", + "true", + "false", + ), + ) + def test_get_messages_dict_with_invalid_json(self, invalid_messages_json: str) -> None: + """Test that an error is raised when the messages-file contains invalid JSON.""" + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + temporary_messages_file.write(invalid_messages_json) + + temporary_messages_file.close() + + INVALID_MESSAGES_JSON_MESSAGE: Final[str] = ( + "Messages JSON file must contain a JSON string" + ) + with pytest.raises(ImproperlyConfiguredError, match=INVALID_MESSAGES_JSON_MESSAGE): + Settings._get_messages_dict( + raw_messages_file_path=temporary_messages_file.name, + ) + + @pytest.mark.parametrize("test_messages_dict", ({"welcome_messages": ["Welcome!"]},)) + def test_setup_welcome_messages_successful_with_messages_file_path( + self, test_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that correct welcome messages are loaded when `MESSAGES_FILE_PATH` is valid.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(test_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + RuntimeSettings._setup_welcome_messages() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["WELCOME_MESSAGES"] == set( + test_messages_dict["welcome_messages"], + ) + + @pytest.mark.parametrize("test_messages_dict", ({"welcome_messages": ["Welcome!"]},)) + def test_setup_welcome_messages_successful_with_no_messages_file_path( + self, test_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that correct welcome messages are loaded when no `MESSAGES_FILE_PATH` given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + DEFAULT_MESSAGES_FILE_PATH: Path = config.PROJECT_ROOT / "messages.json" + + with FileTemporaryDeleter(DEFAULT_MESSAGES_FILE_PATH): + default_messages_file: TextIO + with DEFAULT_MESSAGES_FILE_PATH.open("w") as default_messages_file: + json.dump(test_messages_dict, fp=default_messages_file) + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + RuntimeSettings._setup_welcome_messages() + + DEFAULT_MESSAGES_FILE_PATH.unlink() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["WELCOME_MESSAGES"] == set( + test_messages_dict["welcome_messages"], + ) + + @pytest.mark.parametrize("no_welcome_messages_dict", ({"other_messages": ["Welcome!"]},)) + def test_welcome_messages_key_not_in_messages_json( + self, no_welcome_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that error is raised when messages-file not contain `welcome_messages` key.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(no_welcome_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + exc_info: ExceptionInfo[MessagesJSONFileMissingKeyError] + with pytest.raises(MessagesJSONFileMissingKeyError) as exc_info: + RuntimeSettings._setup_welcome_messages() + + assert exc_info.value.missing_key == "welcome_messages" + + @pytest.mark.parametrize( + "invalid_welcome_messages_dict", + ( + {"welcome_messages": {}}, + {"welcome_messages": []}, + {"welcome_messages": ""}, + {"welcome_messages": 99}, + {"welcome_messages": None}, + {"welcome_messages": True}, + {"welcome_messages": False}, + ), + ) + def test_invalid_welcome_messages( + self, invalid_welcome_messages_dict: "Mapping[str, object]" + ) -> None: + """Test that error is raised when the `welcome_messages` is not a valid value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(invalid_welcome_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + exc_info: ExceptionInfo[MessagesJSONFileValueError] + with pytest.raises(MessagesJSONFileValueError) as exc_info: + RuntimeSettings._setup_welcome_messages() + + assert exc_info.value.dict_key == "welcome_messages" + assert ( + exc_info.value.invalid_value == invalid_welcome_messages_dict["welcome_messages"] + ) + + @pytest.mark.parametrize("test_messages_dict", ({"roles_messages": ["Gaming"]},)) + def test_setup_roles_messages_successful_with_messages_file_path( + self, test_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that correct roles messages are loaded when `MESSAGES_FILE_PATH` is valid.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(test_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + RuntimeSettings._setup_roles_messages() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["ROLES_MESSAGES"] == set( + test_messages_dict["roles_messages"], + ) + + @pytest.mark.parametrize("test_messages_dict", ({"roles_messages": ["Gaming"]},)) + def test_setup_roles_messages_successful_with_no_messages_file_path( + self, test_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that correct roles messages are loaded when no `MESSAGES_FILE_PATH` given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + DEFAULT_MESSAGES_FILE_PATH: Path = config.PROJECT_ROOT / "messages.json" + + with FileTemporaryDeleter(DEFAULT_MESSAGES_FILE_PATH): + default_messages_file: TextIO + with DEFAULT_MESSAGES_FILE_PATH.open("w") as default_messages_file: + json.dump(test_messages_dict, fp=default_messages_file) + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + RuntimeSettings._setup_roles_messages() + + DEFAULT_MESSAGES_FILE_PATH.unlink() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["ROLES_MESSAGES"] == set( + test_messages_dict["roles_messages"], + ) + + @pytest.mark.parametrize("no_roles_messages_dict", ({"other_messages": ["Gaming"]},)) + def test_roles_messages_key_not_in_messages_json( + self, no_roles_messages_dict: "Mapping[str, Iterable[str]]" + ) -> None: + """Test that error is raised when messages-file not contain `roles_messages` key.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(no_roles_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + exc_info: ExceptionInfo[MessagesJSONFileMissingKeyError] + with pytest.raises(MessagesJSONFileMissingKeyError) as exc_info: + RuntimeSettings._setup_roles_messages() + + assert exc_info.value.missing_key == "roles_messages" + + @pytest.mark.parametrize( + "invalid_roles_messages_dict", + ( + {"roles_messages": {}}, + {"roles_messages": []}, + {"roles_messages": ""}, + {"roles_messages": 99}, + {"roles_messages": None}, + {"roles_messages": True}, + {"roles_messages": False}, + ), + ) + def test_invalid_roles_messages( + self, invalid_roles_messages_dict: "Mapping[str, object]" + ) -> None: + """Test that error is raised when the `roles_messages` is not a valid value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + temporary_messages_file: IO[str] + with NamedTemporaryFile(mode="w", delete_on_close=False) as temporary_messages_file: + json.dump(invalid_roles_messages_dict, fp=temporary_messages_file) + + temporary_messages_file.close() + + with EnvVariableDeleter("MESSAGES_FILE_PATH"): + os.environ["MESSAGES_FILE_PATH"] = temporary_messages_file.name + + exc_info: ExceptionInfo[MessagesJSONFileValueError] + with pytest.raises(MessagesJSONFileValueError) as exc_info: + RuntimeSettings._setup_roles_messages() + + assert exc_info.value.dict_key == "roles_messages" + assert exc_info.value.invalid_value == invalid_roles_messages_dict["roles_messages"] + + +class TestSetupSUPlatformAccessCookie: + """Test case to unit-test the `_setup_su_platform_access_cookie()` function.""" + + @pytest.mark.parametrize( + "test_su_platform_access_cookie", + ( + "".join(random.choices(string.hexdigits, k=random.randint(128, 256))), + f" {''.join(random.choices(string.hexdigits, k=random.randint(128, 256)))} ", + ), + ids=[f"case_{i}" for i in range(2)], + ) + def test_setup_members_list_auth_session_cookie_successful( + self, test_su_platform_access_cookie: str + ) -> None: + """ + Test that the given `test_su_platform_access_cookie` is used when provided. + + In this test, the provided `test_su_platform_access_cookie` is valid + and so must be saved successfully. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SU_PLATFORM_ACCESS_COOKIE"): + os.environ["SU_PLATFORM_ACCESS_COOKIE"] = test_su_platform_access_cookie + + RuntimeSettings._setup_su_platform_access_cookie() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SU_PLATFORM_ACCESS_COOKIE"] == ( + test_su_platform_access_cookie.strip() + ) + + def test_missing_su_platform_access_cookie(self) -> None: + """Test that an error is raised when no `SU_PLATFORM_ACCESS_COOKIE` is given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with ( + EnvVariableDeleter("SU_PLATFORM_ACCESS_COOKIE"), + pytest.raises( + ImproperlyConfiguredError, + match=r"SU_PLATFORM_ACCESS_COOKIE.*valid.*\.ASPXAUTH cookie", + ), + ): + RuntimeSettings._setup_su_platform_access_cookie() + + @pytest.mark.parametrize( + "invalid_su_platform_access_cookie", + ( + "invalid_su_platform_access_cookie", + "", + " ", + "".join(random.choices(string.hexdigits, k=5)), + "".join(random.choices(string.hexdigits, k=500)), + ( + f"{''.join(random.choices(string.hexdigits, k=64))}>{ + ''.join(random.choices(string.hexdigits, k=64)) + }" + ), + ), + ids=[f"case_{i}" for i in range(6)], + ) + def test_invalid_su_platform_access_cookie( + self, invalid_su_platform_access_cookie: str + ) -> None: + """Test that an error occurs when `SU_PLATFORM_ACCESS_COOKIE` is invalid.""" + INVALID_MEMBERS_LIST_URL_SESSION_COOKIE_MESSAGE: Final[str] = ( + "SU_PLATFORM_ACCESS_COOKIE must be a valid .ASPXAUTH cookie" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SU_PLATFORM_ACCESS_COOKIE"): + os.environ["SU_PLATFORM_ACCESS_COOKIE"] = invalid_su_platform_access_cookie + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_MEMBERS_LIST_URL_SESSION_COOKIE_MESSAGE, + ): + RuntimeSettings._setup_su_platform_access_cookie() + + +class TestSetupAutoSUPlatformAccessCookieChecking: + """Test cases for `_setup_auto_su_platform_access_cookie_checking()` function.""" + + @pytest.mark.parametrize("true_value", config.TRUE_VALUES) + def test_setup_auto_su_platform_access_cookie_checking_checking_true( + self, true_value: str + ) -> None: + """Test `AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING` is True with positive value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"): + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = true_value + + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] + + @pytest.mark.parametrize("false_value", config.FALSE_VALUES) + def test_setup_auto_su_platform_access_cookie_checking_false( + self, false_value: str + ) -> None: + """Test `AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING` is False with negative value.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"): + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = false_value + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] + + def test_missing_auto_setup_su_platform_access_cookie_checking(self) -> None: + """Test that default is used with no `AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] + + @pytest.mark.parametrize( + "invalid_value", ("invalid_value", "", " ", "5543154", "beeop bopp") + ) + def test_invalid_auto_setup_su_platform_access_cookie_checking( + self, invalid_value: str + ) -> None: + """Test that an exception is raised when an invalid value is given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"): + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = invalid_value + + with pytest.raises( + ImproperlyConfiguredError, + match=r"AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING must be a boolean value", + ): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + def test_invalid_setup_order_auto_su_platform_access_cookie_checking_interval( + self, + ) -> None: + """Test that an error is raised when the interval is configured before the toggle.""" + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: " + "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING must be set up " + "before AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL can be set up." + ) + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with ( + EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"), + pytest.raises(RuntimeError, match=INVALID_SETUP_ORDER_MESSAGE), + ): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking_interval() + + def test_disabled_su_platform_access_cookie_checking_interval(self) -> None: + """Test that a default value is used when checking is disabled.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking_interval() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] == { + "hours": 24 + } + + def test_default_setup_auto_su_platform_access_cookie_checking_interval(self) -> None: + """Test that the default value is used when no interval is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = "True" + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking_interval() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] == { + "hours": 24 + } + + def test_too_short_auto_su_platform_access_cookie_checking_interval(self) -> None: + """Test that an error is raised when the interval is too short.""" + TOO_SMALL_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE: Final[str] = ( + "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL must be greater than 3 seconds." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = "True" + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"): + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] = "2s" + + with pytest.raises( + ImproperlyConfiguredError, + match=TOO_SMALL_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE, + ): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking_interval() + + @pytest.mark.parametrize( + "invalid_interval", + ( + "not a interval", + "50 years", + "10 seconds", + "what is love?", + "baby don't hurt me", + "don't hurt me", + "no more", + ":joy:", + ), + ) + def test_invalid_auto_su_platform_access_cookie_checking_interval( + self, invalid_interval: str + ) -> None: + """Test that an error is raised when the interval is invalid.""" + INVALID_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE: Final[str] = ( + "AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL must contain the delay " + "in any combination of seconds, minutes, hours, days or weeks." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING"] = "True" + RuntimeSettings._setup_auto_su_platform_access_cookie_checking() + + with EnvVariableDeleter("AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"): + os.environ["AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL"] = invalid_interval + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_AUTO_SU_PLATFORM_ACCESS_COOKIE_CHECKING_INTERVAL_MESSAGE, + ): + RuntimeSettings._setup_auto_su_platform_access_cookie_checking_interval() + + +class TestSetupSendIntroductionReminders: + """Test case to unit-test the configuration for sending introduction reminders.""" + + @pytest.mark.parametrize( + "test_send_introduction_reminders_value", + list( + set( + itertools.chain( + config.VALID_SEND_INTRODUCTION_REMINDERS_VALUES, + ( + f" { + next( + iter( + value + for value in config.VALID_SEND_INTRODUCTION_REMINDERS_VALUES + if value.isalpha() + ) + ) + } ", # noqa: E501 + next( + iter( + value + for value in config.VALID_SEND_INTRODUCTION_REMINDERS_VALUES + if value.isalpha() + ), + ).lower(), + next( + iter( + value + for value in config.VALID_SEND_INTRODUCTION_REMINDERS_VALUES + if value.isalpha() + ), + ).upper(), + "".join( + random.choice((str.upper, str.lower))(character) + for character in next( + iter( + value + for value in config.VALID_SEND_INTRODUCTION_REMINDERS_VALUES # noqa: E501 + if value.isalpha() + ), + ) + ), + ), + ), + ) + )[:14], + ids=[f"case_{i}" for i in range(14)], + ) + def test_setup_send_introduction_reminders_successful( + self, test_send_introduction_reminders_value: str + ) -> None: + """Test that setup is successful when a valid option is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = test_send_introduction_reminders_value + + RuntimeSettings._setup_send_introduction_reminders() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS"] == ( + "once" + if test_send_introduction_reminders_value.lower().strip() in config.TRUE_VALUES + else ( + False + if test_send_introduction_reminders_value.lower().strip() + not in ( + "once", + "interval", + ) + else test_send_introduction_reminders_value.lower().strip() + ) + ) + + def test_default_send_introduction_reminders_value(self) -> None: + """Test that a default value is used when no introduction-reminders-flag is given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + RuntimeSettings._setup_send_introduction_reminders() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS"] in ("once", "interval", False) + + @pytest.mark.parametrize( + "invalid_send_introduction_reminders_value", + ( + "invalid_send_introduction_reminders_value", + "", + " ", + "".join( + random.choices(string.ascii_letters + string.digits + string.punctuation, k=8), + ), + ), + ids=[f"case_{i}" for i in range(4)], + ) + def test_invalid_send_introduction_reminders( + self, invalid_send_introduction_reminders_value: str + ) -> None: + """Test that an error occurs when an invalid introduction-reminders-flag is given.""" + INVALID_SEND_INTRODUCTION_REMINDERS_VALUE_MESSAGE: Final[str] = ( + 'SEND_INTRODUCTION_REMINDERS must be one of: "Once", "Interval" or "False"' + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = ( + invalid_send_introduction_reminders_value + ) + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_SEND_INTRODUCTION_REMINDERS_VALUE_MESSAGE, + ): + RuntimeSettings._setup_send_introduction_reminders() + + +class TestSetupSendIntroductionRemindersInterval: + """Test case to unit-test the `_setup_send_introduction_reminders_interval()` function.""" + + @pytest.mark.parametrize( + "test_send_introduction_reminders_interval", + ( + f"{random.randint(3, 999)}s", + f"{random.randint(3, 999)}.{random.randint(0, 999)}s", + ( + f" {random.randint(3, 999)}{ + random.choice(('', f'.{random.randint(0, 999)}')) + }s " + ), + f"{random.randint(1, 999)}{random.choice(('', f'.{random.randint(0, 999)}'))}m", + f"{random.randint(1, 999)}{random.choice(('', f'.{random.randint(0, 999)}'))}h", + ( + f"{random.randint(3, 999)}{random.choice(('', f'.{random.randint(0, 999)}'))}s{ + random.randint(0, 999) + }{random.choice(('', f'.{random.randint(0, 999)}'))}m{random.randint(0, 999)}{ + random.choice(('', f'.{random.randint(0, 999)}')) + }h" + ), + ( + f"{random.randint(3, 999)}{ + random.choice(('', f'.{random.randint(0, 999)}')) + } s {random.randint(0, 999)}{ + random.choice(('', f'.{random.randint(0, 999)}')) + } m {random.randint(0, 999)}{ + random.choice(('', f'.{random.randint(0, 999)}')) + } h" + ), + ), + ids=[f"case_{i}" for i in range(7)], + ) + def test_setup_send_introduction_reminders_interval_successful( + self, test_send_introduction_reminders_interval: str + ) -> None: + """ + Test that the given `SEND_INTRODUCTION_REMINDERS_INTERVAL` is used when provided. + + In this test, the provided `SEND_INTRODUCTION_REMINDERS_INTERVAL` is valid + and so must be saved successfully. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_introduction_reminders() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"), + ): + os.environ["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( + test_send_introduction_reminders_interval + ) + + RuntimeSettings._setup_send_introduction_reminders_interval() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] == { + key: float(value) + for key, value in ( + re.match( + r"\A(?:(?P(?:\d*\.)?\d+)\s*s)?\s*(?:(?P(?:\d*\.)?\d+)\s*m)?\s*(?:(?P(?:\d*\.)?\d+)\s*h)?\Z", + test_send_introduction_reminders_interval.lower().strip(), + ) + .groupdict() # type: ignore[union-attr] + .items() + ) + if value + } + + assert ( + "seconds" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + or "minutes" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + or "hours" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + ) + + assert all( + isinstance(value, float | int) + for value in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"].values() + ) + + timedelta_error: TypeError + try: + assert timedelta( + **RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + ) > timedelta(seconds=3) + + except TypeError as timedelta_error: + if "invalid keyword argument for __new__()" not in str(timedelta_error): + raise timedelta_error from timedelta_error + + pytest.fail( + ( + "Failed to construct `timedelta` object " + "from given `SEND_INTRODUCTION_REMINDERS_INTERVAL`" + ), + pytrace=False, + ) + + def test_default_send_introduction_reminders_interval(self) -> None: + """Test that a default value is used when no `SEND_INTRODUCTION_REMINDERS_INTERVAL`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_introduction_reminders() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"): + RuntimeSettings._setup_send_introduction_reminders_interval() + + RuntimeSettings._is_env_variables_setup = True + + assert ( + "seconds" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + or "minutes" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + or "hours" in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + ) + + assert all( + isinstance(value, float | int) + for value in RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"].values() + ) + + timedelta_error: TypeError + try: + assert timedelta( + **RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_INTERVAL"] + ) > timedelta(seconds=3) + + except TypeError as timedelta_error: + if "invalid keyword argument for __new__()" not in str(timedelta_error): + raise timedelta_error from timedelta_error + + pytest.fail( + ( + "Failed to construct `timedelta` object " + "from given `SEND_INTRODUCTION_REMINDERS_INTERVAL`" + ), + pytrace=False, + ) + + def test_setup_send_introduction_reminders_interval_without_send_introduction_reminders_setup( # noqa: E501 + self, + ) -> None: + """Test that an error is raised when setting up the interval without the flag.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._settings.pop("SEND_INTRODUCTION_REMINDERS", None) + + with pytest.raises(RuntimeError, match="Invalid setup order"): + RuntimeSettings._setup_send_introduction_reminders_interval() + + @pytest.mark.parametrize( + "invalid_send_introduction_reminders_interval", + ( + "invalid_send_introduction_reminders_interval", + "", + " ", + f"{random.randint(1, 999)}d", + f"{random.randint(3, 999)},{random.randint(0, 999)}s", + ), + ids=[f"case_{i}" for i in range(5)], + ) + def test_invalid_send_introduction_reminders_interval_flag_disabled( + self, invalid_send_introduction_reminders_interval: str + ) -> None: + """ + Test that no error is raised when `SEND_INTRODUCTION_REMINDERS_INTERVAL` is invalid. + + The enable/disable flag `SEND_INTRODUCTION_REMINDERS` is disabled (set to `False`) + during this test, so an invalid interval value should be ignored. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"): + os.environ["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( + invalid_send_introduction_reminders_interval + ) + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = "false" + RuntimeSettings._setup_send_introduction_reminders() + RuntimeSettings._setup_send_introduction_reminders_interval() + + @pytest.mark.parametrize( + "invalid_send_introduction_reminders_interval", + ( + "invalid_send_introduction_reminders_interval", + f"{random.randint(1, 999)}d", + f"{random.randint(3, 999)},{random.randint(0, 999)}s", + ), + ids=[f"case_{i}" for i in range(3)], + ) + def test_invalid_send_introduction_reminders_interval_flag_enabled( + self, + invalid_send_introduction_reminders_interval: str, + ) -> None: + """ + Test that an error is raised when `SEND_INTRODUCTION_REMINDERS_INTERVAL` is invalid. + + The enable/disable flag `SEND_INTRODUCTION_REMINDERS` is enabled + (set to `once` or `interval`) during this test, + so an invalid interval value should not be ignored, and an error should be raised. + """ + INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( + "SEND_INTRODUCTION_REMINDERS_INTERVAL must contain the interval " + "in any combination of seconds, minutes or hours" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"): + os.environ["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( + invalid_send_introduction_reminders_interval + ) + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = random.choice(("once", "interval")) + RuntimeSettings._setup_send_introduction_reminders() + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE, + ): + RuntimeSettings._setup_send_introduction_reminders_interval() + + @pytest.mark.parametrize( + "too_small_send_introduction_reminders", + ("0.5s", "0s", "0.03m", "0m", "0.0005h", "0h"), + ) + def test_too_small_send_introduction_reminders_interval_flag_disabled( + self, too_small_send_introduction_reminders: str + ) -> None: + """ + Test that no error is raised when `SEND_INTRODUCTION_REMINDERS_INTERVAL` is too small. + + The enable/disable flag `SEND_INTRODUCTION_REMINDERS` is disabled (set to `False`) + during this test, so an invalid interval value should be ignored. + """ + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"): + os.environ["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( + too_small_send_introduction_reminders + ) + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = "false" + RuntimeSettings._setup_send_introduction_reminders() + + RuntimeSettings._setup_send_introduction_reminders_interval() + + @pytest.mark.parametrize( + "too_small_send_introduction_reminders", + ("0.5s", "0s", "0.03m", "0m", "0.0005h", "0h"), + ) + def test_too_small_send_introduction_reminders_interval_flag_enabled( + self, + too_small_send_introduction_reminders: str, + ) -> None: + """ + Test that an error is raised when `SEND_INTRODUCTION_REMINDERS_INTERVAL` is too small. + + The enable/disable flag `SEND_INTRODUCTION_REMINDERS` is enabled + (set to `once` or `interval`) during this test, + so an invalid interval value should not be ignored, and an error should be raised. + """ + TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( + "SEND_INTRODUCTION_REMINDERS_INTERVAL must be longer than 3 seconds." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"): + os.environ["SEND_INTRODUCTION_REMINDERS_INTERVAL"] = ( + too_small_send_introduction_reminders + ) + + with EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"): + os.environ["SEND_INTRODUCTION_REMINDERS"] = random.choice(("once", "interval")) + RuntimeSettings._setup_send_introduction_reminders() + + with pytest.raises( + ImproperlyConfiguredError, + match=TOO_SMALL_SEND_INTRODUCTION_REMINDERS_INTERVAL_MESSAGE, + ): + RuntimeSettings._setup_send_introduction_reminders_interval() + + +class TestSetupSendIntroductionRemindersDelay: + """Test case to unit-test the `_setup_send_introduction_reminders_delay()` function.""" + + def test_setup_send_introduction_reminders_delay_without_send_introduction_reminders_setup( + self, + ) -> None: + """Test that an error is raised when setting up the delay without the flag.""" + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: SEND_INTRODUCTION_REMINDERS must be set up " + "before SEND_INTRODUCTION_REMINDERS_DELAY can be set up." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + pytest.raises(RuntimeError, match=INVALID_SETUP_ORDER_MESSAGE), + ): + RuntimeSettings._setup_send_introduction_reminders_delay() + + def test_default_send_introduction_reminders_delay(self) -> None: + """Test that a default value is used when no `SEND_INTRODUCTION_REMINDERS_DELAY`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_introduction_reminders() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"), + ): + assert os.environ.get("SEND_INTRODUCTION_REMINDERS_DELAY") is None + RuntimeSettings._setup_send_introduction_reminders_delay() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS"] == "once" + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_DELAY"] == timedelta(hours=40) + + @pytest.mark.parametrize( + "too_short_introduction_reminders_delay", + ("5h", "2h", "1h", "30m", "10m", "5m", "3s", "1s", "0.5s", "0s"), + ) + def test_too_short_introduction_reminders_delay( + self, too_short_introduction_reminders_delay: str + ) -> None: + """Test that an error is raised when `SEND_INTRODUCTION_REMINDERS_DELAY` too short.""" + TOO_SHORT_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( + "SEND_INTRODUCTION_REMINDERS_DELAY must be longer than or equal to 1 day." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_introduction_reminders() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"), + ): + os.environ["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( + too_short_introduction_reminders_delay + ) + + with pytest.raises( + ImproperlyConfiguredError, + match=TOO_SHORT_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, + ): + RuntimeSettings._setup_send_introduction_reminders_delay() + + @pytest.mark.parametrize( + "test_invalid_introduction_reminders_delay", + ("invalid_introduction_reminders_delay", "3.5", "3.5f", "3.5a"), + ) + def test_invalid_introduction_reminders_delay( + self, test_invalid_introduction_reminders_delay: str + ) -> None: + """Test that an error is raised when `SEND_INTRODUCTION_REMINDERS_DELAY` is invalid.""" + INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE: Final[str] = ( + "SEND_INTRODUCTION_REMINDERS_DELAY must contain the delay " + "in any combination of seconds, minutes, hours, days or weeks." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._setup_send_introduction_reminders() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"), + ): + os.environ["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( + test_invalid_introduction_reminders_delay + ) + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_SEND_INTRODUCTION_REMINDERS_DELAY_MESSAGE, + ): + RuntimeSettings._setup_send_introduction_reminders_delay() + + @pytest.mark.parametrize( + "test_send_introduction_reminders_delay", ("48h", "40h", "24h", "1d", "2d", "3d") + ) + def test_setup_introduction_reminders_delay_successful( + self, test_send_introduction_reminders_delay: str + ) -> None: + """Test that the given `SEND_INTRODUCTION_REMINDERS_DELAY` is used when provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_introduction_reminders() + + with ( + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_DELAY"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS"), + EnvVariableDeleter("SEND_INTRODUCTION_REMINDERS_INTERVAL"), + ): + os.environ["SEND_INTRODUCTION_REMINDERS_DELAY"] = ( + test_send_introduction_reminders_delay + ) + + RuntimeSettings._setup_send_introduction_reminders_delay() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_INTRODUCTION_REMINDERS_DELAY"] == timedelta( + **{ + key: float(value) + for key, value in ( + re.fullmatch( + r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", + test_send_introduction_reminders_delay.lower().strip(), + ) + ) + .groupdict() # type: ignore[union-attr] + .items() + if value + }, + ) + + +class TestSetupSendGetRolesReminders: + """Test case to unit-test the configuration for sending get-roles reminders.""" + + @pytest.mark.parametrize( + "test_send_get_roles_reminder_value", + list( + set( + itertools.chain( + config.TRUE_VALUES | config.FALSE_VALUES, + ( + f" { + next( + iter( + value + for value in config.TRUE_VALUES | config.FALSE_VALUES + if value.isalpha() + ) + ) + } ", + next( + iter( + value + for value in config.TRUE_VALUES | config.FALSE_VALUES + if value.isalpha() + ), + ).lower(), + next( + iter( + value + for value in config.TRUE_VALUES | config.FALSE_VALUES + if value.isalpha() + ), + ).upper(), + "".join( + random.choice((str.upper, str.lower))(character) + for character in next( + iter( + value + for value in config.TRUE_VALUES | config.FALSE_VALUES + if value.isalpha() + ), + ) + ), + ), + ), + ) + )[:14], + ids=[f"case_{i}" for i in range(14)], + ) + def test_setup_send_get_roles_reminders_successful( + self, test_send_get_roles_reminder_value: str + ) -> None: + """Test that setup is successful when a valid option is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS"): + os.environ["SEND_GET_ROLES_REMINDERS"] = test_send_get_roles_reminder_value + + RuntimeSettings._setup_send_get_roles_reminders() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_GET_ROLES_REMINDERS"] == ( + test_send_get_roles_reminder_value.lower().strip() in config.TRUE_VALUES + ) + + def test_default_send_get_roles_reminders_value(self) -> None: + """Test that a default value is used when no get-roles-reminders-flag is given.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS"): + RuntimeSettings._setup_send_get_roles_reminders() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_GET_ROLES_REMINDERS"] in (True, False) + + @pytest.mark.parametrize( + "invalid_send_get_role_reminders_value", + ( + "invalid_send_get_role_reminders_value", + "", + " ", + "".join( + random.choices(string.ascii_letters + string.digits + string.punctuation, k=8), + ), + ), + ids=[f"case_{i}" for i in range(4)], + ) + def test_invalid_send_get_roles_reminders( + self, invalid_send_get_role_reminders_value: str + ) -> None: + """Test that an error occurs when an invalid get-roles-reminders-flag is given.""" + INVALID_SEND_GET_ROLES_REMINDERS_VALUE_MESSAGE: Final[str] = ( + "SEND_GET_ROLES_REMINDERS must be a boolean value" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS"): + os.environ["SEND_GET_ROLES_REMINDERS"] = invalid_send_get_role_reminders_value + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_SEND_GET_ROLES_REMINDERS_VALUE_MESSAGE + ): + RuntimeSettings._setup_send_get_roles_reminders() + + +class TestSetupSendGetRolesRemindersInterval: + """Test case to unit-test the `_setup_advanced_send_get_roles_reminders_interval()` function.""" # noqa: E501, W505 + + def test_setup_interval_without_send_roles_reminders_setup(self) -> None: + """Test that an error is raised when setting up the interval without the flag.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " + "before ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL can be set up." + ) + + with ( + EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_INTERVAL"), + EnvVariableDeleter("SEND_GET_ROLES_REMINDERS"), + pytest.raises(RuntimeError, match=INVALID_SETUP_ORDER_MESSAGE), + ): + RuntimeSettings._setup_advanced_send_get_roles_reminders_interval() + + def test_default_send_get_roles_reminders_interval(self) -> None: + """Test that a default value is used when no `SEND_GET_ROLES_REMINDERS_INTERVAL`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_INTERVAL"): + RuntimeSettings._setup_advanced_send_get_roles_reminders_interval() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"] == {"hours": 24} + + @pytest.mark.parametrize( + "test_invalid_send_get_roles_reminders_interval", + ("obviously not a valid interval", "3.5", "3.5f", "3.5a"), + ) + def test_invalid_send_get_roles_reminders_interval( + self, test_invalid_send_get_roles_reminders_interval: str + ) -> None: + """Test that an error is raised when an invalid interval is provided.""" + INVALID_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE: Final[str] = ( + "ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL must contain the interval " + "in any combination of seconds, minutes or hours" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + os.environ["SEND_GET_ROLES_REMINDERS"] = "True" + + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"): + os.environ["ADVANCED_SEND_GET_ROLES_REMINDERS_INTERVAL"] = ( + test_invalid_send_get_roles_reminders_interval + ) + + with pytest.raises( + ImproperlyConfiguredError, + match=INVALID_SEND_GET_ROLES_REMINDERS_INTERVAL_MESSAGE, + ): + RuntimeSettings._setup_advanced_send_get_roles_reminders_interval() + + +class TestSetupSendGetRolesRemindersDelay: + """Test case to unit-test the `_setup_advanced_send_get_roles_reminders_delay()` function.""" # noqa: E501, W505 + + def test_setup_delay_without_send_roles_reminders_setup(self) -> None: + """Test that an error is raised when setting up the delay without the flag.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + INVALID_SETUP_ORDER_MESSAGE: Final[str] = ( + "Invalid setup order: SEND_GET_ROLES_REMINDERS must be set up " + "before SEND_GET_ROLES_REMINDERS_DELAY can be set up." + ) + + with ( + EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_INTERVAL"), + EnvVariableDeleter("SEND_GET_ROLES_REMINDERS"), + pytest.raises(RuntimeError, match=INVALID_SETUP_ORDER_MESSAGE), + ): + RuntimeSettings._setup_send_get_roles_reminders_delay() + + def test_default_send_get_roles_reminders_delay(self) -> None: + """Test that a default value is used when no `SEND_GET_ROLES_REMINDERS_DELAY`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_DELAY"): + RuntimeSettings._setup_send_get_roles_reminders_delay() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_GET_ROLES_REMINDERS_DELAY"] == timedelta(hours=40) + + @pytest.mark.parametrize( + "too_short_get_roles_reminders_delay", + ("0.5s", "0s", "0.03m", "0m", "0.0005h", "0h", "0.9d"), + ) + def test_too_short_send_get_roles_reminders_delay( + self, too_short_get_roles_reminders_delay: str + ) -> None: + """Test that an error is thrown if `SEND_GET_ROLES_REMINDERS_DELAY` is too short.""" + TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( + "SEND_GET_ROLES_REMINDERS_DELAY must be longer than or equal to 1 day." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_DELAY"): + os.environ["SEND_GET_ROLES_REMINDERS_DELAY"] = too_short_get_roles_reminders_delay + + with pytest.raises( + ImproperlyConfiguredError, + match=TOO_SMALL_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE, + ): + RuntimeSettings._setup_send_get_roles_reminders_delay() + + @pytest.mark.parametrize( + "invalid_send_get_roles_reminders_delay", + ("invalid_send_get_roles_reminders_delay", "3.5", "3.5f", "3.5a"), + ) + def test_invalid_send_get_roles_reminders_delay( + self, invalid_send_get_roles_reminders_delay: str + ) -> None: + """Test that an error is raised when an invalid delay is provided.""" + INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE: Final[str] = ( + "SEND_GET_ROLES_REMINDERS_DELAY must contain the delay " + "in any combination of seconds, minutes, hours, days or weeks." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_DELAY"): + os.environ["SEND_GET_ROLES_REMINDERS_DELAY"] = ( + invalid_send_get_roles_reminders_delay + ) + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_SEND_GET_ROLES_REMINDERS_DELAY_MESSAGE + ): + RuntimeSettings._setup_send_get_roles_reminders_delay() + + @pytest.mark.parametrize( + "test_send_get_roles_reminders_delay", ("48h", "40h", "24h", "1d", "2d", "3d") + ) + def test_setup_send_get_roles_reminders_delay_successful( + self, test_send_get_roles_reminders_delay: str + ) -> None: + """Test that the given `SEND_GET_ROLES_REMINDERS_DELAY` is used when provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + RuntimeSettings._setup_send_get_roles_reminders() + + with EnvVariableDeleter("SEND_GET_ROLES_REMINDERS_DELAY"): + os.environ["SEND_GET_ROLES_REMINDERS_DELAY"] = test_send_get_roles_reminders_delay + + RuntimeSettings._setup_send_get_roles_reminders_delay() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["SEND_GET_ROLES_REMINDERS_DELAY"] == timedelta( + **{ + key: float(value) + for key, value in ( + re.fullmatch( + r"\A(?:(?P(?:\d*\.)?\d+)s)?(?:(?P(?:\d*\.)?\d+)m)?(?:(?P(?:\d*\.)?\d+)h)?(?:(?P(?:\d*\.)?\d+)d)?(?:(?P(?:\d*\.)?\d+)w)?\Z", + test_send_get_roles_reminders_delay.lower().strip(), + ) + ) + .groupdict() # type: ignore[union-attr] + .items() + if value + }, + ) + + +class TestSetupStatisticsDays: + """Test case to unit-test the `_setup_statistics_days()` function.""" + + @pytest.mark.parametrize("test_statistics_days", ("5", "3.55", "664", " 5 ")) + def test_setup_statistics_days_successful(self, test_statistics_days: str) -> None: + """Test that the given valid `STATISTICS_DAYS` is used when one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_DAYS"): + os.environ["STATISTICS_DAYS"] = test_statistics_days + + RuntimeSettings._setup_statistics_days() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["STATISTICS_DAYS"] == timedelta( + days=float(test_statistics_days.strip()), + ) + + def test_default_statistics_days(self) -> None: + """Test that a default value is used when no `STATISTICS_DAYS` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_DAYS"): + RuntimeSettings._setup_statistics_days() + + RuntimeSettings._is_env_variables_setup = True + + assert isinstance(RuntimeSettings()["STATISTICS_DAYS"], timedelta) + + assert RuntimeSettings()["STATISTICS_DAYS"] > timedelta(days=1) + + @pytest.mark.parametrize( + "invalid_statistics_days", + ( + "invalid_statistics_days", + "", + " ", + "".join( + random.choices( + string.ascii_letters + string.digits + string.punctuation, + k=18, + ), + ), + ), + ids=[f"case_{i}" for i in range(4)], + ) + def test_invalid_statistics_days(self, invalid_statistics_days: str) -> None: + """Test that an error is raised when an invalid `STATISTICS_DAYS` is provided.""" + INVALID_STATISTICS_DAYS_MESSAGE: Final[str] = ( + "STATISTICS_DAYS must contain the statistics period in days" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_DAYS"): + os.environ["STATISTICS_DAYS"] = invalid_statistics_days + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_STATISTICS_DAYS_MESSAGE + ): + RuntimeSettings._setup_statistics_days() + + @pytest.mark.parametrize( + "too_small_statistics_days", + ("-15", "-2.3", "-0.02", "0", "0.40"), + ) + def test_too_small_statistics_days(self, too_small_statistics_days: str) -> None: + """Test that an error is raised when a too small `STATISTICS_DAYS` is provided.""" + TOO_SMALL_STATISTICS_DAYS_MESSAGE: Final[str] = ( + "STATISTICS_DAYS cannot be less than 1 day" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_DAYS"): + os.environ["STATISTICS_DAYS"] = too_small_statistics_days + + with pytest.raises( + ImproperlyConfiguredError, match=TOO_SMALL_STATISTICS_DAYS_MESSAGE + ): + RuntimeSettings._setup_statistics_days() + + +class TestSetupStatisticsRoles: + """Test case to unit-test the `_setup_statistics_roles()` function.""" + + @pytest.mark.parametrize( + "test_statistics_roles", + ( + "Guest", + "Guest,Member", + "Guest,Member,Admin", + " Guest,Member,Admin ", + " Guest , Member ,Admin ", + ), + ) + def test_setup_statistics_roles_successful(self, test_statistics_roles: str) -> None: + """Test that the given valid `STATISTICS_ROLES` is used when they are provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_ROLES"): + os.environ["STATISTICS_ROLES"] = test_statistics_roles + + RuntimeSettings._setup_statistics_roles() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["STATISTICS_ROLES"] == { + test_statistics_role.strip() + for test_statistics_role in test_statistics_roles.strip().split(",") + if test_statistics_role.strip() + } + + def test_default_statistics_roles(self) -> None: + """Test that default values are used when no `STATISTICS_ROLES` are provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("STATISTICS_ROLES"): + RuntimeSettings._setup_statistics_roles() + + RuntimeSettings._is_env_variables_setup = True + + assert isinstance(RuntimeSettings()["STATISTICS_ROLES"], Iterable) + + assert bool(RuntimeSettings()["STATISTICS_ROLES"]) + + assert all( + isinstance(statistics_role, str) and bool(statistics_role) + for statistics_role in RuntimeSettings()["STATISTICS_ROLES"] + ) + + +class TestSetupMembershipDependentRoles: + """Test case to unit-test the `_setup_membership_dependent_roles()` function.""" + + @pytest.mark.parametrize( + "test_membership_dependent_roles", + ( + "Guest", + "Guest,Member", + "Guest,Member,Admin", + " Guest,Member,Admin ", + " Guest , Member ,Admin ", + ), + ) + def test_setup_membership_dependent_roles_successful( + self, test_membership_dependent_roles: str + ) -> None: + """Test that the given valid `MEMBERSHIP_DEPENDENT_ROLES` are used when provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_DEPENDENT_ROLES"): + os.environ["MEMBERSHIP_DEPENDENT_ROLES"] = test_membership_dependent_roles + + RuntimeSettings._setup_membership_dependent_roles() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["MEMBERSHIP_DEPENDENT_ROLES"] == { + membership_dependent_role.strip() + for membership_dependent_role in test_membership_dependent_roles.strip().split(",") + if membership_dependent_role.strip() + } + + def test_default_membership_dependent_roles(self) -> None: + """Test an empty set is used when no `MEMBERSHIP_DEPENDENT_ROLES` are provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MEMBERSHIP_DEPENDENT_ROLES"): + RuntimeSettings._setup_membership_dependent_roles() + + RuntimeSettings._is_env_variables_setup = True + + assert isinstance(RuntimeSettings()["MEMBERSHIP_DEPENDENT_ROLES"], Iterable) + + assert not bool(RuntimeSettings()["MEMBERSHIP_DEPENDENT_ROLES"]) + + +class TestSetupModerationDocumentURL: + """Test case to unit-test the `_setup_moderation_document_url()` function.""" + + @pytest.mark.parametrize( + "test_moderation_document_url", + ("https://google.com", "www.google.com/", " https://google.com "), + ) + def test_setup_moderation_document_url_successful( + self, test_moderation_document_url: str + ) -> None: + """Test that the given valid `MODERATION_DOCUMENT_URL` is used when one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MODERATION_DOCUMENT_URL"): + os.environ["MODERATION_DOCUMENT_URL"] = test_moderation_document_url + + RuntimeSettings._setup_moderation_document_url() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["MODERATION_DOCUMENT_URL"] == ( + f"https://{test_moderation_document_url.strip()}" + if "://" not in test_moderation_document_url.strip() + else test_moderation_document_url.strip() + ) + + def test_missing_moderation_document_url(self) -> None: + """Test that an error is raised when no `MODERATION_DOCUMENT_URL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MODERATION_DOCUMENT_URL"): # noqa: SIM117 + with pytest.raises( + ImproperlyConfiguredError, match=r"MODERATION_DOCUMENT_URL.*valid.*URL" + ): + RuntimeSettings._setup_moderation_document_url() + + def test_invalid_protocol_moderation_document_url(self) -> None: + """Test that an error occurs when `MODERATION_DOCUMENT_URL` is not https.""" + INVALID_MODERATION_DOCUMENT_URL: Final[str] = ( + "Only HTTPS is supported as a protocol for MODERATION_DOCUMENT_URL." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MODERATION_DOCUMENT_URL"): + os.environ["MODERATION_DOCUMENT_URL"] = "ftp://google.com" + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_MODERATION_DOCUMENT_URL + ): + RuntimeSettings._setup_moderation_document_url() + + @pytest.mark.parametrize( + "invalid_moderation_document_url", + ("invalid_moderation_document_url", "www.google..com/", "", " "), + ) + def test_invalid_moderation_document_url( + self, invalid_moderation_document_url: str + ) -> None: + """Test that an error occurs when the provided `MODERATION_DOCUMENT_URL` is invalid.""" + INVALID_MODERATION_DOCUMENT_URL_MESSAGE: Final[str] = ( + "MODERATION_DOCUMENT_URL must be a valid URL" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MODERATION_DOCUMENT_URL"): + os.environ["MODERATION_DOCUMENT_URL"] = invalid_moderation_document_url + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_MODERATION_DOCUMENT_URL_MESSAGE + ): + RuntimeSettings._setup_moderation_document_url() + + +class TestSetupManualModerationWarningMessageLocation: + """Test case for the `_setup_strike_performed_manually_warning_location()` function.""" + + @pytest.mark.parametrize( + "test_manual_moderation_warning_message_location", + ("DM", "dm", "general", "Memes", " general ", "JUST-CHATTING", "Talking4"), + ) + def test_setup_strike_performed_manually_warning_location_successful( + self, test_manual_moderation_warning_message_location: str + ) -> None: + """Test that the given valid `MANUAL_MODERATION_WARNING_MESSAGE_LOCATION` is used.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MANUAL_MODERATION_WARNING_MESSAGE_LOCATION"): + os.environ["MANUAL_MODERATION_WARNING_MESSAGE_LOCATION"] = ( + test_manual_moderation_warning_message_location + ) + + RuntimeSettings._setup_strike_performed_manually_warning_location() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"] == ( + test_manual_moderation_warning_message_location.strip() + ) + + def test_default_manual_moderation_warning_message_location(self) -> None: + """Test a default value used when no `MANUAL_MODERATION_WARNING_MESSAGE_LOCATION`.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("MANUAL_MODERATION_WARNING_MESSAGE_LOCATION"): + RuntimeSettings._setup_strike_performed_manually_warning_location() + + RuntimeSettings._is_env_variables_setup = True + + assert isinstance(RuntimeSettings()["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"], str) + + assert bool(RuntimeSettings()["STRIKE_PERFORMED_MANUALLY_WARNING_LOCATION"]) + + +class TestSetupCustomDiscordInviteUrl: + """Test case for the `_setup_custom_discord_invite_url()` function.""" + + @pytest.mark.parametrize( + "test_custom_discord_invite_url", + ( + "https://discord.gg/abc123", + "https://discord.com/invite/abc123", + " https://discord.gg/abc123 ", + "https://cssbham.com", + "www.cssbham.com/discord", + ), + ) + def test_setup_custom_discord_invite_url_successful( + self, test_custom_discord_invite_url: str + ) -> None: + """Test that a given valid `CUSTOM_DISCORD_INVITE_URL` is used when provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CUSTOM_DISCORD_INVITE_URL"): + os.environ["CUSTOM_DISCORD_INVITE_URL"] = test_custom_discord_invite_url + + RuntimeSettings._setup_custom_discord_invite_url() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["CUSTOM_DISCORD_INVITE_URL"] == ( + f"https://{test_custom_discord_invite_url.strip()}" + if "://" not in test_custom_discord_invite_url.strip() + else test_custom_discord_invite_url.strip() + ) + + def test_missing_custom_discord_invite_url(self) -> None: + """Test that no error is raised when no `CUSTOM_DISCORD_INVITE_URL` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CUSTOM_DISCORD_INVITE_URL"): + RuntimeSettings._setup_custom_discord_invite_url() + + RuntimeSettings._is_env_variables_setup = True + + assert not RuntimeSettings()["CUSTOM_DISCORD_INVITE_URL"] + + def test_invalid_protocol_custom_discord_invite_url(self) -> None: + """Test that an error is raised when `CUSTOM_DISCORD_INVITE_URL` is not https.""" + INVALID_CUSTOM_DISCORD_INVITE_URL: Final[str] = ( + "Only HTTPS is supported as a protocol for CUSTOM_DISCORD_INVITE_URL." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CUSTOM_DISCORD_INVITE_URL"): + os.environ["CUSTOM_DISCORD_INVITE_URL"] = "ftp://discord.gg/abc123" + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_CUSTOM_DISCORD_INVITE_URL + ): + RuntimeSettings._setup_custom_discord_invite_url() + + @pytest.mark.parametrize( + "test_invalid_discord_invite_url", + ( + "definitely not a url", + "https://couldbeaurlbutactually.com really isnt", + "www.ican'tbelieveit'snotbutter", + ), + ) + def test_invalid_custom_discord_invite_url( + self, test_invalid_discord_invite_url: str + ) -> None: + """Test that an error is raised when the `CUSTOM_DISCORD_INVITE_URL` is invalid.""" + INVALID_CUSTOM_DISCORD_INVITE_URL: Final[str] = ( + "CUSTOM_DISCORD_INVITE_URL must be a valid URL." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("CUSTOM_DISCORD_INVITE_URL"): + os.environ["CUSTOM_DISCORD_INVITE_URL"] = test_invalid_discord_invite_url + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_CUSTOM_DISCORD_INVITE_URL + ): + RuntimeSettings._setup_custom_discord_invite_url() + + +class TestSetupOrganisationID: + """Test case for the `_setup_organisation_id()` function.""" + + @pytest.mark.parametrize( + "test_organisation_id", ("13471", "43422", "6531", "39091", "41502") + ) + def test_setup_organisation_id_successful(self, test_organisation_id: str) -> None: + """Test that the given valid `ORGANISATION_ID` is used when one is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("ORGANISATION_ID"): + os.environ["ORGANISATION_ID"] = test_organisation_id + + RuntimeSettings._setup_organisation_id() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["ORGANISATION_ID"] == test_organisation_id.strip() + + def test_missing_organisation_id(self) -> None: + """Test that an error is raised when no `ORGANISATION_ID` is provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with ( + EnvVariableDeleter("ORGANISATION_ID"), + pytest.raises( + ImproperlyConfiguredError, + match="ORGANISATION_ID must be an integer 4 to 5 digits long.", + ), + ): + RuntimeSettings._setup_organisation_id() + + @pytest.mark.parametrize( + "invalid_organisation_id", + ( + "invalid_organisation_id", + "123", + "123456", + "12.34", + "12,34", + "12.34.56", + "12,34,56", + "1234a", + "a1234", + ), + ) + def test_invalid_organisation_id(self, invalid_organisation_id: str) -> None: + """Test that an error is raised when the provided `ORGANISATION_ID` is invalid.""" + INVALID_ORGANISATION_ID_MESSAGE: Final[str] = ( + "ORGANISATION_ID must be an integer 4 to 5 digits long." + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("ORGANISATION_ID"): + os.environ["ORGANISATION_ID"] = invalid_organisation_id + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_ORGANISATION_ID_MESSAGE + ): + RuntimeSettings._setup_organisation_id() + + +class TestSetupAutoAddCommitteeToThreads: + """Test case for the `_setup_auto_add_committee_to_threads()` function.""" + + @pytest.mark.parametrize( + "test_auto_add_committee_to_threads_value", + ( + "true", + "false", + "True", + "False", + " True ", + " False ", + "t", + "f", + "yes", + "no", + "y", + "n", + "1", + "0", + " 1 ", + " 0 ", + ), + ) + def test_setup_auto_add_committee_to_threads_successful( + self, test_auto_add_committee_to_threads_value: str + ) -> None: + """Test that the given valid `AUTO_ADD_COMMITTEE_TO_THREADS` is used when provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_ADD_COMMITTEE_TO_THREADS"): + os.environ["AUTO_ADD_COMMITTEE_TO_THREADS"] = ( + test_auto_add_committee_to_threads_value + ) + + RuntimeSettings._setup_auto_add_committee_to_threads() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["AUTO_ADD_COMMITTEE_TO_THREADS"] == ( + test_auto_add_committee_to_threads_value.lower().strip() in config.TRUE_VALUES + ) + + def test_default_auto_add_committee_to_threads_value(self) -> None: + """Test that the default is used when `AUTO_ADD_COMMITTEE_TO_THREADS` not provided.""" + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_ADD_COMMITTEE_TO_THREADS"): + RuntimeSettings._setup_auto_add_committee_to_threads() + + RuntimeSettings._is_env_variables_setup = True + + assert RuntimeSettings()["AUTO_ADD_COMMITTEE_TO_THREADS"] + + @pytest.mark.parametrize( + "invalid_auto_add_commmittee_to_threads", + ( + "invalid_auto_add_commmittee_to_threads", + "definitely not a valid value", + "won d e r i f t h i s a valid value", + "".join( + random.choices(string.ascii_letters + string.digits + string.punctuation, k=8), + ), + ), + ids=[f"case_{i}" for i in range(4)], + ) + def test_invalid_auto_add_commmittee_to_threads( + self, invalid_auto_add_commmittee_to_threads: str + ) -> None: + """Test that an error is raised when is `AUTO_ADD_COMMITTEE_TO_THREADS` invalid.""" + INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE: Final[str] = ( + "AUTO_ADD_COMMITTEE_TO_THREADS must be a boolean value" + ) + + RuntimeSettings: Final[type[Settings]] = config._settings_class_factory() + + with EnvVariableDeleter("AUTO_ADD_COMMITTEE_TO_THREADS"): + os.environ["AUTO_ADD_COMMITTEE_TO_THREADS"] = ( + invalid_auto_add_commmittee_to_threads + ) + + with pytest.raises( + ImproperlyConfiguredError, match=INVALID_AUTO_ADD_COMMITTEE_TO_THREADS_MESSAGE + ): + RuntimeSettings._setup_auto_add_committee_to_threads() diff --git a/tests/test_generate_invite_url.py b/tests/test_generate_invite_url.py new file mode 100644 index 000000000..084a1f866 --- /dev/null +++ b/tests/test_generate_invite_url.py @@ -0,0 +1,45 @@ +"""Test suite for utils package.""" + +import random +import re +from typing import TYPE_CHECKING + +import utils + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import Final + +__all__: "Sequence[str]" = () + + +class TestGenerateInviteURL: + """Test case to unit-test the generate_invite_url utility function.""" + + @staticmethod + def test_url_generates() -> None: + """Test that the invite URL generates successfully when valid arguments are passed.""" + DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( + 10000000000000000, + 99999999999999999999, + ) + DISCORD_MAIN_GUILD_ID: Final[int] = random.randint( + 10000000000000000, + 99999999999999999999, + ) + + invite_url: str = utils.generate_invite_url( + DISCORD_BOT_APPLICATION_ID, + DISCORD_MAIN_GUILD_ID, + ) + + assert re.fullmatch( + ( + r"\Ahttps://discord.com/.*=" + + str(DISCORD_BOT_APPLICATION_ID) + + r".*=" + + str(DISCORD_MAIN_GUILD_ID) + + r".*\Z" + ), + invite_url, + ) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 7d27e4aee..000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Test suite for utils package.""" - -import random -import re -from typing import TYPE_CHECKING - -import utils - -if TYPE_CHECKING: - from collections.abc import Sequence - from typing import Final - -__all__: "Sequence[str]" = () - -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestPlotBarChart: -# """Test case to unit-test the plot_bar_chart function.""" -# -# def test_bar_chart_generates(self) -> None: -# """Test that the bar chart generates successfully when valid arguments are passed.""" # noqa: ERA001, E501, W505 -# FILENAME: Final[str] = "output_chart.png" # noqa: ERA001 -# DESCRIPTION: Final[str] = "Bar chart of the counted value of different roles." # noqa: ERA001, E501, W505 -# -# bar_chart_image: discord.File = plot_bar_chart( -# data={"role1": 5, "role2": 7}, # noqa: ERA001 -# x_label="Role Name", # noqa: ERA001 -# y_label="Counted value", # noqa: ERA001 -# title="Counted Value Of Each Role", # noqa: ERA001 -# filename=FILENAME, # noqa: ERA001 -# description=DESCRIPTION, # noqa: ERA001 -# extra_text="This is extra text" # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# -# assert bar_chart_image.filename == FILENAME # noqa: ERA001 -# assert bar_chart_image.description == DESCRIPTION # noqa: ERA001 -# assert bool(bar_chart_image.fp.read()) is True # noqa: ERA001 - - -# TODO(CarrotManMatt): Move to stats_tests # noqa: FIX002 -# https://github.com/CSSUoB/TeX-Bot-Py-V2/issues/57 -# class TestAmountOfTimeFormatter: -# """Test case to unit-test the amount_of_time_formatter function.""" -# -# @pytest.mark.parametrize( -# "time_value", -# (1, 1.0, 0.999999, 1.000001) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_unit_value(self, time_value: float) -> None: -# """Test that a value of one only includes the time_scale.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# formatted_amount_of_time: str = amount_of_time_formatter(time_value, TIME_SCALE) # noqa: ERA001, E501, W505 -# -# assert formatted_amount_of_time == TIME_SCALE # noqa: ERA001 -# assert not formatted_amount_of_time.endswith("s") # noqa: ERA001 -# -# @pytest.mark.parametrize( -# "time_value", -# (*range(2, 21), 2.00, 0, 0.0, 25.0, -0, -0.0, -25.0) # noqa: ERA001 -# ) # noqa: ERA001, RUF100 -# def test_format_integer_value(self, time_value: float) -> None: -# """Test that an integer value includes the value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{int(time_value)} {TIME_SCALE}s" -# -# @pytest.mark.parametrize("time_value", (3.14159, 0.005, 25.0333333)) -# def test_format_float_value(self, time_value: float) -> None: -# """Test that a float value includes the rounded value and time_scale pluralized.""" -# TIME_SCALE: Final[str] = "day" # noqa: ERA001 -# -# assert amount_of_time_formatter( -# time_value, -# TIME_SCALE -# ) == f"{time_value:.2f} {TIME_SCALE}s" - - -class TestGenerateInviteURL: - """Test case to unit-test the generate_invite_url utility function.""" - - @staticmethod - def test_url_generates() -> None: - """Test that the invite URL generates successfully when valid arguments are passed.""" - DISCORD_BOT_APPLICATION_ID: Final[int] = random.randint( # noqa: S311 - 10000000000000000, 99999999999999999999 - ) - DISCORD_MAIN_GUILD_ID: Final[int] = random.randint( # noqa: S311 - 10000000000000000, 99999999999999999999 - ) - - invite_url: str = utils.generate_invite_url( - DISCORD_BOT_APPLICATION_ID, DISCORD_MAIN_GUILD_ID - ) - - assert re.fullmatch( - ( - r"\Ahttps://discord.com/.*=" - + str(DISCORD_BOT_APPLICATION_ID) - + r".*=" - + str(DISCORD_MAIN_GUILD_ID) - + r".*\Z" - ), - invite_url, - ) diff --git a/utils/__init__.py b/utils/__init__.py index 62c3eade4..ad3078b9e 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -8,7 +8,19 @@ import discord from .command_checks import CommandChecks +from .context_managers import ( + EnvVariableDeleter, + FileTemporaryDeleter, + TemporarySettingsKeyReplacer, +) from .message_sender_components import MessageSavingSenderComponent +from .random_generators import ( + BaseRandomEnvVariableValueGenerator, + RandomDiscordBotTokenGenerator, + RandomDiscordGuildIDGenerator, + RandomDiscordLogChannelWebhookURLGenerator, + RandomOrganisationIDGenerator, +) from .suppress_traceback import SuppressTraceback from .tex_bot import TeXBot from .tex_bot_base_cog import TeXBotBaseCog @@ -21,13 +33,21 @@ __all__: "Sequence[str]" = ( "GLOBAL_SSL_CONTEXT", "AllChannelTypes", + "BaseRandomEnvVariableValueGenerator", "CommandChecks", + "EnvVariableDeleter", + "FileTemporaryDeleter", "MessageSavingSenderComponent", + "RandomDiscordBotTokenGenerator", + "RandomDiscordGuildIDGenerator", + "RandomDiscordLogChannelWebhookURLGenerator", + "RandomOrganisationIDGenerator", "SuppressTraceback", "TeXBot", "TeXBotApplicationContext", "TeXBotAutocompleteContext", "TeXBotBaseCog", + "TemporarySettingsKeyReplacer", "generate_invite_url", "is_member_inducted", "is_running_in_async", diff --git a/utils/context_managers.py b/utils/context_managers.py new file mode 100644 index 000000000..93d7f3b80 --- /dev/null +++ b/utils/context_managers.py @@ -0,0 +1,168 @@ +"""Module containing context managers for temporary deletions and replacements.""" + +import hashlib +import os +from pathlib import Path +from typing import TYPE_CHECKING + +import git + +from config import settings + +if TYPE_CHECKING: + from collections.abc import Sequence + from types import TracebackType + from typing import Final + + +__all__: "Sequence[str]" = ( + "EnvVariableDeleter", + "FileTemporaryDeleter", + "TemporarySettingsKeyReplacer", +) + + +class EnvVariableDeleter: + """ + Context manager that deletes the given environment variable. + + The given environment variable is removed from both + the system environment variables list, + and the .env file in this project's root directory. + """ + + def __init__(self, env_variable_name: str) -> None: + """Store the current state of any instances of the stored environment variable.""" + self.env_variable_name: str = env_variable_name + + PROJECT_ROOT: Final[str | git.PathLike | None] = git.Repo( + ".", search_parent_directories=True + ).working_tree_dir + if PROJECT_ROOT is None: + NO_ROOT_DIRECTORY_MESSAGE: Final[str] = "Could not locate project root directory." + raise FileNotFoundError(NO_ROOT_DIRECTORY_MESSAGE) + + self.env_file_path: Path = PROJECT_ROOT / Path(".env") + self.old_env_value: str | None = os.environ.get(self.env_variable_name) + + def __enter__(self) -> None: + """Delete all stored instances of the stored environment variable.""" + if self.env_file_path.is_file(): + self.env_file_path = self.env_file_path.rename( + self.env_file_path.parent / Path(".env.original"), + ) + + if self.old_env_value is not None: + del os.environ[self.env_variable_name] + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: "TracebackType | None", # noqa: PYI036 + ) -> None: + """Restore the deleted environment variable to its previous states.""" + if self.env_file_path.is_file(): + self.env_file_path.rename(self.env_file_path.parent / Path(".env")) + + if self.old_env_value is not None: + os.environ[self.env_variable_name] = self.old_env_value + + +class TemporarySettingsKeyReplacer: + """Context manager that temporarily replaces the value at the given settings key.""" + + NOT_SET: "Final[object]" = object() + + @classmethod + def _get_old_settings_value(cls, settings_key_name: str) -> object: + try: + return settings[settings_key_name] + except KeyError: + return cls.NOT_SET + + def __init__(self, settings_key_name: str, new_settings_value: object) -> None: + """Store the current state of the settings value if it exists.""" + self.settings_key_name: str = settings_key_name + self.new_settings_value: object = new_settings_value + + self.old_settings_value: object = self._get_old_settings_value( + self.settings_key_name, + ) + + def __enter__(self) -> None: + """Replace the settings value with the new value provided.""" + settings._settings[self.settings_key_name] = self.new_settings_value # noqa: SLF001 + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: "TracebackType | None", # noqa: PYI036 + ) -> None: + """Restore the replaced settings value with the original value if it existed.""" + if self.old_settings_value is self.NOT_SET: + settings._settings.pop(self.settings_key_name) # noqa: SLF001 + + else: + settings._settings[self.settings_key_name] = self.old_settings_value # noqa: SLF001 + + +class FileTemporaryDeleter: + """ + Context manager that temporarily deletes the file at the given file path. + + The file at the given file path is restored after the context manager exits. + """ + + def __init__(self, file_path: Path) -> None: + """Store the given file path to delete.""" + self.file_path: Path = file_path + self._temp_file_path: Path | None = None + + def __enter__(self) -> None: + """Delete the file at the stored file path if that file actually exists.""" + if self._temp_file_path is not None: + ALREADY_DELETED_MESSAGE: Final[str] = ( + "Given file path has already been deleted by this context manager." + ) + raise RuntimeError(ALREADY_DELETED_MESSAGE) + + if self.file_path.is_file(): + new_file_path: Path = self.file_path.parent / ( + f"{self.file_path.name}." + f"{ + hashlib.sha1( + str(self.file_path.resolve(strict=False)).encode(), + usedforsecurity=False, + ).hexdigest()[:10] + }-" + f"invalid" + ) + + if new_file_path.exists(): + CANNOT_DELETE_FILE_MESSAGE: Final[str] = ( + "Cannot delete file at given file path: " + "file already exists at temporary file path." + ) + raise RuntimeError(CANNOT_DELETE_FILE_MESSAGE) + + self.file_path.replace(new_file_path) + self._temp_file_path = new_file_path + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: "TracebackType | None", # noqa: PYI036 + ) -> None: + """Restore the deleted file at the stored file path.""" + if self._temp_file_path is not None: + if not self._temp_file_path.exists(): + TEMPORARY_FILE_PATH_NOT_SAVED_CORRECTLY_MESSAGE: Final[str] = ( + "Cannot restore the deleted file, " + "because the temporary file path was not stored correctly." + ) + raise RuntimeError(TEMPORARY_FILE_PATH_NOT_SAVED_CORRECTLY_MESSAGE) + + self._temp_file_path.replace(self.file_path) diff --git a/utils/random_generators.py b/utils/random_generators.py new file mode 100644 index 000000000..c80e3548a --- /dev/null +++ b/utils/random_generators.py @@ -0,0 +1,137 @@ +"""Module for generating random values for environment variables.""" + +import abc +import random +import string +from collections.abc import Iterable +from typing import TYPE_CHECKING, override + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +__all__: "Sequence[str]" = ( + "BaseRandomEnvVariableValueGenerator", + "RandomDiscordBotTokenGenerator", + "RandomDiscordGuildIDGenerator", + "RandomDiscordLogChannelWebhookURLGenerator", + "RandomOrganisationIDGenerator", +) + + +class BaseRandomEnvVariableValueGenerator[T](abc.ABC): + """Generates random values for a specific environment variable.""" + + @classmethod + @abc.abstractmethod + def multiple_values(cls, count: int = 5) -> "Iterable[T]": + """Return `count` number of random values.""" + + @classmethod + def single_value(cls) -> T: + """Return a single random value.""" + return next(iter(cls.multiple_values(count=1))) + + +class RandomDiscordBotTokenGenerator(BaseRandomEnvVariableValueGenerator[str]): + """Generates random values that are valid Discord bot tokens.""" + + @classmethod + @override + def multiple_values(cls, count: int = 5) -> "Iterable[str]": + """Return `count` number of random `DISCORD_BOT_TOKEN` values.""" + return ( + f"{ + ''.join( + random.choices( + string.ascii_letters + string.digits, k=random.randint(24, 26) + ) + ) + }.{''.join(random.choices(string.ascii_letters + string.digits, k=6))}.{ + ''.join( + random.choices( + string.ascii_letters + string.digits + '_-', k=random.randint(27, 38) + ) + ) + }" # noqa: S311 + for _ in range(count) + ) + + @classmethod + @override + def single_value(cls) -> str: + """Return a single random `DISCORD_BOT_TOKEN` value.""" + return super().single_value() + + +class RandomDiscordLogChannelWebhookURLGenerator(BaseRandomEnvVariableValueGenerator[str]): + """Generates random values that are valid Discord log channel webhook URLs.""" + + @classmethod + @override + def multiple_values( + cls, count: int = 5, *, with_trailing_slash: bool | None = None + ) -> "Iterable[str]": + """Return `count` number of random `DISCORD_LOG_CHANNEL_WEBHOOK_URL` values.""" + return ( + f"https://discord.com/api/webhooks/{ + ''.join(random.choices(string.digits, k=random.randint(17, 20))) + }/{ + ''.join( + random.choices( + string.ascii_letters + string.digits, k=random.randint(60, 90) + ) + ) + }{ + ( + '/' + if with_trailing_slash + else (random.choice(('', '/')) if with_trailing_slash is None else '') + ) + }" # noqa: S311 + for _ in range(count) + ) + + @classmethod + @override + def single_value(cls) -> str: + """Return a single random `DISCORD_LOG_CHANNEL_WEBHOOK_URL` value.""" + return super().single_value() + + +class RandomDiscordGuildIDGenerator(BaseRandomEnvVariableValueGenerator[str]): + """Generates random values that are valid Discord guild IDs.""" + + @classmethod + @override + def multiple_values(cls, count: int = 5) -> "Iterable[str]": + """Return `count` number of random `DISCORD_GUILD_ID` values.""" + return ( + "".join(random.choices(string.digits, k=random.randint(17, 20))) # noqa: S311 + for _ in range(count) + ) + + @classmethod + @override + def single_value(cls) -> str: + """Return a single random `DISCORD_GUILD_ID` value.""" + return super().single_value() + + +class RandomOrganisationIDGenerator(BaseRandomEnvVariableValueGenerator[str]): + """Generates random values that are valid organisation IDs.""" + + @classmethod + @override + def multiple_values(cls, count: int = 5) -> "Iterable[str]": + """Return `count` number of random `ORGANISATION_ID` values.""" + return ( + "".join(random.choices(string.digits, k=random.randint(4, 5))) # noqa: S311 + for _ in range(count) + ) + + @classmethod + @override + def single_value(cls) -> str: + """Return a single random `ORGANISATION_ID` value.""" + return super().single_value() diff --git a/uv.lock b/uv.lock index 74c544d2b..049069b39 100644 --- a/uv.lock +++ b/uv.lock @@ -379,6 +379,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.44" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196, upload-time = "2025-01-02T07:32:43.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, +] + [[package]] name = "identify" version = "2.6.14" @@ -877,6 +901,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + [[package]] name = "soupsieve" version = "2.8" @@ -904,6 +937,7 @@ source = { virtual = "." } dev = [ { name = "ccft-pymarkdown" }, { name = "django-stubs", extra = ["compatible-mypy"] }, + { name = "gitpython" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -934,12 +968,19 @@ pre-commit = [ { name = "pre-commit" }, ] test = [ + { name = "gitpython" }, { name = "pytest" }, { name = "pytest-cov" }, ] +test-core = [ + { name = "gitpython" }, + { name = "pytest" }, +] type-check = [ { name = "django-stubs", extra = ["compatible-mypy"] }, + { name = "gitpython" }, { name = "mypy" }, + { name = "pytest" }, { name = "types-beautifulsoup4" }, ] @@ -949,10 +990,11 @@ type-check = [ dev = [ { name = "ccft-pymarkdown", specifier = ">=2.0" }, { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1" }, + { name = "gitpython", specifier = ">=3.1.44" }, { name = "mypy", specifier = ">=1.13" }, { name = "pre-commit", specifier = ">=4.0" }, { name = "pytest", specifier = ">=8.3" }, - { name = "pytest-cov", specifier = ">=6.1" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "ruff", specifier = ">=0.12" }, { name = "types-beautifulsoup4", specifier = ">=4.12" }, ] @@ -977,12 +1019,19 @@ main = [ ] pre-commit = [{ name = "pre-commit", specifier = ">=4.0" }] test = [ + { name = "gitpython", specifier = ">=3.1.44" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, +] +test-core = [ + { name = "gitpython", specifier = ">=3.1.44" }, { name = "pytest", specifier = ">=8.3" }, - { name = "pytest-cov", specifier = ">=6.1" }, ] type-check = [ { name = "django-stubs", extras = ["compatible-mypy"], specifier = ">=5.1" }, + { name = "gitpython", specifier = ">=3.1.44" }, { name = "mypy", specifier = ">=1.13" }, + { name = "pytest", specifier = ">=8.3" }, { name = "types-beautifulsoup4", specifier = ">=4.12" }, ]