from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.adapters.nostr.config import NostrConfig, NostrConfigError from yuxi.channels.adapters.nostr.crypto import NostrCryptoError from yuxi.channels.adapters.nostr.probe import ProbeResult class TestNostrConfigValidation: def test_valid_config_passes_validation(self): cfg = NostrConfig( private_key="nsec1test", relays=["wss://test.relay"], dm_policy="pairing", streaming_mode="block", ) assert cfg.dm_policy == "pairing" def test_invalid_dm_policy_raises_error(self): with pytest.raises(NostrConfigError, match="dm_policy 值无效"): NostrConfig(dm_policy="invalid_policy") def test_invalid_streaming_mode_raises_error(self): with pytest.raises(NostrConfigError, match="streaming_mode 值无效"): NostrConfig(streaming_mode="invalid_mode") def test_invalid_reconnect_interval_raises_error(self): with pytest.raises(NostrConfigError, match="reconnect_interval_sec"): NostrConfig(reconnect_interval_sec=0) def test_invalid_relay_timeout_raises_error(self): with pytest.raises(NostrConfigError, match="relay_timeout_sec"): NostrConfig(relay_timeout_sec=0) def test_invalid_degraded_threshold_raises_error(self): with pytest.raises(NostrConfigError, match="relay_degraded_threshold"): NostrConfig(relay_degraded_threshold=0) def test_invalid_degraded_threshold_above_one_raises_error(self): with pytest.raises(NostrConfigError, match="relay_degraded_threshold"): NostrConfig(relay_degraded_threshold=1.5) def test_invalid_relay_url_raises_error(self): with pytest.raises(NostrConfigError, match="Relay URL 必须以 ws:// 或 wss:// 开头"): NostrConfig(relays=["http://invalid.relay"]) def test_all_valid_dm_policies_pass(self): for policy in ("pairing", "open", "whitelist"): cfg = NostrConfig(dm_policy=policy) assert cfg.dm_policy == policy def test_all_valid_streaming_modes_pass(self): for mode in ("off", "block", "progress"): cfg = NostrConfig(streaming_mode=mode) assert cfg.streaming_mode == mode def test_from_dict_with_invalid_config_still_validates(self): with pytest.raises(NostrConfigError): NostrConfig.from_dict({ "accounts": {"default": {"dm_policy": "bad"}} }) def test_wss_relay_url_valid(self): cfg = NostrConfig(relays=["wss://relay.example.com"]) assert len(cfg.relays) == 1 def test_ws_relay_url_valid(self): cfg = NostrConfig(relays=["ws://relay.example.com"]) assert len(cfg.relays) == 1 class TestNostrCryptoDecryptNip17: @pytest.mark.asyncio async def test_decrypt_nip17_raises_on_none_unwrapped(self): with patch( "yuxi.channels.adapters.nostr.crypto.Event" ) as mock_event_class, patch( "yuxi.channels.adapters.nostr.crypto.UnwrappedGift" ) as mock_gift_class: mock_event = MagicMock() mock_event_class.from_json.return_value = mock_event mock_gift = MagicMock() mock_gift.from_gift_wrap.return_value = None mock_gift_class.from_gift_wrap.return_value = None from yuxi.channels.adapters.nostr.crypto import NostrCrypto crypto = create_crypto_instance() with pytest.raises(NostrCryptoError, match="Gift Wrap 解封返回空"): await crypto.decrypt_nip17('{"id":"test"}') @pytest.mark.asyncio async def test_decrypt_nip17_raises_on_none_rumor(self): with patch( "yuxi.channels.adapters.nostr.crypto.Event" ) as mock_event_class, patch( "yuxi.channels.adapters.nostr.crypto.UnwrappedGift" ) as mock_gift_class: mock_event = MagicMock() mock_event_class.from_json.return_value = mock_event mock_gift = MagicMock() mock_gift.from_gift_wrap.return_value = mock_gift mock_gift_class.from_gift_wrap.return_value = mock_gift mock_gift.rumor.return_value = None from yuxi.channels.adapters.nostr.crypto import NostrCrypto crypto = create_crypto_instance() with pytest.raises(NostrCryptoError, match="Rumor 为空"): await crypto.decrypt_nip17('{"id":"test"}') class TestProbeResult: def test_probe_result_is_dataclass(self): result = ProbeResult(url="wss://test.relay", connected=True, latency_ms=100.0) assert result.url == "wss://test.relay" assert result.connected is True assert result.latency_ms == 100.0 assert result.error is None def test_probe_result_with_error(self): result = ProbeResult( url="wss://bad.relay", connected=False, error="Connection refused" ) assert result.connected is False assert result.error == "Connection refused" def test_probe_result_repr(self): result = ProbeResult(url="wss://test.relay", connected=True, latency_ms=50.0) repr_str = repr(result) assert "ProbeResult" in repr_str def create_crypto_instance(): from yuxi.channels.adapters.nostr.crypto import NostrCrypto with patch( "yuxi.channels.adapters.nostr.crypto.Keys" ) as mock_keys_class: mock_keys = MagicMock() mock_public_key = MagicMock() mock_public_key.to_bech32.return_value = "npub1test" mock_public_key.to_hex.return_value = "abcdef" mock_secret_key = MagicMock() mock_secret_key.to_bech32.return_value = "nsec1test" mock_keys.public_key.return_value = mock_public_key mock_keys.secret_key.return_value = mock_secret_key mock_keys_class.parse.return_value = mock_keys return NostrCrypto("nsec1test") class TestHealthCheckGuards: @pytest.mark.asyncio async def test_health_check_total_zero_returns_unhealthy(self): from yuxi.channels.adapters.nostr.adapter import NostrAdapter adapter = NostrAdapter(config={"private_key": None, "relays": []}) adapter._nostr_config = NostrConfig(relays=[]) adapter._relay_manager = MagicMock() adapter._relay_manager.active_count.return_value = (0, 0) result = await adapter.health_check() assert result.status == "unhealthy" assert "没有 Relay" in result.last_error @pytest.mark.asyncio async def test_health_check_no_crypto_still_healthy(self): from yuxi.channels.adapters.nostr.adapter import NostrAdapter adapter = NostrAdapter(config={"private_key": None, "relays": []}) adapter._nostr_config = NostrConfig(relays=["wss://test.relay"]) adapter._relay_manager = MagicMock() adapter._relay_manager.active_count.return_value = (2, 3) adapter._crypto = None result = await adapter.health_check() assert result.status == "healthy" assert result.metadata["npub"] == "" class TestAnalysisCoordinator: @pytest.mark.asyncio async def test_coordinator_generates_complete_report(self): from yuxi.channels.adapters.nostr.analysis.coordinator import ( AnalysisCoordinator, ) coordinator = AnalysisCoordinator("package/yuxi/channels/adapters/nostr") report = await coordinator.run() assert report.module_name == "NostrAdapter" assert len(report.sub_agent_reports) == 3 assert report.sub_agent_reports[0].agent_id == "web_researcher" assert report.sub_agent_reports[1].agent_id == "code_analyzer" assert report.sub_agent_reports[2].agent_id == "problem_finder" assert all(r.status == "completed" for r in report.sub_agent_reports) assert len(report.code_issues) > 0 assert len(report.fix_proposals) > 0 assert report.generated_at != "" def test_report_generator_markdown_output(self): from yuxi.channels.adapters.nostr.analysis.coordinator import ( AnalysisCoordinator, ) from yuxi.channels.adapters.nostr.analysis.reporter import ReportGenerator import asyncio coordinator = AnalysisCoordinator("package/yuxi/channels/adapters/nostr") report = asyncio.run(coordinator.run()) md = ReportGenerator.to_markdown(report) assert "# NostrAdapter 功能欠缺分析报告" in md assert "缺失的核心功能点" in md assert "未实现的接口或方法" in md assert "与行业标准的差距" in md assert "潜在的性能瓶颈" in md assert "代码问题详细清单" in md assert "代码修复方案" in md assert "子Agent报告摘要" in md @pytest.mark.asyncio async def test_web_researcher_produces_findings(self): from yuxi.channels.adapters.nostr.analysis.web_researcher import ( WebResearcher, ) researcher = WebResearcher() report = await researcher.research() assert report.status == "completed" assert len(report.findings) > 0 assert report.findings[0].category in ( "NIP标准协议", "DM加密演变", "Relay管理", "事件处理", "同类实现参考" ) @pytest.mark.asyncio async def test_code_analyzer_finds_interface_gaps(self): from yuxi.channels.adapters.nostr.analysis.code_analyzer import CodeAnalyzer analyzer = CodeAnalyzer("package/yuxi/channels/adapters/nostr") report = await analyzer.analyze() assert report.status == "completed" assert len(report.findings) >= 10 @pytest.mark.asyncio async def test_problem_finder_detects_critical_issues(self): from yuxi.channels.adapters.nostr.analysis.problem_finder import ( ProblemFinder, ) finder = ProblemFinder("package/yuxi/channels/adapters/nostr") report = await finder.analyze() assert report.status == "completed" assert len(report.findings) >= 20 critical_count = sum( 1 for f in report.findings if hasattr(f, "severity") and f.severity == "critical" ) assert critical_count >= 1