mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 12:06:51 +08:00
188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
"""配置快照与窄读写端口测试。"""
|
|
|
|
import asyncio
|
|
from dataclasses import FrozenInstanceError
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from app.application.configuration import (
|
|
ApiRuntimeConfig,
|
|
ChainRuntimeConfig,
|
|
RuntimeConfiguration,
|
|
RuntimeSettingsService,
|
|
SchedulerRuntimeConfig,
|
|
SystemConfigService,
|
|
TransferRetryConfig,
|
|
configure_runtime_configuration,
|
|
configure_transfer_retry_config,
|
|
get_api_runtime_config_snapshot,
|
|
get_transfer_retry_config,
|
|
)
|
|
from app.application.security.userconfig import UserConfigurationService
|
|
from app.runtime.settings import RuntimeSettingsCompat, configure_runtime_settings_compat
|
|
|
|
|
|
class _InlineDatabaseExecutor:
|
|
"""同步执行测试操作,并保留异步应用端口的调用形态。"""
|
|
|
|
async def run(self, operation):
|
|
"""执行并返回操作结果。"""
|
|
return operation()
|
|
|
|
|
|
class _MutableSettings:
|
|
"""记录管理设置服务的读取和更新操作。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化一组可变测试设置。"""
|
|
self.VALUE = "before"
|
|
|
|
def model_dump(self, *, include=None, exclude=None):
|
|
"""按白名单返回设置字典。"""
|
|
values = {"VALUE": self.VALUE, "SECRET": "hidden"}
|
|
if include is not None:
|
|
values = {key: value for key, value in values.items() if key in include}
|
|
if exclude is not None:
|
|
values = {key: value for key, value in values.items() if key not in exclude}
|
|
return values
|
|
|
|
def update_settings(self, env):
|
|
"""批量更新并返回逐项结果。"""
|
|
for key, value in env.items():
|
|
setattr(self, key, value)
|
|
return {key: (True, "") for key in env}
|
|
|
|
def update_setting(self, key, value):
|
|
"""更新单个设置。"""
|
|
setattr(self, key, value)
|
|
return True, ""
|
|
|
|
|
|
def test_runtime_settings_service_hides_mutable_settings_implementation() -> None:
|
|
"""管理 API 通过窄服务读取和修改设置,不依赖全局 Settings 类型。"""
|
|
settings = _MutableSettings()
|
|
service = RuntimeSettingsService(settings)
|
|
|
|
assert service.contains("VALUE")
|
|
assert service.get("VALUE") == "before"
|
|
assert service.snapshot(include={"VALUE"}) == {"VALUE": "before"}
|
|
assert service.update("VALUE", "after") == (True, "")
|
|
assert service.update_many({"VALUE": "final"}) == {"VALUE": (True, "")}
|
|
assert service.get("VALUE") == "final"
|
|
|
|
|
|
def test_runtime_settings_compat_delegates_to_concrete_service_backend() -> None:
|
|
"""兼容 Settings 代理委托到真实设置对象时不会在 model_dump 中递归。"""
|
|
service = RuntimeSettingsService(_MutableSettings())
|
|
configure_runtime_settings_compat(service)
|
|
|
|
assert RuntimeSettingsCompat().model_dump(include={"VALUE"}) == {
|
|
"VALUE": "before"
|
|
}
|
|
|
|
|
|
def test_system_config_service_supports_separate_reader_and_writer() -> None:
|
|
"""应用服务可以分别注入只读与写入适配器。"""
|
|
reader = MagicMock()
|
|
reader.get.return_value = "old"
|
|
writer = MagicMock()
|
|
writer.set.return_value = True
|
|
writer.delete.return_value = True
|
|
service = SystemConfigService(
|
|
reader=reader,
|
|
writer=writer,
|
|
async_executor=_InlineDatabaseExecutor(),
|
|
)
|
|
|
|
assert service.get("key") == "old"
|
|
assert service.set("key", "new") is True
|
|
assert asyncio.run(service.async_set("key", "new")) is True
|
|
service.delete("key")
|
|
assert asyncio.run(service.async_delete("key")) is True
|
|
|
|
reader.get.assert_called_once_with("key")
|
|
assert writer.set.call_args_list == [
|
|
(("key", "new"), {}),
|
|
(("key", "new"), {}),
|
|
]
|
|
assert writer.delete.call_args_list == [
|
|
(("key",), {}),
|
|
(("key",), {}),
|
|
]
|
|
|
|
|
|
def test_user_configuration_service_supports_sync_and_async_writes() -> None:
|
|
"""用户配置服务的同步与异步入口执行同一仓储方法。"""
|
|
repository = MagicMock()
|
|
repository.set.return_value = True
|
|
service = UserConfigurationService(
|
|
repository,
|
|
async_executor=_InlineDatabaseExecutor(),
|
|
)
|
|
|
|
assert service.set("alice", "theme", "dark") is True
|
|
assert asyncio.run(service.async_set("alice", "theme", "light")) is True
|
|
assert repository.set.call_args_list == [
|
|
((), {"username": "alice", "key": "theme", "value": "dark"}),
|
|
((), {"username": "alice", "key": "theme", "value": "light"}),
|
|
]
|
|
|
|
|
|
def test_transfer_retry_provider_returns_frozen_snapshot_per_call() -> None:
|
|
"""配置工厂在每次用例入口创建新快照,旧快照不受 reload 后状态影响。"""
|
|
state = {"value": 2}
|
|
configure_transfer_retry_config(
|
|
lambda: TransferRetryConfig(max_failed_retries=state["value"])
|
|
)
|
|
|
|
before_reload = get_transfer_retry_config()
|
|
state["value"] = 4
|
|
after_reload = get_transfer_retry_config()
|
|
|
|
assert before_reload.max_failed_retries == 2
|
|
assert after_reload.max_failed_retries == 4
|
|
|
|
|
|
def test_api_runtime_provider_returns_frozen_snapshot_per_request() -> None:
|
|
"""API 每次请求读取新配置,但已取得的快照保持不变。"""
|
|
state = {"enabled": False}
|
|
configure_runtime_configuration(
|
|
RuntimeConfiguration(
|
|
api=lambda: ApiRuntimeConfig(
|
|
advanced_mode=False,
|
|
access_token_expire_minutes=60,
|
|
btrfs_fsid_dedup=False,
|
|
ai_agent_enable=state["enabled"],
|
|
),
|
|
scheduler=lambda: SchedulerRuntimeConfig(
|
|
False, "Asia/Shanghai", 1, False, "", None, None, False,
|
|
24, "rss", 30, False, None, None, False, None, False, None,
|
|
),
|
|
chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)),
|
|
)
|
|
)
|
|
|
|
before_reload = get_api_runtime_config_snapshot()
|
|
state["enabled"] = True
|
|
after_reload = get_api_runtime_config_snapshot()
|
|
|
|
assert before_reload.ai_agent_enable is False
|
|
assert after_reload.ai_agent_enable is True
|
|
|
|
|
|
def test_chain_runtime_config_is_an_instance_scoped_frozen_snapshot() -> None:
|
|
"""Chain 配置应随实例固定,避免同一业务调用中途读取到 reload 后的新值。"""
|
|
snapshot = ChainRuntimeConfig(
|
|
media_extensions=(".mkv",),
|
|
superuser="root",
|
|
media_recognize_share=True,
|
|
resource_url="https://example.test/#/resource",
|
|
)
|
|
|
|
assert snapshot.superuser == "root"
|
|
assert snapshot.media_recognize_share is True
|
|
assert snapshot.resource_url == "https://example.test/#/resource"
|
|
with pytest.raises(FrozenInstanceError):
|
|
snapshot.superuser = "changed" # type: ignore[misc]
|