from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.exceptions import ChannelAuthenticationError, ChannelNotConnectedError from yuxi.channels.models import ( Attachment, ChannelIdentity, ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, EventType, MessageType, ) from yuxi.channels.adapters.synologychat.adapter import SynologyChatAdapter from yuxi.channels.adapters.synologychat.client import DSMClient from yuxi.channels.adapters.synologychat.normalize import normalize_event from yuxi.channels.adapters.synologychat.probe import probe_dsm, resolve_api_version def make_dsm_event( user_id: str = "user-001", channel_id: str = "channel-001", message_id: str = "msg-001", text: str = "Hello", channel_type: str = "user", ) -> dict: return { "user_id": user_id, "channel_id": channel_id, "message_id": message_id, "message": {"text": text}, "channel_type": channel_type, "channel_name": "test-channel", "user_name": "test-user", "timestamp": 1746691200, } def make_adapter_config(**overrides) -> dict: config = { "dsm_url": "https://synology-nas.local:5001", "username": "bot", "password": "test123", "dm_policy": "open", } config.update(overrides) return config class TestSynologyChatAdapter: def test_channel_id_and_type(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.channel_id == "synologychat" assert adapter.channel_type == ChannelType.SYNOLOGYCHAT def test_default_config_values(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.text_chunk_limit == 4000 assert adapter.supports_markdown is True assert adapter.supports_streaming is True assert adapter.streaming_modes == ["block"] assert adapter._status == ChannelStatus.DISCONNECTED class TestNormalizeInbound: def test_direct_message(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event(channel_type="user") msg = adapter.normalize_inbound(event) assert msg.identity.channel_id == "synologychat" assert msg.identity.channel_type == ChannelType.SYNOLOGYCHAT assert msg.identity.channel_user_id == "user-001" assert msg.identity.channel_chat_id == "channel-001" assert msg.identity.channel_message_id == "msg-001" assert msg.chat_type == ChatType.DIRECT assert msg.content == "Hello" assert msg.event_type == EventType.MESSAGE_RECEIVED assert msg.message_type == MessageType.TEXT def test_group_message(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event(channel_type="channel", text="Group hello") msg = adapter.normalize_inbound(event) assert msg.chat_type == ChatType.GROUP assert msg.content == "Group hello" assert msg.metadata["dsm_chat_type"] == "group" def test_with_attachment(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event() event["message"]["file"] = { "url": "https://nas.local/file.jpg", "filename": "photo.jpg", "file_size": 1024, "mime_type": "image/jpeg", "is_image": True, } msg = adapter.normalize_inbound(event) assert len(msg.attachments) == 1 assert msg.attachments[0].type == "image" assert msg.attachments[0].url == "https://nas.local/file.jpg" assert msg.attachments[0].filename == "photo.jpg" assert msg.attachments[0].mime_type == "image/jpeg" def test_without_file(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event() msg = adapter.normalize_inbound(event) assert len(msg.attachments) == 0 def test_timestamp_parsing(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event() msg = adapter.normalize_inbound(event) assert msg.timestamp is not None assert msg.timestamp.year == 2025 def test_empty_text(self): adapter = SynologyChatAdapter(make_adapter_config()) event = make_dsm_event(text="") msg = adapter.normalize_inbound(event) assert msg.content == "" class TestFormatOutbound: def test_text_message(self): adapter = SynologyChatAdapter(make_adapter_config()) response = ChannelResponse( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="channel-001", ), content="Hello back", ) payload = adapter.format_outbound(response) assert payload["channel_id"] == "channel-001" assert payload["text"] == "Hello back" def test_with_image_attachment(self): adapter = SynologyChatAdapter(make_adapter_config()) response = ChannelResponse( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="channel-001", ), content="Check this", attachments=[Attachment(type="image", url="https://nas.local/img.png")], ) payload = adapter.format_outbound(response) assert payload["file_url"] == "https://nas.local/img.png" def test_without_attachment_url(self): adapter = SynologyChatAdapter(make_adapter_config()) response = ChannelResponse( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="channel-001", ), content="Hello", attachments=[Attachment(type="image", url=None)], ) payload = adapter.format_outbound(response) assert "file_url" not in payload class TestHealthCheck: @pytest.mark.asyncio async def test_no_http_client(self): adapter = SynologyChatAdapter(make_adapter_config()) status = await adapter.health_check() assert status.status == "unhealthy" @pytest.mark.asyncio async def test_with_http_client_probe_fails(self): adapter = SynologyChatAdapter(make_adapter_config()) adapter._http_client = AsyncMock() adapter._http_client.get = AsyncMock(return_value=MagicMock()) adapter._http_client.get.return_value.raise_for_status = MagicMock() adapter._http_client.get.return_value.json.return_value = { "success": False, "error": {"code": 102}, } status = await adapter.health_check() assert status.status == "degraded" @pytest.mark.asyncio async def test_healthy(self): adapter = SynologyChatAdapter(make_adapter_config()) adapter._http_client = AsyncMock() adapter._dsm_client = AsyncMock() adapter._dsm_client.list_channels.return_value = { "success": True, "data": {"channels": []}, } adapter._status = ChannelStatus.CONNECTED mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": True, "data": { "SYNO.API.Auth": {"maxVersion": 6}, "SYNO.Chat.External": {"maxVersion": 1}, }, } adapter._http_client.get.return_value = mock_response status = await adapter.health_check() assert status.status == "healthy" class TestPreConnect: @pytest.mark.asyncio async def test_missing_dsm_url(self): adapter = SynologyChatAdapter({}) result = await adapter.pre_connect() assert result["status"] == "error" @pytest.mark.asyncio async def test_probe_success(self): adapter = SynologyChatAdapter(make_adapter_config()) with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client_cls.return_value.__aenter__.return_value = mock_client mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": True, "data": { "SYNO.API.Auth": {"maxVersion": 6}, "SYNO.Chat.External": {"maxVersion": 1}, }, } mock_client.get.return_value = mock_response result = await adapter.pre_connect() assert result["status"] == "ok" assert "SYNO.API.Auth" in result["available_apis"] class TestProbe: @pytest.mark.asyncio async def test_probe_success(self): mock_client = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": True, "data": { "SYNO.API.Auth": {"maxVersion": 6, "minVersion": 1}, "SYNO.Chat.External": {"maxVersion": 1, "minVersion": 1}, }, } mock_client.get.return_value = mock_response result = await probe_dsm(mock_client, "https://nas.local:5001") assert "SYNO.API.Auth" in result assert "SYNO.Chat.External" in result @pytest.mark.asyncio async def test_probe_not_success(self): mock_client = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = {"success": False, "error": {"code": 102}} mock_client.get.return_value = mock_response result = await probe_dsm(mock_client, "https://nas.local:5001") assert result == {} @pytest.mark.asyncio async def test_probe_missing_api(self): mock_client = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": True, "data": { "SYNO.API.Auth": {"maxVersion": 6}, }, } mock_client.get.return_value = mock_response result = await probe_dsm(mock_client, "https://nas.local:5001") assert result == {} class TestResolveApiVersion: def test_with_api_info(self): api_info = {"SYNO.API.Auth": {"maxVersion": 6, "minVersion": 1}} assert resolve_api_version(api_info, "SYNO.API.Auth") == 6 def test_without_api_info_auth(self): assert resolve_api_version(None, "SYNO.API.Auth") == 6 def test_without_api_info_chat(self): assert resolve_api_version(None, "SYNO.Chat.External") == 1 def test_without_api_info_unknown(self): assert resolve_api_version(None, "UNKNOWN") == 1 def test_fallback_to_min_version(self): api_info = {"SYNO.API.Auth": {"minVersion": 3}} assert resolve_api_version(api_info, "SYNO.API.Auth") == 3 class TestDSMClient: def test_client_init(self): mock_http = AsyncMock() client = DSMClient(mock_http, "https://nas.local:5001", {"username": "bot"}, None) assert client.sid is None @pytest.mark.asyncio async def test_login_success(self): mock_http = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": True, "data": {"sid": "test_sid_12345"}, } mock_http.get.return_value = mock_response client = DSMClient( mock_http, "https://nas.local:5001", {"username": "bot", "password": "test123"}, {"SYNO.API.Auth": {"maxVersion": 6}}, ) sid = await client.login() assert sid == "test_sid_12345" assert client.sid == "test_sid_12345" @pytest.mark.asyncio async def test_login_failure(self): mock_http = AsyncMock() mock_response = MagicMock() mock_response.raise_for_status = MagicMock() mock_response.json.return_value = { "success": False, "error": {"code": 400}, } mock_http.get.return_value = mock_response client = DSMClient( mock_http, "https://nas.local:5001", {"username": "bot", "password": "wrong"}, {"SYNO.API.Auth": {"maxVersion": 6}}, ) with pytest.raises(ChannelAuthenticationError): await client.login() @pytest.mark.asyncio async def test_call_with_auth(self): mock_http = AsyncMock() def make_response(params=None): mock_response = MagicMock() mock_response.raise_for_status = MagicMock() if "login" in str(params): mock_response.json.return_value = { "success": True, "data": {"sid": "test_sid_12345"}, } else: mock_response.json.return_value = { "success": True, "data": {"events": []}, } return mock_response mock_http.get.side_effect = lambda url, params=None, **kwargs: make_response(params) client = DSMClient( mock_http, "https://nas.local:5001", {"username": "bot", "password": "test123"}, {"SYNO.API.Auth": {"maxVersion": 6}, "SYNO.Chat.External": {"maxVersion": 1}}, ) result = await client.call("SYNO.Chat.External", "Polling", {}) assert result["success"] is True class TestSessionRoute: def test_direct_route(self): from yuxi.channels.adapters.synologychat.session import resolve_session_route msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="dm-channel-001", ), chat_type=ChatType.DIRECT, content="Hello", ) route = resolve_session_route(msg) assert route == "agent:default:synologychat:default:direct:user-001" def test_group_route(self): from yuxi.channels.adapters.synologychat.session import resolve_session_route msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="group-channel-001", ), chat_type=ChatType.GROUP, content="Hello", ) route = resolve_session_route(msg) assert route == "agent:default:synologychat:default:group:group-channel-001" def test_custom_agent_id(self): from yuxi.channels.adapters.synologychat.session import resolve_session_route msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="chat-001", ), chat_type=ChatType.DIRECT, content="Hello", ) route = resolve_session_route(msg, "custom-agent") assert route == "agent:custom-agent:synologychat:default:direct:user-001" # ========== New tests for fixes and new features ========== class TestSynologyChatSecurityPolicy: @pytest.fixture def security_open(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy return SynologyChatSecurityPolicy({"security": {"dm_policy": "open", "group_policy": "open"}}) @pytest.fixture def security_allowlist(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy return SynologyChatSecurityPolicy( { "security": { "dm_policy": "allowlist", "group_policy": "allowlist", "allow_from": ["user-001"], "group_allow_from": ["user-003"], } } ) @pytest.fixture def security_disabled(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy return SynologyChatSecurityPolicy({"security": {"dm_policy": "disabled", "group_policy": "disabled"}}) def make_dm_msg(self, user_id="user-001"): return ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id=user_id, channel_chat_id="chat-001", ), chat_type=ChatType.DIRECT, content="Hello", ) def make_group_msg(self, user_id="user-001", chat_id="chat-001"): return ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id=user_id, channel_chat_id=chat_id, ), chat_type=ChatType.GROUP, content="Hello", ) def test_dm_open_allows_all(self, security_open): assert security_open.check(self.make_dm_msg("anyone")) is True def test_dm_disabled_blocks_all(self, security_disabled): assert security_disabled.check(self.make_dm_msg("anyone")) is False def test_dm_allowlist_allows_known(self, security_allowlist): assert security_allowlist.check(self.make_dm_msg("user-001")) is True def test_dm_allowlist_blocks_unknown(self, security_allowlist): assert security_allowlist.check(self.make_dm_msg("user-002")) is False def test_group_open_allows_all(self, security_open): assert security_open.check(self.make_group_msg("anyone")) is True def test_group_disabled_blocks_all(self, security_disabled): assert security_disabled.check(self.make_group_msg("anyone")) is False def test_group_allowlist_allows_known(self, security_allowlist): assert security_allowlist.check(self.make_group_msg("user-003")) is True def test_group_allowlist_blocks_unknown(self, security_allowlist): assert security_allowlist.check(self.make_group_msg("user-004")) is False def test_unknown_dm_policy_defaults_open(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "invalid_policy"}}) assert policy.check(self.make_dm_msg("anyone")) is True def test_unknown_group_policy_defaults_open(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"group_policy": "invalid_policy"}}) assert policy.check(self.make_group_msg("anyone")) is True class TestNormalizeWithBotMention: def test_bot_mentioned(self): event = make_dsm_event(text="Hey @bot help me") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT, bot_name="bot") assert msg.mentions is not None assert msg.mentions.is_bot_mentioned is True assert "bot" in msg.mentions.mentioned_user_ids def test_bot_not_mentioned(self): event = make_dsm_event(text="Hey @someone help me") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT, bot_name="bot") assert msg.mentions is not None assert msg.mentions.is_bot_mentioned is False def test_no_mentions_without_bot_name(self): event = make_dsm_event(text="Hey @bot help me") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.mentions is not None assert msg.mentions.is_bot_mentioned is False class TestNormalizeUrlExtraction: def test_extract_single_url(self): event = make_dsm_event(text="Check https://example.com/page") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert "https://example.com/page" in msg.extracted_urls def test_extract_multiple_urls(self): event = make_dsm_event(text="See https://a.com and http://b.org/path") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert len(msg.extracted_urls) == 2 assert "https://a.com" in msg.extracted_urls assert "http://b.org/path" in msg.extracted_urls def test_no_url_in_text(self): event = make_dsm_event(text="Just plain text") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.extracted_urls == [] class TestNormalizeCommand: def test_command_slash(self): event = make_dsm_event(text="/help") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.message_type == MessageType.COMMAND def test_command_with_args(self): event = make_dsm_event(text="/search something") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.message_type == MessageType.COMMAND def test_regular_text_not_command(self): event = make_dsm_event(text="not a /command") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.message_type == MessageType.TEXT class TestNormalizeEventTypeMapping: @pytest.mark.parametrize( "event_type_str,expected", [ ("", EventType.MESSAGE_RECEIVED), ("message", EventType.MESSAGE_RECEIVED), ("message_received", EventType.MESSAGE_RECEIVED), ("message_updated", EventType.MESSAGE_UPDATED), ("message_edited", EventType.MESSAGE_UPDATED), ("message_deleted", EventType.MESSAGE_DELETED), ("bot_added", EventType.BOT_ADDED), ("bot_removed", EventType.BOT_REMOVED), ("member_joined", EventType.MEMBER_JOINED), ("member_left", EventType.MEMBER_LEFT), ("unknown_type", EventType.MESSAGE_RECEIVED), ], ) def test_event_type_resolution(self, event_type_str, expected): from yuxi.channels.adapters.synologychat.normalize import _resolve_event_type assert _resolve_event_type(event_type_str) == expected class TestAdapterNewMethods: @pytest.fixture def adapter(self): return SynologyChatAdapter(make_adapter_config()) @pytest.mark.asyncio async def test_get_user_info_no_client(self, adapter): result = await adapter.get_user_info("user-001") assert result == {} @pytest.mark.asyncio async def test_get_user_info_with_client(self, adapter): adapter._dsm_client = AsyncMock() adapter._dsm_client.user_list.return_value = { "success": True, "data": { "users": [ {"user_id": "user-001", "username": "alice", "name": "Alice"}, {"user_id": "user-002", "username": "bob", "name": "Bob"}, ] }, } result = await adapter.get_user_info("user-001") assert result["username"] == "alice" assert result["name"] == "Alice" @pytest.mark.asyncio async def test_get_user_info_not_found(self, adapter): adapter._dsm_client = AsyncMock() adapter._dsm_client.user_list.return_value = { "success": True, "data": {"users": []}, } result = await adapter.get_user_info("user-999") assert result == {} @pytest.mark.asyncio async def test_download_media_no_client(self, adapter): with pytest.raises(ChannelNotConnectedError): await adapter.download_media("file-001") @pytest.mark.asyncio async def test_download_media_success(self, adapter): adapter._dsm_client = AsyncMock() adapter._dsm_client.call.return_value = { "success": True, "data": b"fake-binary-data", } result = await adapter.download_media("file-001") assert result == b"fake-binary-data" @pytest.mark.asyncio async def test_send_chat_action_no_client(self, adapter): result = await adapter.send_chat_action("chat-001", "typing") assert result.success is False @pytest.mark.asyncio async def test_send_chat_action_success(self, adapter): adapter._dsm_client = AsyncMock() adapter._dsm_client.call.return_value = {"success": True} result = await adapter.send_chat_action("chat-001", "typing") assert result.success is True class TestSendReplyPrefixLogging: def test_build_text_drops_reply_prefix_with_log(self): from yuxi.channels.adapters.synologychat.send import _build_text response = ChannelResponse( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="chat-001", ), content="x" * 3990, reply_to_message_id="msg-001", ) config = {"text_chunk_limit": 4000, "reply_to_mode": "first"} result = _build_text(response, config) assert "msg-001" not in result class TestPollingLoopCircuitBreaker: @pytest.mark.asyncio async def test_polling_loop_handles_circuit_breaker_open(self): from yuxi.channels.adapters.synologychat.monitor import polling_loop from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError client = AsyncMock() client.poll_messages.side_effect = CircuitBreakerOpenError("Open") cb = CircuitBreaker(failure_threshold=1, recovery_timeout=0.1) cb.state = "open" status_values = [ChannelStatus.CONNECTED, ChannelStatus.CONNECTED, ChannelStatus.DISCONNECTED] status_iter = iter(status_values) def get_status(): return next(status_iter) normalize_fn = MagicMock() handle_fn = AsyncMock() await polling_loop( client, {"polling_interval_seconds": 0.01, "polling_max_backoff_seconds": 1}, normalize_fn, handle_fn, get_status, cb, ) @pytest.mark.asyncio async def test_polling_loop_resets_failures_on_success(self): from yuxi.channels.adapters.synologychat.monitor import polling_loop from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError client = AsyncMock() poll_results = [ CircuitBreakerOpenError("Open"), {"success": True, "data": {"events": [], "next_cursor": "cursor-1"}}, CircuitBreakerOpenError("Open"), ] poll_iter = iter(poll_results) async def mock_poll(*args, **kwargs): val = next(poll_iter) if isinstance(val, Exception): raise val return val client.poll_messages = mock_poll cb = CircuitBreaker(failure_threshold=2, recovery_timeout=0.1) cb.state = "open" status_values = [ChannelStatus.CONNECTED] * 5 + [ChannelStatus.DISCONNECTED] status_iter = iter(status_values) def get_status(): return next(status_iter) def normalize_fn(x): return ChannelMessage( identity=ChannelIdentity( channel_id="test", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="u1", channel_chat_id="c1", ), content="test", ) handle_fn = AsyncMock() await polling_loop( client, {"polling_interval_seconds": 0.01, "polling_max_backoff_seconds": 1}, normalize_fn, handle_fn, get_status, cb, ) class TestMarkdownFormatting: def test_empty_text(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat assert _format_markdown_to_chat("") == "" def test_bold_conversion(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat result = _format_markdown_to_chat("**hello**") assert result == "*hello*" def test_bold_and_italic_not_confused(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat result = _format_markdown_to_chat("**bold** *italic*") assert result == "*bold* _italic_" result2 = _format_markdown_to_chat("*italic* **bold**") assert result2 == "_italic_ *bold*" def test_italic_conversion(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat result = _format_markdown_to_chat("*hello*") assert result == "_hello_" def test_code_conversion(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat result = _format_markdown_to_chat("use `print()`") assert result == "use `print()`" def test_strikethrough_conversion(self): from yuxi.channels.adapters.synologychat.adapter import _format_markdown_to_chat result = _format_markdown_to_chat("~~deleted~~") assert result == "~deleted~" # ========== P0 tests: Capability fixes & session isolation ========== class TestCapabilityFixes: def test_edit_unsend_reactions_declared_false(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.capabilities.edit is False assert adapter.capabilities.unsend is False assert adapter.capabilities.reactions is False def test_supports_chat_formatting(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.supports_chat_formatting is True def test_block_streaming_remains_true(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.capabilities.block_streaming is True assert adapter.capabilities.streaming_modes == ["block"] def test_dm_scope_declared(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter.dm_scope == "per-account-channel-peer" class TestSessionRouteFix: def test_dm_route_uses_user_id(self): from yuxi.channels.adapters.synologychat.session import resolve_session_route msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="dm-channel-001", ), chat_type=ChatType.DIRECT, content="Hello", ) route = resolve_session_route(msg) assert route == "agent:default:synologychat:default:direct:user-001" def test_group_route_uses_chat_id(self): from yuxi.channels.adapters.synologychat.session import resolve_session_route msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="group-channel-001", ), chat_type=ChatType.GROUP, content="Hello", ) route = resolve_session_route(msg) assert route == "agent:default:synologychat:default:group:group-channel-001" # ========== P0 tests: Rate limiter account isolation ========== class TestRateLimiterAccountIsolation: def test_account_specific_rate_key(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 10}}, account_id="acct-1") msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="chat-001", ), chat_type=ChatType.DIRECT, content="Hello", ) assert policy.check(msg) is True assert policy.check_rate("user-001") is True class TestRateLimiterRebuild: def test_rebuild_rate_limiter(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 5}}) assert policy._rate_limiter._max_requests == 5 policy.rebuild_rate_limiter(max_per_minute=20, window_seconds=120) assert policy._rate_limiter._max_requests == 20 assert policy._rate_limiter._window_seconds == 120 def test_rebuild_partial(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"rate_limit": {"max_per_minute": 10, "window_seconds": 60}}) policy.rebuild_rate_limiter(max_per_minute=15) assert policy._rate_limiter._max_requests == 15 assert policy._rate_limiter._window_seconds == 60 # ========== P0 tests: Invalid token rate limiter ========== class TestInvalidTokenRateLimiter: def test_allow_under_limit(self): from yuxi.channels.adapters.synologychat.webhook_handler import ( InvalidTokenRateLimiter, clear_invalid_token_limiter_for_test, ) clear_invalid_token_limiter_for_test() limiter = InvalidTokenRateLimiter(max_attempts=3, window_seconds=60) assert limiter.allow("192.168.1.1") is True assert limiter.allow("192.168.1.1") is True def test_record_failure_and_check(self): from yuxi.channels.adapters.synologychat.webhook_handler import ( InvalidTokenRateLimiter, clear_invalid_token_limiter_for_test, ) clear_invalid_token_limiter_for_test() limiter = InvalidTokenRateLimiter(max_attempts=2, window_seconds=60, block_seconds=1) limiter.record_failure("10.0.0.1") assert limiter.allow("10.0.0.1") is True limiter.record_failure("10.0.0.1") assert limiter.allow("10.0.0.1") is True limiter.record_failure("10.0.0.1") assert limiter.allow("10.0.0.1") is False def test_clear(self): from yuxi.channels.adapters.synologychat.webhook_handler import InvalidTokenRateLimiter limiter = InvalidTokenRateLimiter(max_attempts=1, window_seconds=60, block_seconds=300) limiter.record_failure("10.0.0.1") limiter.record_failure("10.0.0.1") assert limiter.allow("10.0.0.1") is False limiter.clear("10.0.0.1") assert limiter.allow("10.0.0.1") is True # ========== P1 tests: Agent context injection ========== class TestAgentContextInjection: def test_chat_type_in_metadata(self): event = make_dsm_event(channel_type="user") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.metadata["chat_type"] == "direct" assert msg.metadata["command_authorized"] is True def test_group_chat_type_in_metadata(self): event = make_dsm_event(channel_type="channel") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.metadata["chat_type"] == "group" # ========== P1 tests: Forwarded message recognition ========== class TestForwardedMessage: def test_forwarded_message_metadata(self): event = make_dsm_event(text="check this") event["forward"] = {"user_id": "forwarder-001", "username": "alice"} msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.metadata["is_forwarded"] is True assert msg.metadata["forwarded_from"] == "forwarder-001" def test_forwarded_in_message_field(self): event = make_dsm_event(text="fw msg") event["message"]["forward"] = {"username": "bob"} msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.metadata["is_forwarded"] is True assert msg.metadata["forwarded_from"] == "bob" def test_not_forwarded(self): event = make_dsm_event(text="normal") msg = normalize_event(event, "synologychat", ChannelType.SYNOLOGYCHAT) assert msg.metadata.get("is_forwarded") is None # ========== P1 tests: Pairing approved/denied messages ========== class TestPairingMessages: def test_approve_returns_message(self): from yuxi.channels.adapters.synologychat.pairing import PairingManager from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "pairing", "allow_from": []}}) manager = PairingManager(policy) manager.request_pairing("user-001") result = manager.approve("user-001") assert result["status"] == "approved" assert "approved_message" in result assert "access has been approved" in result["approved_message"] def test_deny_returns_message(self): from yuxi.channels.adapters.synologychat.pairing import PairingManager from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "pairing", "allow_from": []}}) manager = PairingManager(policy) manager.request_pairing("user-002") result = manager.deny("user-002") assert result["status"] == "denied" assert "denied_message" in result assert "denied" in result["denied_message"] # ========== P1 tests: Sent message cache TTL ========== class TestSentMessageCache: def test_cache_with_ttl(self): mock_http = AsyncMock() client = DSMClient(mock_http, "https://nas.local:5001", {}, None) assert client._sent_message_ttl == 600 def test_get_nonexistent(self): mock_http = AsyncMock() client = DSMClient(mock_http, "https://nas.local:5001", {}, None) assert client.get_sent_message_id("ch-1", "hello") is None # ========== P2 tests: Dual ID space user matching ========== class TestDualIdSpace: @pytest.mark.asyncio async def test_resolve_user_id_by_username(self): mock_http = AsyncMock() client = DSMClient(mock_http, "https://nas.local:5001", {}, None) client.user_list = AsyncMock( return_value={ "success": True, "data": { "users": [ {"user_id": "101", "username": "alice"}, {"user_id": "202", "username": "bob"}, ] }, } ) result = await client.resolve_user_id_by_username("alice") assert result == "101" @pytest.mark.asyncio async def test_resolve_user_id_not_found(self): mock_http = AsyncMock() client = DSMClient(mock_http, "https://nas.local:5001", {}, None) client.user_list = AsyncMock( return_value={ "success": True, "data": {"users": []}, } ) result = await client.resolve_user_id_by_username("nobody") assert result is None # ========== P2 tests: clearBaseFields ========== class TestClearBaseFields: def test_named_account_clears_unset_fields(self): from yuxi.channels.adapters.synologychat.accounts import resolve_account config = { "dsm_url": "https://base.local:5001", "username": "base_user", "password": "base_secret", "password_file": "/run/secrets/base_pwd", "accounts": { "acct-a": { "dsm_url": "https://acct-a.local:5001", } }, } result = resolve_account(config, "acct-a") assert result.dsm_url == "https://acct-a.local:5001" assert result.username == "" assert result.password == "" def test_default_account_inherits_base(self): from yuxi.channels.adapters.synologychat.accounts import resolve_account config = { "dsm_url": "https://base.local:5001", "username": "base_user", "password": "base_secret", } result = resolve_account(config, "default") assert result.dsm_url == "https://base.local:5001" assert result.username == "base_user" # ========== P3 tests: Identity links ========== class TestIdentityLinks: def test_build_identity_link(self): from yuxi.channels.adapters.synologychat.session import build_identity_link link = build_identity_link("user-001", account_id="acct-1", username="alice", chat_id="chat-1") assert link["channel"] == "synologychat" assert link["user_id"] == "user-001" assert link["account_id"] == "acct-1" assert link["username"] == "alice" def test_resolve_identity_links(self): from yuxi.channels.adapters.synologychat.session import resolve_identity_links msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="chat-001", ), chat_type=ChatType.DIRECT, content="Hello", metadata={"dsm_user_name": "alice"}, ) links = resolve_identity_links(msg) assert len(links) == 1 assert links[0]["user_id"] == "user-001" def test_resolve_with_forwarded(self): from yuxi.channels.adapters.synologychat.session import resolve_identity_links msg = ChannelMessage( identity=ChannelIdentity( channel_id="synologychat", channel_type=ChannelType.SYNOLOGYCHAT, channel_user_id="user-001", channel_chat_id="chat-001", ), chat_type=ChatType.DIRECT, content="Hello", metadata={"forwarded_from": "forwarder-001"}, ) links = resolve_identity_links(msg) assert len(links) == 2 assert links[1]["user_id"] == "forwarder-001" # ========== P3 tests: Send message cache TTL ========== class TestSetupWizardNamedAccount: def test_named_account_path_prefix(self): from yuxi.channels.adapters.synologychat.setup_wizard import build_setup_prompt config = {"accounts": {"acct-1": {"dsm_url": "https://nas2.local:5001"}}} result = build_setup_prompt(config, account_id="acct-1") assert result["path_prefix"] == "accounts.acct-1." assert result["is_named_account"] is True assert result["steps"][0]["env"] is None def test_default_account_env_enabled(self): from yuxi.channels.adapters.synologychat.setup_wizard import build_setup_prompt result = build_setup_prompt({}) assert result["path_prefix"] == "" assert result["is_named_account"] is False assert result["steps"][0]["env"] == "DSM_URL" def test_named_account_instructions(self): from yuxi.channels.adapters.synologychat.setup_wizard import get_setup_instructions result = get_setup_instructions("acct-1") assert "Named Account: acct-1" in result assert "only available" in result # ========== P0 tests: New config fields & validation ========== class TestDangerousConfigFields: def test_dangerously_allow_name_matching_default(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter._dangerously_allow_name_matching is False def test_dangerously_allow_name_matching_enabled(self): adapter = SynologyChatAdapter(make_adapter_config(dangerously_allow_name_matching=True)) assert adapter._dangerously_allow_name_matching is True issues = adapter.startup_validation() codes = [i["code"] for i in issues] assert "dangerous_name_matching" in codes def test_dangerously_allow_inherited_webhook_path_default(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter._dangerously_allow_inherited_webhook_path is False def test_webhook_path_source_default(self): adapter = SynologyChatAdapter(make_adapter_config()) assert adapter._webhook_path_source == "default" class TestStartupValidationEnhanced: def test_name_matching_warning(self): adapter = SynologyChatAdapter(make_adapter_config(dangerously_allow_name_matching=True)) issues = adapter.startup_validation() name_issues = [i for i in issues if i["code"] == "dangerous_name_matching"] assert len(name_issues) == 1 assert name_issues[0]["severity"] == "warning" def test_webhook_path_conflict_detection(self): config = make_adapter_config( connect_mode="webhook", webhook_path="/webhook/synology", accounts={ "acct-a": {"webhook_path": "/webhook/synology"}, "acct-b": {"webhook_path": "/webhook/synology"}, }, ) adapter = SynologyChatAdapter(config) issues = adapter.startup_validation() conflict_issues = [i for i in issues if i["code"] == "webhook_path_conflict"] assert len(conflict_issues) >= 1 def test_distinct_webhook_paths_no_conflict(self): config = make_adapter_config( connect_mode="webhook", webhook_path="/webhook/synology", accounts={ "acct-a": {"webhook_path": "/webhook/synology/a"}, "acct-b": {"webhook_path": "/webhook/synology/b"}, }, ) adapter = SynologyChatAdapter(config) issues = adapter.startup_validation() conflict_issues = [i for i in issues if i["code"] == "webhook_path_conflict"] assert len(conflict_issues) == 0 class TestPatchConfigAllowFromEnforcement: def test_allow_from_forces_dm_allowlist(self): from yuxi.channels.adapters.synologychat.setup_wizard import patch_config config = {"dm_policy": "open"} result = patch_config({"security.allow_from": "user-001"}, config) sec = result.get("security", {}) assert sec.get("dm_policy") == "allowlist" assert "user-001" in sec.get("allow_from", []) def test_allow_from_keeps_existing_policy(self): from yuxi.channels.adapters.synologychat.setup_wizard import patch_config config = {"security": {"dm_policy": "pairing"}} result = patch_config({"security.allow_from": "user-001"}, config) sec = result.get("security", {}) assert sec.get("dm_policy") == "pairing" def test_named_account_allow_from(self): from yuxi.channels.adapters.synologychat.setup_wizard import patch_config config = {"accounts": {"acct-1": {"dm_policy": "open"}}} result = patch_config({"accounts.acct-1.security.allow_from": "user-002"}, config, account_id="acct-1") acct = result.get("accounts", {}).get("acct-1", {}) sec = acct.get("security", {}) assert sec.get("dm_policy") == "allowlist" def test_empty_allow_from_no_change(self): from yuxi.channels.adapters.synologychat.setup_wizard import patch_config config = {"dm_policy": "open"} result = patch_config({"security.allow_from": ""}, config) sec = result.get("security", {}) assert sec.get("dm_policy", "open") == "open" class TestSecurityAddAllowedUser: def test_add_to_dm_allowlist(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "allowlist", "allow_from": []}}) assert policy.add_allowed_user("user-001") is True assert policy.add_allowed_user("user-001") is False def test_add_to_group_allowlist(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"group_policy": "allowlist", "group_allow_from": []}}) assert policy.add_allowed_user("user-003", scope="group") is True def test_add_invalid_scope(self): from yuxi.channels.adapters.synologychat.security import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "allowlist", "allow_from": []}}) assert policy.add_allowed_user("user-001", scope="invalid") is False class TestSynologyNasHostEnvVar: def test_nas_host_env_var(self): import os from yuxi.channels.adapters.synologychat.auth import apply_env_defaults os.environ["SYNOLOGY_NAS_HOST"] = "https://nas-from-env.local:5001" try: config = apply_env_defaults({}) assert config["dsm_url"] == "https://nas-from-env.local:5001" finally: del os.environ["SYNOLOGY_NAS_HOST"] def test_nas_host_does_not_override_dsm_url(self): import os from yuxi.channels.adapters.synologychat.auth import apply_env_defaults os.environ["SYNOLOGY_NAS_HOST"] = "https://nas-env.local:5001" try: config = apply_env_defaults({"dsm_url": "https://explicit.local:5001"}) assert config["dsm_url"] == "https://explicit.local:5001" finally: del os.environ["SYNOLOGY_NAS_HOST"] class TestInitExports: def test_clear_invalid_token_limiter_exported(self): from yuxi.channels.adapters.synologychat import clear_invalid_token_limiter_for_test assert callable(clear_invalid_token_limiter_for_test) clear_invalid_token_limiter_for_test() def test_patch_config_exported(self): from yuxi.channels.adapters.synologychat import patch_config result = patch_config({"name": "test-channel"}, {}) assert result["name"] == "test-channel" def test_security_policy_exported(self): from yuxi.channels.adapters.synologychat import SynologyChatSecurityPolicy policy = SynologyChatSecurityPolicy({"security": {"dm_policy": "open"}}) assert policy.dm_policy == "open"