ForcePilot/backend/test/unit/channel/test_config.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

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