"""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