1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
426 lines
15 KiB
Python
426 lines
15 KiB
Python
"""MSTeams accounts / audit / connection_modes / group_mgmt / setup_wizard 单元测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.msteams.accounts import AccountManager
|
|
from yuxi.channels.adapters.msteams.audit import (
|
|
GraphPermissionAuditor,
|
|
REQUIRED_GRAPH_PERMISSIONS,
|
|
)
|
|
from yuxi.channels.adapters.msteams.connection_modes import (
|
|
WebSocketClient,
|
|
PollingClient,
|
|
ConnectionModeManager,
|
|
CONNECTION_MODE_WEBHOOK,
|
|
CONNECTION_MODE_WEBSOCKET,
|
|
CONNECTION_MODE_POLLING,
|
|
)
|
|
from yuxi.channels.adapters.msteams.setup_wizard import (
|
|
MSTeamsSetupWizard,
|
|
_validate_and_normalize_answers,
|
|
)
|
|
|
|
|
|
class TestAccountManager:
|
|
@pytest.fixture
|
|
def mgr(self):
|
|
return AccountManager()
|
|
|
|
def test_register_account(self, mgr):
|
|
mgr.register_account("acct-1", "app-id", "app-secret", tenant_id="t1", label="Account 1")
|
|
account = mgr.get_account("acct-1")
|
|
assert account is not None
|
|
assert account["app_id"] == "app-id"
|
|
assert account["app_password"] == "app-secret"
|
|
assert account["tenant_id"] == "t1"
|
|
assert account["label"] == "Account 1"
|
|
assert account["status"] == "registered"
|
|
|
|
def test_register_account_default_label(self, mgr):
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
|
account = mgr.get_account("acct-1")
|
|
assert account["label"] == "acct-1"
|
|
|
|
def test_unregister_account(self, mgr):
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
|
result = mgr.unregister_account("acct-1")
|
|
assert result is True
|
|
assert mgr.get_account("acct-1") is None
|
|
|
|
def test_unregister_nonexistent(self, mgr):
|
|
result = mgr.unregister_account("nonexistent")
|
|
assert result is False
|
|
|
|
def test_get_all_accounts(self, mgr):
|
|
mgr.register_account("a1", "id1", "pw1")
|
|
mgr.register_account("a2", "id2", "pw2")
|
|
accounts = mgr.get_all_accounts()
|
|
assert len(accounts) == 2
|
|
|
|
def test_list_account_ids(self, mgr):
|
|
mgr.register_account("a1", "id1", "pw1")
|
|
mgr.register_account("a2", "id2", "pw2")
|
|
ids = mgr.list_account_ids()
|
|
assert "a1" in ids
|
|
assert "a2" in ids
|
|
|
|
def test_account_count(self, mgr):
|
|
assert mgr.account_count == 0
|
|
mgr.register_account("a1", "id1", "pw1")
|
|
assert mgr.account_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_account_nonexistent(self, mgr):
|
|
result = await mgr.probe_account("nonexistent")
|
|
assert result["status"] == "error"
|
|
assert "not found" in result["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_account(self, mgr):
|
|
mgr.register_account("acct-1", "app-id", "app-secret", tenant_id="t1")
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.config = {}
|
|
result = await mgr.start_account("acct-1", adapter=mock_adapter)
|
|
assert result is True
|
|
assert mock_adapter.config["app_id"] == "app-id"
|
|
assert mock_adapter.config["app_password"] == "app-secret"
|
|
assert mock_adapter.config["tenant_id"] == "t1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_account_nonexistent(self, mgr):
|
|
result = await mgr.start_account("nonexistent")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_account_without_adapter(self, mgr):
|
|
mgr.register_account("acct-1", "id", "pw")
|
|
result = await mgr.start_account("acct-1")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_account_no_tenant(self, mgr):
|
|
mgr.register_account("acct-1", "app-id", "app-secret")
|
|
mock_adapter = MagicMock()
|
|
mock_adapter.config = {}
|
|
result = await mgr.start_account("acct-1", adapter=mock_adapter)
|
|
assert result is True
|
|
assert "tenant_id" not in mock_adapter.config
|
|
|
|
|
|
class TestGraphPermissionAuditor:
|
|
def test_init(self):
|
|
auditor = GraphPermissionAuditor(token="fake-token", app_id="fake-app")
|
|
assert auditor._token == "fake-token"
|
|
assert auditor._app_id == "fake-app"
|
|
|
|
def test_parse_oauth2_permissions_empty(self):
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
|
result = auditor._parse_oauth2_permissions({"value": []})
|
|
assert result == set()
|
|
|
|
def test_parse_oauth2_permissions_with_data(self):
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
|
sp_data = {
|
|
"value": [{
|
|
"oauth2PermissionScopes": [
|
|
{"value": "scope1"},
|
|
{"value": "scope2"},
|
|
],
|
|
}],
|
|
}
|
|
result = auditor._parse_oauth2_permissions(sp_data)
|
|
assert result == {"scope1", "scope2"}
|
|
|
|
def test_get_missing_permissions(self):
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
|
audit_result = {"perm1": True, "perm2": False, "perm3": False}
|
|
missing = auditor.get_missing_permissions(audit_result)
|
|
assert missing == ["perm2", "perm3"]
|
|
|
|
def test_get_missing_permissions_all_granted(self):
|
|
auditor = GraphPermissionAuditor(token="t", app_id="a")
|
|
audit_result = {"perm1": True, "perm2": True}
|
|
missing = auditor.get_missing_permissions(audit_result)
|
|
assert missing == []
|
|
|
|
def test_required_permissions_list(self):
|
|
assert "User.Read.All" in REQUIRED_GRAPH_PERMISSIONS
|
|
assert "ChannelMessage.Send" in REQUIRED_GRAPH_PERMISSIONS
|
|
|
|
def test_format_audit_display(self):
|
|
data = {
|
|
"scopes": [{"value": "scope1", "type": "Admin"}],
|
|
"roles": [{"value": "role1", "display_name": "Role 1"}],
|
|
}
|
|
result = GraphPermissionAuditor.format_audit_display(data)
|
|
assert "scope1" in result
|
|
assert "role1" in result
|
|
|
|
def test_format_audit_display_empty(self):
|
|
result = GraphPermissionAuditor.format_audit_display({})
|
|
assert result == "No scopes or roles found"
|
|
|
|
|
|
class TestWebSocketClient:
|
|
@pytest.fixture
|
|
def ws_client(self):
|
|
return WebSocketClient(app_id="test-id", app_password="test-secret")
|
|
|
|
def test_init_defaults(self, ws_client):
|
|
assert ws_client._app_id == "test-id"
|
|
assert ws_client._app_password == "test-secret"
|
|
assert ws_client._running is False
|
|
assert ws_client._ws is None
|
|
|
|
def test_set_message_handler(self, ws_client):
|
|
async def handler(msg):
|
|
pass
|
|
|
|
ws_client.set_message_handler(handler)
|
|
assert ws_client._on_message is handler
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_already_running(self, ws_client):
|
|
ws_client._running = True
|
|
await ws_client.connect()
|
|
assert ws_client._ws is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_activity_closed(self, ws_client):
|
|
result = await ws_client.send_activity({"type": "message"})
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_sets_running_false(self, ws_client):
|
|
ws_client._running = True
|
|
await ws_client.close()
|
|
assert ws_client._running is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_with_session(self, ws_client):
|
|
mock_session = MagicMock()
|
|
mock_session.closed = False
|
|
mock_session.close = AsyncMock()
|
|
ws_client._session = mock_session
|
|
ws_client._ws = Mock()
|
|
ws_client._ws.closed = False
|
|
ws_client._ws.close = AsyncMock()
|
|
await ws_client.close()
|
|
assert ws_client._session is None
|
|
assert ws_client._ws is None
|
|
|
|
|
|
class TestPollingClient:
|
|
@pytest.fixture
|
|
def poll_client(self):
|
|
return PollingClient(app_id="test-id", app_password="test-secret")
|
|
|
|
def test_init_defaults(self, poll_client):
|
|
assert poll_client._app_id == "test-id"
|
|
assert poll_client._running is False
|
|
|
|
def test_set_message_handler(self, poll_client):
|
|
async def handler(msg):
|
|
pass
|
|
|
|
poll_client.set_message_handler(handler)
|
|
assert poll_client._on_message is handler
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_already_running(self, poll_client):
|
|
poll_client._running = True
|
|
await poll_client.connect()
|
|
assert poll_client._session is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_sets_running_false(self, poll_client):
|
|
poll_client._running = True
|
|
await poll_client.close()
|
|
assert poll_client._running is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_with_session(self, poll_client):
|
|
mock_session = MagicMock()
|
|
mock_session.closed = False
|
|
mock_session.close = AsyncMock()
|
|
poll_client._session = mock_session
|
|
await poll_client.close()
|
|
assert poll_client._session is None
|
|
|
|
|
|
class TestConnectionModeManager:
|
|
def test_default_mode(self):
|
|
mgr = ConnectionModeManager({})
|
|
assert mgr.mode == CONNECTION_MODE_WEBHOOK
|
|
assert mgr.is_webhook is True
|
|
assert mgr.is_websocket is False
|
|
assert mgr.is_polling is False
|
|
|
|
def test_custom_mode_websocket(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
|
assert mgr.mode == CONNECTION_MODE_WEBSOCKET
|
|
assert mgr.is_websocket is True
|
|
|
|
def test_custom_mode_polling(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_POLLING})
|
|
assert mgr.mode == CONNECTION_MODE_POLLING
|
|
assert mgr.is_polling is True
|
|
|
|
def test_invalid_mode_fallback(self):
|
|
mgr = ConnectionModeManager({"connection_mode": "invalid"})
|
|
assert mgr.mode == CONNECTION_MODE_WEBHOOK
|
|
|
|
def test_set_message_router(self):
|
|
mgr = ConnectionModeManager({})
|
|
|
|
async def router(msg):
|
|
pass
|
|
|
|
mgr.set_message_router(router)
|
|
assert mgr._message_router is router
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_webhook_mode(self):
|
|
mgr = ConnectionModeManager({})
|
|
await mgr.start("app-id", "app-secret")
|
|
assert mgr._ws_client is None
|
|
assert mgr._poll_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_websocket_mode(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
|
with patch.object(WebSocketClient, "connect", new_callable=AsyncMock) as mock_connect:
|
|
await mgr.start("app-id", "app-secret")
|
|
assert mgr._ws_client is not None
|
|
mock_connect.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_polling_mode(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_POLLING})
|
|
with patch.object(PollingClient, "connect", new_callable=AsyncMock) as mock_connect:
|
|
await mgr.start("app-id", "app-secret")
|
|
assert mgr._poll_client is not None
|
|
mock_connect.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
|
mgr._ws_client = MagicMock()
|
|
mgr._ws_client.closed = False
|
|
mgr._ws_client.close = AsyncMock()
|
|
mgr._poll_client = MagicMock()
|
|
mgr._poll_client.closed = False
|
|
mgr._poll_client.close = AsyncMock()
|
|
await mgr.stop()
|
|
assert mgr._ws_client is None
|
|
assert mgr._poll_client is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_activity_webhook(self):
|
|
mgr = ConnectionModeManager({})
|
|
result = await mgr.send_activity({"type": "message"})
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_activity_websocket_success(self):
|
|
mgr = ConnectionModeManager({"connection_mode": CONNECTION_MODE_WEBSOCKET})
|
|
mock_ws = MagicMock()
|
|
mock_ws.send_activity = AsyncMock(return_value=True)
|
|
mgr._ws_client = mock_ws
|
|
result = await mgr.send_activity({"type": "message"})
|
|
assert result is True
|
|
|
|
|
|
class TestSetupWizardNonInteractive:
|
|
def test_basic_answers(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"app_id": "test-app",
|
|
"app_password": "test-pw",
|
|
"dm_policy": "open",
|
|
"group_policy": "open",
|
|
"streaming_mode": "block",
|
|
})
|
|
assert config["app_id"] == "test-app"
|
|
assert config["dm_policy"] == "open"
|
|
|
|
def test_allow_list_parsing(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"allow_from": "user-1,user-2 , user-3",
|
|
"dm_policy": "allowlist",
|
|
})
|
|
assert config["allow_from"] == ["user-1", "user-2", "user-3"]
|
|
|
|
def test_allow_list_as_list(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"allow_from": ["user-1", "user-2"],
|
|
"dm_policy": "allowlist",
|
|
})
|
|
assert config["allow_from"] == ["user-1", "user-2"]
|
|
|
|
def test_bool_keys(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"feedback_enabled": True,
|
|
"welcome_card": False,
|
|
})
|
|
assert config["feedback_enabled"] is True
|
|
assert config["welcome_card"] is False
|
|
|
|
def test_oauth_enabled(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"oauth_enabled": True,
|
|
"oauth_scopes": ["scope1"],
|
|
})
|
|
assert config["delegated_auth"]["enabled"] is True
|
|
assert "scope1" in config["delegated_auth"]["scopes"]
|
|
|
|
def test_sso_connection(self):
|
|
wizard = MSTeamsSetupWizard()
|
|
config = wizard.run_non_interactive({
|
|
"sso_enabled": True,
|
|
"sso_connection_name": "my-conn",
|
|
})
|
|
assert config["sso"]["enabled"] is True
|
|
assert config["sso"]["connection_name"] == "my-conn"
|
|
|
|
def test_save_config(self, tmp_path):
|
|
wizard = MSTeamsSetupWizard({"app_id": "test"})
|
|
filepath = wizard.save_config(str(tmp_path / "config.json"))
|
|
assert "config.json" in filepath
|
|
|
|
def test_existing_config_preserved(self):
|
|
wizard = MSTeamsSetupWizard({"app_id": "old-id", "extra_key": "extra"})
|
|
config = wizard.run_non_interactive({"app_id": "new-id"})
|
|
assert config["app_id"] == "new-id"
|
|
assert config["extra_key"] == "extra"
|
|
|
|
|
|
class TestValidateAnswers:
|
|
def test_str_keys(self):
|
|
result = _validate_and_normalize_answers({
|
|
"app_id": "test-id",
|
|
"dm_policy": "open",
|
|
})
|
|
assert result["app_id"] == "test-id"
|
|
assert result["dm_policy"] == "open"
|
|
|
|
def test_empty_str_skipped(self):
|
|
result = _validate_and_normalize_answers({"app_id": ""})
|
|
assert "app_id" not in result
|
|
|
|
def test_group_allow_from_comma(self):
|
|
result = _validate_and_normalize_answers({"group_allow_from": "g1,g2,g3"})
|
|
assert result["group_allow_from"] == ["g1", "g2", "g3"]
|
|
|
|
def test_invalid_bool_cast(self):
|
|
result = _validate_and_normalize_answers({"feedback_enabled": "yes"})
|
|
assert result["feedback_enabled"] is True |