Files
MoviePilot/tests/test_system_settings_contract.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

230 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 系统设置完整合同、Schema、写入边界和并发语义测试。
"""
from contextlib import nullcontext
from unittest.mock import AsyncMock, MagicMock
import pytest
import app.application.settings as settings_module
from app.agent.policy.api import API_OPERATION_ROUTES
from app.agent.tools.impl.api import MoviePilotApiTool
from app.application.settings.contract import (
ALL_SETTING_SPECS,
CORE_SETTING_SPECS,
SETTING_GROUPS,
SYSTEMCONFIG_SETTING_SPECS,
build_value_schema,
validate_setting_value,
)
from app.runtime.config import Settings
from app.schemas.types import SystemConfigKey
def test_subscription_search_strategy_contract_declares_three_choices() -> None:
"""
订阅搜索策略 Schema 明确声明智能、完整和单页三种选项。
"""
from pydantic import ValidationError
spec = ALL_SETTING_SPECS[SystemConfigKey.SubscribeSearchStrategy.value]
schema = build_value_schema(spec)
assert schema["anyOf"][0]["enum"] == ["smart", "full", "single_page"]
for value in (None, "smart", "full", "single_page"):
validate_setting_value(spec, value)
for value in (True, "unknown", {"mode": "full"}):
with pytest.raises(ValidationError):
validate_setting_value(spec, value)
def test_settings_contract_covers_every_declared_source() -> None:
"""
合同必须覆盖全部 Settings 字段和全部 SystemConfigKey,且每项有来源说明。
"""
assert set(CORE_SETTING_SPECS) == set(Settings.model_fields)
assert set(SYSTEMCONFIG_SETTING_SPECS) == {item.value for item in SystemConfigKey}
assert len(ALL_SETTING_SPECS) == len(Settings.model_fields) + len(SystemConfigKey)
assert SETTING_GROUPS
for spec in ALL_SETTING_SPECS.values():
assert spec.description
assert spec.group
assert spec.source_file
assert spec.source_line > 0
schema = build_value_schema(spec)
assert schema.get("description") == spec.description
assert isinstance(schema, dict)
def test_settings_contract_exposes_machine_readable_boundaries() -> None:
"""
合同应公开单位、示例、依赖和复杂设置的专用 operation 边界。
"""
runtime_spec = ALL_SETTING_SPECS["ACCESS_TOKEN_EXPIRE_MINUTES"]
assert runtime_spec.unit == "minutes"
assert runtime_spec.examples
runtime_schema = build_value_schema(runtime_spec)
assert runtime_schema["x-unit"] == "minutes"
assert runtime_schema["examples"] == list(runtime_spec.examples)
assert build_value_schema(ALL_SETTING_SPECS["API_WORKERS"])["minimum"] == 1
managed = ALL_SETTING_SPECS[SystemConfigKey.CustomFilterRules.value]
assert managed.generic_write_allowed is False
assert managed.update_operations == ()
assert managed.preferred_operation_ids
assert managed.apply_mode == "dedicated_operation"
dependent = ALL_SETTING_SPECS[SystemConfigKey.SearchFilterRuleGroups.value]
assert SystemConfigKey.UserFilterRuleGroups.value in dependent.dependencies
def test_settings_catalog_is_lightweight_and_value_free() -> None:
"""
list 阶段只返回合同索引,不应读取当前配置值。
"""
runtime = MagicMock()
system = MagicMock()
service = settings_module.SystemSettingsService(runtime, system, AsyncMock())
result = service.catalog(group="ai_agent", limit=2)
assert result["returned_count"] <= 2
assert result["settings"]
assert all("value" not in item for item in result["settings"])
assert all("value_schema" not in item for item in result["settings"])
assert all("update_operations" in item for item in result["settings"])
runtime.get.assert_not_called()
system.get.assert_not_called()
def test_settings_describe_returns_schema_value_and_revision() -> None:
"""
describe 阶段应返回单项完整 schema、当前值和稳定 revision。
"""
runtime = MagicMock()
runtime.get.return_value = "gpt-test"
system = MagicMock()
service = settings_module.SystemSettingsService(runtime, system, AsyncMock())
result = service.describe(setting_key="LLM_MODEL")
assert result["setting_key"] == "LLM_MODEL"
assert result["value"] == "gpt-test"
assert result["revision"].startswith("sha256:")
assert result["definition"]["value_schema"]["type"] == "string"
assert result["definition"]["persistence"] == "app.env"
def test_structured_sensitive_settings_preserve_non_secret_match_fields() -> None:
"""
复杂敏感配置只隐藏凭据字段,保留名称和类型供 Agent 定位条目。
"""
spec = ALL_SETTING_SPECS[SystemConfigKey.Notifications.value]
redacted = settings_module.redact_setting_value(
spec,
[{"name": "telegram-main", "type": "telegram", "config": {"token": "secret"}}],
)
assert redacted[0]["name"] == "telegram-main"
assert redacted[0]["type"] == "telegram"
assert redacted[0]["config"]["token"] == "***"
@pytest.mark.asyncio
async def test_settings_update_rejects_managed_and_internal_generic_writes(monkeypatch) -> None:
"""
通用写入口必须拒绝专用 operation 和内部状态设置。
"""
runtime = MagicMock()
system = MagicMock()
publish = AsyncMock()
monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext())
service = settings_module.SystemSettingsService(runtime, system, publish)
with pytest.raises(ValueError, match="禁止通用写入"):
await service.update(
setting_key=SystemConfigKey.CustomFilterRules.value,
value=[],
)
with pytest.raises(ValueError, match="内部状态"):
await service.update(
setting_key=SystemConfigKey.AIAgentConfig.value,
value={},
)
runtime.update.assert_not_called()
system.async_set_with_normalized_value.assert_not_called()
@pytest.mark.asyncio
async def test_runtime_settings_revision_is_optional_but_protects_conditional_writes(monkeypatch) -> None:
"""
旧调用可省略 revision,新调用提供旧 revision 时必须拒绝并发覆盖。
"""
runtime = MagicMock()
state = {"value": "old"}
runtime.get.side_effect = lambda _key: state["value"]
def update(_key, value):
"""
更新测试中的运行时设置快照。
"""
state["value"] = value
return True, "updated"
runtime.update.side_effect = update
system = MagicMock()
publish = AsyncMock()
monkeypatch.setattr(settings_module, "plugin_system_config_mutation", lambda _key: nullcontext())
service = settings_module.SystemSettingsService(runtime, system, publish)
legacy = await service.update(setting_key="LLM_MODEL", value="legacy")
assert legacy["changed"] is True
revision = legacy["revision"]
success = await service.update(
setting_key="LLM_MODEL",
value="next",
expected_revision=revision,
)
assert success["saved_value"] == "next"
with pytest.raises(settings_module.SystemSettingConflictError, match="expected_revision"):
await service.update(
setting_key="LLM_MODEL",
value="stale",
expected_revision=revision,
)
assert state["value"] == "next"
def test_setting_key_resolution_accepts_enum_names_and_values() -> None:
"""
设置解析应同时支持 SystemConfigKey 的枚举名和值。
"""
by_name = settings_module.resolve_setting_spec(SystemConfigKey.Downloaders.name)
by_value = settings_module.resolve_setting_spec(SystemConfigKey.Downloaders.value)
assert by_name is not None
assert by_name == by_value
assert by_name.systemconfig_key is SystemConfigKey.Downloaders
def test_system_setting_operations_expose_the_three_stage_contract() -> None:
"""
Agent 网关必须公开 list、describe、get 和 update 的固定路由与参数分支。
"""
assert API_OPERATION_ROUTES["config.system.list"].method == "GET"
assert API_OPERATION_ROUTES["config.system.list"].path == "/api/v1/system/settings/catalog"
assert API_OPERATION_ROUTES["config.system.describe"].method == "GET"
assert API_OPERATION_ROUTES["config.system.describe"].path == "/api/v1/system/settings/describe/{setting_key}"
schema = MoviePilotApiTool(session_id="contract", user_id="1").get_mcp_input_schema()
branches = {
item["properties"]["operation_id"]["const"]: item
for item in schema["oneOf"]
}
assert branches["config.system.list"]["properties"]["query"]["properties"]["limit"]["maximum"] == 500
assert branches["config.system.describe"]["properties"]["path_params"]["required"] == ["setting_key"]
update_body_ref = branches["config.system.update"]["properties"]["body"]["$ref"]
update_body = schema["$defs"][update_body_ref.rsplit("/", 1)[-1]]
assert "expected_revision" in update_body["properties"]