ForcePilot/backend/test/unit/channels/test_synologychat_adapter.py
Kris fbe47b6393 refactor(webhook,tests): 完善webhook日志与多适配器测试修复
1.  webhook路由新增记录请求源IP与请求头
2.  修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑
3.  新增Nostr适配器测试通用fixture
4.  更新NextcloudTalk签名相关测试与函数命名
2026-05-12 14:52:12 +08:00

1379 lines
50 KiB
Python

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"