from __future__ import annotations import os from unittest.mock import patch import pytest from yuxi.channels.adapters.nostr.config import ( NostrConfig, NostrConfigError, GuardPolicyConfig, RateLimitConfig, ) class TestNostrConfigValidation: def test_invalid_dm_policy_raises(self): with pytest.raises(NostrConfigError, match="dm_policy"): NostrConfig(dm_policy="invalid_policy") def test_invalid_streaming_mode_raises(self): with pytest.raises(NostrConfigError, match="streaming_mode"): NostrConfig(streaming_mode="realtime") def test_invalid_markdown_table_mode_raises(self): with pytest.raises(NostrConfigError, match="markdown_table_mode"): NostrConfig(markdown_table_mode="invalid") def test_whitelist_aliased_to_allowlist(self): cfg = NostrConfig(dm_policy="whitelist") assert cfg.dm_policy == "allowlist" def test_reconnect_interval_too_small_raises(self): with pytest.raises(NostrConfigError, match="reconnect_interval_sec"): NostrConfig(reconnect_interval_sec=0) def test_relay_timeout_too_small_raises(self): with pytest.raises(NostrConfigError, match="relay_timeout_sec"): NostrConfig(relay_timeout_sec=0) def test_degraded_threshold_out_of_range_low(self): with pytest.raises(NostrConfigError, match="relay_degraded_threshold"): NostrConfig(relay_degraded_threshold=0) def test_degraded_threshold_out_of_range_high(self): with pytest.raises(NostrConfigError, match="relay_degraded_threshold"): NostrConfig(relay_degraded_threshold=1.5) def test_degraded_threshold_valid(self): cfg = NostrConfig(relay_degraded_threshold=0.8) assert cfg.relay_degraded_threshold == 0.8 def test_relay_url_with_invalid_prefix_raises(self): with pytest.raises(NostrConfigError, match="ws:// 或 wss://"): NostrConfig(relays=["http://bad.relay"]) def test_relay_url_ws_rejected_when_require_tls(self): with pytest.raises(NostrConfigError, match="明文 ws://"): NostrConfig(relays=["ws://plain.relay"], require_tls=True) def test_relay_url_ws_allowed_when_require_tls_false(self): cfg = NostrConfig(relays=["ws://plain.relay"], require_tls=False) assert "ws://plain.relay" in cfg.relays def test_all_valid_dm_policies(self): for policy in NostrConfig.VALID_DM_POLICIES: if policy == "whitelist": continue cfg = NostrConfig(dm_policy=policy) assert cfg.dm_policy == policy def test_all_streaming_modes(self): for mode in NostrConfig.VALID_STREAMING_MODES: cfg = NostrConfig(streaming_mode=mode) assert cfg.streaming_mode == mode class TestNostrConfigFromEnv: def test_from_env_defaults(self): with patch.dict(os.environ, {}, clear=True): cfg = NostrConfig.from_env() assert cfg.private_key == "" assert len(cfg.relays) == 4 assert cfg.dm_policy == "pairing" assert cfg.nip17_enabled is True def test_from_env_custom_values(self): env_vars = { "NOSTR_PRIVATE_KEY": "nsec1testkey123", "NOSTR_RELAYS": "wss://custom.relay,wss://custom2.relay", "NOSTR_DM_POLICY": "open", "NOSTR_STREAMING_MODE": "progress", "NOSTR_RECONNECT_INTERVAL_SEC": "10", "NOSTR_RELAY_TIMEOUT_SEC": "60", "NOSTR_RELAY_DEGRADED_THRESHOLD": "0.7", "NOSTR_NIP17_ENABLED": "false", "NOSTR_NIP42_AUTH_ENABLED": "true", "NOSTR_BACKFILL_WINDOW_SEC": "300", "NOSTR_ENABLED": "false", "NOSTR_NAME": "test_bot", "NOSTR_SEND_CACHE_SIZE": "200", "NOSTR_MESSAGE_ORDERING": "true", "NOSTR_MESSAGE_ORDERING_WINDOW_MS": "1000", "NOSTR_MULTI_ACCOUNT_ENABLED": "true", "NOSTR_REQUIRE_TLS": "false", } with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.private_key == "nsec1testkey123" assert len(cfg.relays) == 2 assert cfg.dm_policy == "open" assert cfg.streaming_mode == "progress" assert cfg.reconnect_interval_sec == 10 assert cfg.relay_timeout_sec == 60 assert cfg.relay_degraded_threshold == 0.7 assert cfg.nip17_enabled is False assert cfg.nip42_auth_enabled is True assert cfg.backfill_window_sec == 300 assert cfg.enabled is False assert cfg.name == "test_bot" assert cfg.send_message_cache_size == 200 assert cfg.message_ordering is True assert cfg.message_ordering_window_ms == 1000 assert cfg.multi_account_enabled is True assert cfg.require_tls is False def test_from_env_bool_variants(self): variants = [ ("1", True), ("true", True), ("TRUE", True), ("yes", True), ("on", True), ("0", False), ("false", False), ("no", False), ("off", False), ] for val, expected in variants: env_vars = {"NOSTR_NIP17_ENABLED": val} with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.nip17_enabled == expected, f"Failed for value {val}" def test_from_env_bool_missing_defaults_to_false(self): with patch.dict(os.environ, {}, clear=True): cfg = NostrConfig.from_env() assert cfg.nip42_auth_enabled is False assert cfg.message_ordering is False def test_from_env_int_invalid_falls_back_to_default(self): env_vars = {"NOSTR_RECONNECT_INTERVAL_SEC": "not_a_number"} with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.reconnect_interval_sec == 5 def test_from_env_float_invalid_falls_back_to_default(self): env_vars = {"NOSTR_RELAY_DEGRADED_THRESHOLD": "abc"} with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.relay_degraded_threshold == 0.5 def test_from_env_list_splits_commas(self): env_vars = {"NOSTR_ALLOW_FROM": " pub_a , pub_b , pub_c "} with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.allow_from == ["pub_a", "pub_b", "pub_c"] def test_from_env_list_empty_returns_none(self): with patch.dict(os.environ, {"NOSTR_ALLOW_FROM": ""}, clear=True): cfg = NostrConfig.from_env() assert cfg.allow_from == [] def test_from_env_guard_policy(self): env_vars = { "NOSTR_GUARD_ALLOWED_KINDS": "1,4,7", "NOSTR_GUARD_MAX_CIPHERTEXT_BYTES": "30000", "NOSTR_GUARD_MAX_FUTURE_SKEW_SEC": "60", "NOSTR_RATE_LIMIT_WINDOW_MS": "5000", "NOSTR_RATE_LIMIT_MAX_PER_SENDER": "15", "NOSTR_RATE_LIMIT_MAX_GLOBAL": "300", } with patch.dict(os.environ, env_vars, clear=True): cfg = NostrConfig.from_env() assert cfg.guard_policy.allowed_kinds == ["1", "4", "7"] assert cfg.guard_policy.max_ciphertext_bytes == 30000 assert cfg.guard_policy.max_future_skew_sec == 60 assert cfg.guard_policy.rate_limit.window_ms == 5000 assert cfg.guard_policy.rate_limit.max_per_sender_per_window == 15 assert cfg.guard_policy.rate_limit.max_global_per_window == 300 class TestNostrConfigAccountManagement: def test_get_account_configs_empty_returns_default(self): cfg = NostrConfig() result = cfg.get_account_configs({}) assert "default" in result assert len(result) == 1 def test_get_account_configs_with_accounts(self): raw = { "accounts": { "account1": {"private_key": "nsec1aaa", "enabled": True}, "account2": {"private_key": "nsec1bbb", "enabled": True}, "disabled_account": {"private_key": "nsec1ccc", "enabled": False}, } } cfg = NostrConfig() result = cfg.get_account_configs(raw) assert "account1" in result assert "account2" in result assert "disabled_account" not in result assert len(result) == 2 def test_get_account_configs_all_disabled_returns_default(self): raw = { "accounts": { "disabled1": {"enabled": False}, "disabled2": {"enabled": False}, } } cfg = NostrConfig() result = cfg.get_account_configs(raw) assert "default" in result assert len(result) == 1 def test_list_account_ids_empty(self): cfg = NostrConfig() assert cfg.list_account_ids({}) == ["default"] def test_list_account_ids_with_accounts(self): raw = { "accounts": { "account_a": {"enabled": True}, "account_b": {"enabled": True}, } } cfg = NostrConfig() result = cfg.list_account_ids(raw) assert result == ["account_a", "account_b"] def test_list_account_ids_skips_disabled(self): raw = { "accounts": { "enabled_acc": {"enabled": True}, "disabled_acc": {"enabled": False}, } } cfg = NostrConfig() result = cfg.list_account_ids(raw) assert result == ["enabled_acc"] def test_resolve_default_account_id(self): cfg = NostrConfig() assert cfg.resolve_default_account_id() == "default" def test_from_dict_with_flat_rate_limit_legacy(self): cfg = NostrConfig.from_dict({ "accounts": { "default": { "guard_policy": { "rate_limit_window_ms": 8000, "rate_limit_max_per_sender_per_window": 25, "rate_limit_max_global_per_window": 500, } } } }) assert cfg.guard_policy.rate_limit.window_ms == 8000 assert cfg.guard_policy.rate_limit.max_per_sender_per_window == 25 assert cfg.guard_policy.rate_limit.max_global_per_window == 500 def test_guard_policy_config_defaults(self): gpc = GuardPolicyConfig() assert gpc.allowed_kinds == [1, 4, 5, 7, 1059] assert gpc.max_ciphertext_bytes == 50_000 assert gpc.max_plaintext_bytes == 10_000 assert gpc.max_future_skew_sec == 30 def test_rate_limit_config_defaults(self): rlc = RateLimitConfig() assert rlc.window_ms == 10_000 assert rlc.max_per_sender_per_window == 20 assert rlc.max_global_per_window == 200 class TestNostrConfigFromDictEdgeCases: def test_from_dict_with_zero_backfill(self): cfg = NostrConfig.from_dict({ "accounts": {"default": {"backfill_window_sec": 0}} }) assert cfg.backfill_window_sec == 0 def test_from_dict_with_allow_from(self): cfg = NostrConfig.from_dict({ "accounts": {"default": {"allow_from": ["pub_a", "pub_b"]}} }) assert cfg.allow_from == ["pub_a", "pub_b"] def test_from_dict_with_markdown_table_convert(self): cfg = NostrConfig.from_dict({ "accounts": {"default": {"markdown_table_mode": "convert"}} }) assert cfg.markdown_table_mode == "convert" def test_from_dict_degraded_threshold_default(self): cfg = NostrConfig.from_dict({}) assert cfg.relay_degraded_threshold == 0.5