# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Configuration loading, validation and URL derivation.""" from __future__ import annotations import pytest from signal_bot.config import Config, ConfigError, load_config BASE_ENV = { "PHONE_NUMBER": "+15550000000", "GROUP_ID": "watched==", "GROUP_ID_SEND": "reply==", } def test_loads_minimal_environment() -> None: config = load_config(dict(BASE_ENV)) assert config.phone_number == "+15550000000" assert config.api_base_url == "http://localhost:9922" assert config.report_hour == 21 assert config.report_minute == 37 @pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"]) def test_refuses_to_start_without_required_value(missing: str) -> None: env = dict(BASE_ENV) del env[missing] with pytest.raises(ConfigError, match=missing): load_config(env) @pytest.mark.parametrize("missing", ["PHONE_NUMBER", "GROUP_ID", "GROUP_ID_SEND"]) def test_refuses_to_start_on_blank_value(missing: str) -> None: """A linked device sees every chat, so an unscoped bot must not start.""" env = dict(BASE_ENV) | {missing: " "} with pytest.raises(ConfigError, match=missing): load_config(env) def test_overrides_are_honoured() -> None: env = dict(BASE_ENV) | { "API_BASE_URL": "https://signal.example:8443", "DATABASE_PATH": "/var/lib/bot/bot.sqlite3", "REPORT_HOUR": "6", "REPORT_MINUTE": "5", "COOLDOWN_SECONDS": "0", } config = load_config(env) assert config.api_base_url == "https://signal.example:8443" assert config.database_path == "/var/lib/bot/bot.sqlite3" assert config.report_hour == 6 assert config.cooldown_seconds == 0 def test_blank_optionals_fall_back_to_defaults() -> None: env = dict(BASE_ENV) | {"API_BASE_URL": " ", "DATABASE_PATH": "", "REPORT_HOUR": ""} config = load_config(env) assert config.api_base_url == "http://localhost:9922" assert config.database_path == "signal_bot.sqlite3" assert config.report_hour == 21 def test_rejects_non_integer_option() -> None: with pytest.raises(ConfigError, match="must be an integer"): load_config(dict(BASE_ENV) | {"REPORT_HOUR": "quarter past"}) def test_rejects_negative_option() -> None: with pytest.raises(ConfigError, match="must not be negative"): load_config(dict(BASE_ENV) | {"COOLDOWN_SECONDS": "-1"}) @pytest.mark.parametrize(("hour", "minute"), [("24", "0"), ("0", "60")]) def test_rejects_out_of_range_clock(hour: str, minute: str) -> None: env = dict(BASE_ENV) | {"REPORT_HOUR": hour, "REPORT_MINUTE": minute} with pytest.raises(ConfigError, match="out of range"): load_config(env) def test_reads_os_environ_when_no_mapping_given(monkeypatch: pytest.MonkeyPatch) -> None: for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) config = load_config(use_dotenv=False) assert config.group_id == "watched==" def test_derived_urls() -> None: config = load_config(dict(BASE_ENV)) assert config.receive_url == ( "ws://localhost:9922/v1/receive/+15550000000?send_read_receipts=false" ) assert config.send_url == "http://localhost:9922/v2/send" assert config.attachments_url == "http://localhost:9922/v1/attachments" assert config.about_url == "http://localhost:9922/v1/about" def test_https_base_url_yields_wss() -> None: config = Config( phone_number="+1", group_id="g", group_id_send="r", api_base_url="https://signal.example", ) assert config.receive_url.startswith("wss://signal.example/")