from __future__ import annotations from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channel.config import CONFIG_CHANGE_CHANNEL, ChannelConfigManager @pytest.fixture def route_cache(): cache = AsyncMock() cache.invalidate_by_account = AsyncMock() return cache @pytest.fixture def manager(route_cache): return ChannelConfigManager(route_cache=route_cache) @pytest.fixture def sample_config(): return { "id": "550e8400-e29b-41d4-a716-446655440000", "channel_type": "feishu", "account_id": "acc-1", "name": "测试账户", "enabled": True, "config_json": {"webhook_url": "https://example.com"}, } def _make_channel_config(**overrides): config = MagicMock() config.id = "550e8400-e29b-41d4-a716-446655440000" config.channel_type = "feishu" config.account_id = "acc-1" config.name = "测试账户" config.enabled = True config.config_json = {"webhook_url": "https://example.com"} config.created_by = "user-1" config.updated_by = "user-1" config.created_at = datetime.now(UTC) config.updated_at = datetime.now(UTC) for key, value in overrides.items(): setattr(config, key, value) return config async def test_list_enabled_accounts(manager, sample_config): mock_config = _make_channel_config() session = MagicMock() result = MagicMock() result.scalars().all.return_value = [mock_config] session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) accounts = await manager.list_enabled_accounts() assert len(accounts) == 1 assert accounts[0]["channel_type"] == "feishu" assert accounts[0]["account_id"] == "acc-1" assert accounts[0]["enabled"] is True async def test_get_config_found(manager, sample_config): mock_config = _make_channel_config() session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=mock_config) session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) config = await manager.get_config("feishu", "acc-1") assert config["channel_type"] == "feishu" assert config["account_id"] == "acc-1" assert config["webhook_url"] == "https://example.com" async def test_get_config_not_found(manager): session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=None) session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) with pytest.raises(ValueError, match="Channel config not found"): await manager.get_config("feishu", "missing") async def test_list_all_accounts(manager): mock_config = _make_channel_config() session = MagicMock() result = MagicMock() result.scalars().all.return_value = [mock_config] session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) accounts = await manager.list_all_accounts() assert len(accounts) == 1 assert accounts[0]["config_json"] == {"webhook_url": "https://example.com"} async def test_create_config_success(manager, route_cache, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = { "type": "object", "required": ["webhook_url"], "properties": {"webhook_url": {"type": "string"}}, } plugin.validate_config.return_value = (True, []) registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) redis = AsyncMock() monkeypatch.setattr("yuxi.channel.config.get_redis_client", AsyncMock(return_value=redis)) session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=None) session.execute = AsyncMock(return_value=result) session.add = MagicMock() session.commit = AsyncMock() session.refresh = AsyncMock() with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) created = await manager.create_config( "feishu", "acc-1", {"webhook_url": "https://example.com"}, name="测试账户" ) assert created["channel_type"] == "feishu" assert created["account_id"] == "acc-1" assert created["enabled"] is False route_cache.invalidate_by_account.assert_awaited_once_with("feishu", "acc-1") redis.publish.assert_awaited_once() assert redis.publish.await_args.args[0] == CONFIG_CHANGE_CHANNEL async def test_create_config_validation_fails(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = { "type": "object", "required": ["webhook_url"], "properties": {"webhook_url": {"type": "string"}}, } plugin.validate_config.return_value = (False, ["invalid webhook"]) registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) with pytest.raises(ValueError, match="Config validation failed"): await manager.create_config("feishu", "acc-1", {"webhook_url": "bad"}) async def test_create_config_missing_required(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = { "type": "object", "required": ["webhook_url"], "properties": {"webhook_url": {"type": "string"}}, } registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) with pytest.raises(ValueError, match="Missing required config fields"): await manager.create_config("feishu", "acc-1", {}) async def test_create_config_unknown_channel(manager, monkeypatch): registry = MagicMock() registry.get_plugin.return_value = None monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) with pytest.raises(ValueError, match="Unknown channel type"): await manager.create_config("unknown", "acc-1", {}) async def test_create_config_already_exists(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = {"type": "object", "properties": {}} plugin.validate_config.return_value = (True, []) registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) existing = _make_channel_config() session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=existing) session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) with pytest.raises(ValueError, match="Channel config already exists"): await manager.create_config("feishu", "acc-1", {}) async def test_update_config_success(manager, route_cache, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = { "type": "object", "properties": {"webhook_url": {"type": "string"}}, } plugin.validate_config.return_value = (True, []) registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) redis = AsyncMock() monkeypatch.setattr("yuxi.channel.config.get_redis_client", AsyncMock(return_value=redis)) existing = _make_channel_config(enabled=False) session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=existing) session.execute = AsyncMock(return_value=result) session.commit = AsyncMock() session.refresh = AsyncMock() with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) updated = await manager.update_config( "feishu", "acc-1", config_json={"webhook_url": "https://new.com"}, enabled=True ) assert updated["enabled"] is True route_cache.invalidate_by_account.assert_awaited_once_with("feishu", "acc-1") redis.publish.assert_awaited_once() async def test_update_config_not_found(manager): session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=None) session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) with pytest.raises(ValueError, match="Channel config not found"): await manager.update_config("feishu", "missing", name="x") async def test_delete_config_success(manager, route_cache, monkeypatch): redis = AsyncMock() monkeypatch.setattr("yuxi.channel.config.get_redis_client", AsyncMock(return_value=redis)) existing = _make_channel_config() session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=existing) session.execute = AsyncMock(return_value=result) session.delete = AsyncMock() session.commit = AsyncMock() with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) deleted = await manager.delete_config("feishu", "acc-1") assert deleted is True route_cache.invalidate_by_account.assert_awaited_once_with("feishu", "acc-1") async def test_delete_config_not_found(manager): session = MagicMock() result = MagicMock() result.scalar_one_or_none = MagicMock(return_value=None) session.execute = AsyncMock(return_value=result) with patch("yuxi.channel.config.pg_manager.get_async_session_context") as ctx: ctx.return_value.__aenter__ = AsyncMock(return_value=session) ctx.return_value.__aexit__ = AsyncMock(return_value=False) deleted = await manager.delete_config("feishu", "missing") assert deleted is False async def test_validate_config_json_type_errors(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = { "type": "object", "properties": { "text": {"type": "string"}, "count": {"type": "integer"}, "ratio": {"type": "number"}, "flag": {"type": "boolean"}, "items": {"type": "array"}, "meta": {"type": "object"}, }, } registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) with pytest.raises(ValueError, match="Invalid config types"): manager.validate_config_json( "feishu", { "text": 123, "count": "1", "ratio": "0.5", "flag": "true", "items": "x", "meta": "y", }, ) def test_validate_config_json_middlewares_ok(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = {"type": "object", "properties": {}} registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) manager.validate_config_json( "feishu", { "inbound_middlewares": [ {"name": "dedupe", "enabled": True, "order": 100}, {"name": "route", "enabled": False, "config": {"x": 1}}, ], "outbound_middlewares": [ {"name": "send", "enabled": True, "order": 700}, ], "security_checkers": [ {"name": "allowlist", "enabled": True, "priority": 1}, ], }, ) def test_validate_config_json_middlewares_invalid(manager, monkeypatch): plugin = MagicMock() plugin.get_meta.return_value.config_schema = {"type": "object", "properties": {}} registry = MagicMock() registry.get_plugin.return_value = plugin monkeypatch.setattr("yuxi.channel.config.get_registry", lambda: registry) with pytest.raises(ValueError, match="inbound_middlewares must be an array"): manager.validate_config_json("feishu", {"inbound_middlewares": "x"}) with pytest.raises(ValueError, match="outbound_middlewares\\[0\\] missing required field 'enabled'"): manager.validate_config_json("feishu", {"outbound_middlewares": [{"name": "send"}]}) with pytest.raises(ValueError, match="security_checkers\\[0\\]\\.name must be a string"): manager.validate_config_json("feishu", {"security_checkers": [{"name": 1, "enabled": True}]}) with pytest.raises(ValueError, match="inbound_middlewares\\[0\\] order/priority must be an integer"): manager.validate_config_json("feishu", {"inbound_middlewares": [{"name": "x", "enabled": True, "order": "1"}]}) with pytest.raises(ValueError, match="outbound_middlewares\\[0\\]\\.config must be an object"): manager.validate_config_json( "feishu", {"outbound_middlewares": [{"name": "x", "enabled": True, "config": "y"}]} ) def test_to_runtime_config_prefers_model_fields(manager): config = _make_channel_config(config_json={"channel_type": "fake", "account_id": "fake"}) runtime = manager._to_runtime_config(config) assert runtime["channel_type"] == "feishu" assert runtime["account_id"] == "acc-1" assert runtime["enabled"] is True def test_to_config_dict_structure(manager): config = _make_channel_config() data = manager.to_config_dict(config) assert data["id"] == str(config.id) assert data["channel_type"] == "feishu" assert data["config_json"] == {"webhook_url": "https://example.com"} assert data["created_at"] == config.created_at.isoformat() async def test_publish_config_change_logs_exception(manager, monkeypatch): monkeypatch.setattr( "yuxi.channel.config.get_redis_client", AsyncMock(side_effect=RuntimeError("redis down")), ) await manager._publish_config_change("feishu", "acc-1", "created")