412 lines
15 KiB
Python
412 lines
15 KiB
Python
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")
|