mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
refactor(database): isolate configuration transactions
This commit is contained in:
+31
-1
@@ -3,6 +3,7 @@
|
||||
引导与网络守卫均复用 ``app/testing`` 的共享 harness(与插件仓 conftest 同源),
|
||||
引导逻辑只在 ``app/testing`` 维护一处。
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -20,6 +21,14 @@ prepare_backend()
|
||||
from app.testing.network_guard import block_real_network # noqa: E402,F401
|
||||
|
||||
|
||||
class _TestDatabaseExecutor:
|
||||
"""让绕过完整 lifespan 的测试仍通过线程执行同步数据库写入。"""
|
||||
|
||||
async def run(self, operation):
|
||||
"""在线程中执行测试事务。"""
|
||||
return await asyncio.to_thread(operation)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_plugin_system_services():
|
||||
"""为绕过完整启动流程的单元测试装配真实插件系统适配器。"""
|
||||
@@ -61,6 +70,11 @@ def configure_plugin_system_services():
|
||||
configure_transaction_runners,
|
||||
)
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.application.security.userconfig import (
|
||||
UserConfigurationService,
|
||||
configure_user_configuration,
|
||||
)
|
||||
|
||||
configure_token_codec(create_access_token, decode_access_token)
|
||||
configure_runtime_configuration(
|
||||
@@ -73,7 +87,23 @@ def configure_plugin_system_services():
|
||||
configure_runtime_settings(RuntimeSettingsService(settings))
|
||||
configure_runtime_setting_provider(lambda key: getattr(settings, key))
|
||||
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
|
||||
configure_system_config(SystemConfigService(repository=SystemConfigOper()))
|
||||
database_executor = _TestDatabaseExecutor()
|
||||
system_config = SystemConfigOper()
|
||||
system_config.load_snapshot()
|
||||
user_config = UserConfigOper()
|
||||
user_config.load_snapshot()
|
||||
configure_system_config(
|
||||
SystemConfigService(
|
||||
repository=system_config,
|
||||
async_executor=database_executor,
|
||||
)
|
||||
)
|
||||
configure_user_configuration(
|
||||
UserConfigurationService(
|
||||
repository=user_config,
|
||||
async_executor=database_executor,
|
||||
)
|
||||
)
|
||||
configure_transfer_retry_config(
|
||||
lambda: TransferRetryConfig(
|
||||
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
|
||||
|
||||
@@ -138,7 +138,10 @@ async def test_agent_initialization_failure_does_not_stop_module_startup(
|
||||
monkeypatch.setattr(modules_initializer, "start_frontend", start_frontend)
|
||||
monkeypatch.setattr(modules_initializer, "check_auth", check_auth)
|
||||
|
||||
await modules_initializer.init_modules()
|
||||
try:
|
||||
await modules_initializer.init_modules()
|
||||
finally:
|
||||
await modules_initializer.stop_database_worker()
|
||||
|
||||
manager.initialize.assert_awaited_once_with()
|
||||
start_frontend.assert_called_once_with()
|
||||
|
||||
@@ -164,7 +164,13 @@ def test_init_modules_does_not_clear_package_tool_cache(monkeypatch):
|
||||
monkeypatch.setattr(modules_initializer, "start_frontend", lambda: None)
|
||||
monkeypatch.setattr(modules_initializer, "check_auth", lambda: None)
|
||||
|
||||
asyncio.run(modules_initializer.init_modules())
|
||||
async def initialize_modules() -> None:
|
||||
try:
|
||||
await modules_initializer.init_modules()
|
||||
finally:
|
||||
await modules_initializer.stop_database_worker()
|
||||
|
||||
asyncio.run(initialize_modules())
|
||||
|
||||
assert called is False
|
||||
init_agent.assert_awaited_once_with()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""配置快照启动顺序测试。"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.startup import modules_initializer
|
||||
from app.startup.lifecycle import initialize_modules_component
|
||||
|
||||
|
||||
class _InlineWorker:
|
||||
"""按提交顺序执行配置加载操作。"""
|
||||
|
||||
async def run(self, operation):
|
||||
"""执行并返回操作结果。"""
|
||||
return operation()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_services_publish_after_both_snapshots_load(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""两个完整快照加载成功前不发布任一配置服务。"""
|
||||
events = []
|
||||
|
||||
class _SystemConfig:
|
||||
def load_snapshot(self):
|
||||
events.append("load-system")
|
||||
|
||||
class _UserConfig:
|
||||
def load_snapshot(self):
|
||||
events.append("load-user")
|
||||
|
||||
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
|
||||
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"configure_system_config",
|
||||
lambda _service: events.append("publish-system"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"configure_user_configuration",
|
||||
lambda _service: events.append("publish-user"),
|
||||
)
|
||||
|
||||
await modules_initializer._initialize_configuration_services(_InlineWorker())
|
||||
|
||||
assert events == [
|
||||
"load-system",
|
||||
"load-user",
|
||||
"publish-system",
|
||||
"publish-user",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_configuration_load_failure_does_not_publish_partial_service(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""任一快照加载失败时不发布半套配置服务。"""
|
||||
published = []
|
||||
|
||||
class _SystemConfig:
|
||||
def load_snapshot(self):
|
||||
return None
|
||||
|
||||
class _UserConfig:
|
||||
def load_snapshot(self):
|
||||
raise RuntimeError("load failed")
|
||||
|
||||
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
|
||||
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"configure_system_config",
|
||||
lambda service: published.append(service),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
modules_initializer,
|
||||
"configure_user_configuration",
|
||||
lambda service: published.append(service),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="load failed"):
|
||||
await modules_initializer._initialize_configuration_services(_InlineWorker())
|
||||
|
||||
assert published == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modules_startup_failure_stops_database_worker(monkeypatch) -> None:
|
||||
"""模块启动失败时立即关闭已创建的数据库 worker。"""
|
||||
monkeypatch.setattr(
|
||||
"app.startup.lifecycle.init_modules",
|
||||
AsyncMock(side_effect=RuntimeError("startup failed")),
|
||||
)
|
||||
stop_worker = AsyncMock()
|
||||
monkeypatch.setattr(modules_initializer, "stop_database_worker", stop_worker)
|
||||
|
||||
with pytest.raises(RuntimeError, match="startup failed"):
|
||||
await initialize_modules_component(object())
|
||||
|
||||
stop_worker.assert_awaited_once_with()
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -19,6 +19,15 @@ from app.application.configuration import (
|
||||
get_api_runtime_config_snapshot,
|
||||
get_transfer_retry_config,
|
||||
)
|
||||
from app.application.security.userconfig import UserConfigurationService
|
||||
|
||||
|
||||
class _InlineDatabaseExecutor:
|
||||
"""同步执行测试操作,并保留异步应用端口的调用形态。"""
|
||||
|
||||
async def run(self, operation):
|
||||
"""执行并返回操作结果。"""
|
||||
return operation()
|
||||
|
||||
|
||||
class _MutableSettings:
|
||||
@@ -66,23 +75,44 @@ def test_system_config_service_supports_separate_reader_and_writer() -> None:
|
||||
"""应用服务可以分别注入只读与写入适配器。"""
|
||||
reader = MagicMock()
|
||||
reader.get.return_value = "old"
|
||||
reader.async_get = AsyncMock(return_value="async-old")
|
||||
writer = MagicMock()
|
||||
writer.set.return_value = True
|
||||
writer.async_set = AsyncMock(return_value=True)
|
||||
service = SystemConfigService(reader=reader, writer=writer)
|
||||
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_get("key")) == "async-old"
|
||||
assert asyncio.run(service.async_set("key", "new")) is True
|
||||
service.delete("key")
|
||||
|
||||
reader.get.assert_called_once_with("key")
|
||||
writer.set.assert_called_once_with("key", "new")
|
||||
assert writer.set.call_args_list == [
|
||||
(("key", "new"), {}),
|
||||
(("key", "new"), {}),
|
||||
]
|
||||
writer.delete.assert_called_once_with("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}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""数据库短事务 worker 的容量、取消与关闭合同测试。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.worker import (
|
||||
DatabaseWorker,
|
||||
DatabaseWorkerClosedError,
|
||||
DatabaseWorkerOverloadedError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_requires_explicit_start() -> None:
|
||||
"""构造对象不会隐式创建可执行线程池。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=1)
|
||||
|
||||
with pytest.raises(DatabaseWorkerClosedError):
|
||||
await worker.run(lambda: None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_rejects_work_beyond_running_and_queue_capacity() -> None:
|
||||
"""运行与排队任务达到总容量后立即拒绝新任务。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=2)
|
||||
await worker.start()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def block() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
|
||||
running = asyncio.create_task(worker.run(block))
|
||||
await asyncio.to_thread(started.wait)
|
||||
queued = asyncio.create_task(worker.run(lambda: None))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with pytest.raises(DatabaseWorkerOverloadedError):
|
||||
await worker.run(lambda: None)
|
||||
|
||||
assert worker.snapshot().running == 1
|
||||
assert worker.snapshot().queued == 1
|
||||
assert worker.snapshot().rejected == 1
|
||||
|
||||
release.set()
|
||||
await asyncio.gather(running, queued)
|
||||
await worker.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelling_queued_work_prevents_execution() -> None:
|
||||
"""尚未取得线程的任务取消后不得执行数据库操作。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=2)
|
||||
await worker.start()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
queued_executed = threading.Event()
|
||||
|
||||
def block() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
|
||||
running = asyncio.create_task(worker.run(block))
|
||||
await asyncio.to_thread(started.wait)
|
||||
queued = asyncio.create_task(worker.run(queued_executed.set))
|
||||
await asyncio.sleep(0)
|
||||
queued.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await queued
|
||||
|
||||
release.set()
|
||||
await running
|
||||
await worker.shutdown()
|
||||
|
||||
assert queued_executed.is_set() is False
|
||||
assert worker.snapshot().queued == 0
|
||||
assert worker.snapshot().running == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelling_running_work_waits_for_transaction_terminal_state() -> None:
|
||||
"""线程内操作开始后,取消结果必须晚于操作的最终状态。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=1)
|
||||
await worker.start()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
completed = threading.Event()
|
||||
|
||||
def operation() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
completed.set()
|
||||
|
||||
task = asyncio.create_task(worker.run(operation))
|
||||
await asyncio.to_thread(started.wait)
|
||||
task.cancel()
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
assert task.done() is False
|
||||
assert completed.is_set() is False
|
||||
|
||||
release.set()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
assert completed.is_set() is True
|
||||
await worker.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_rejects_new_work_and_waits_for_running_work() -> None:
|
||||
"""关闭期间不接收新任务,并等待已开始的操作结束。"""
|
||||
worker = DatabaseWorker(max_workers=1, capacity=2)
|
||||
await worker.start()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def operation() -> None:
|
||||
started.set()
|
||||
release.wait(1)
|
||||
|
||||
running = asyncio.create_task(worker.run(operation))
|
||||
await asyncio.to_thread(started.wait)
|
||||
shutdown = asyncio.create_task(worker.shutdown())
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with pytest.raises(DatabaseWorkerClosedError):
|
||||
await worker.run(lambda: None)
|
||||
assert shutdown.done() is False
|
||||
|
||||
release.set()
|
||||
await running
|
||||
await shutdown
|
||||
|
||||
assert worker.snapshot().closing is True
|
||||
assert worker.snapshot().queued == 0
|
||||
assert worker.snapshot().running == 0
|
||||
@@ -1,6 +1,7 @@
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -16,15 +17,105 @@ def _unique_key() -> str:
|
||||
|
||||
|
||||
def _fresh_oper() -> SystemConfigOper:
|
||||
"""重置单例并从数据库重新加载配置缓存。"""
|
||||
"""重置单例并显式加载数据库配置快照。"""
|
||||
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
|
||||
return SystemConfigOper()
|
||||
oper = SystemConfigOper()
|
||||
oper.load_snapshot()
|
||||
return oper
|
||||
|
||||
|
||||
def test_constructor_does_not_query_database(monkeypatch):
|
||||
"""构造配置对象时不打开数据库会话。"""
|
||||
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
|
||||
monkeypatch.setattr(
|
||||
SystemConfig,
|
||||
"list",
|
||||
lambda _db: pytest.fail("构造阶段不应查询数据库"),
|
||||
)
|
||||
|
||||
oper = SystemConfigOper()
|
||||
|
||||
with pytest.raises(RuntimeError, match="快照尚未加载"):
|
||||
oper.get("key")
|
||||
|
||||
|
||||
def test_load_snapshot_publishes_complete_dictionary(monkeypatch):
|
||||
"""重新加载期间读取方只会看到完整旧快照或完整新快照。"""
|
||||
Singleton._instances.pop((SystemConfigOper, (), frozenset()), None)
|
||||
oper = SystemConfigOper()
|
||||
values = [SimpleNamespace(key="key", value="old")]
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
monkeypatch.setattr(SystemConfig, "list", lambda _db: values)
|
||||
oper.load_snapshot()
|
||||
|
||||
def load_new_snapshot(_db):
|
||||
entered.set()
|
||||
release.wait(1)
|
||||
return [SimpleNamespace(key="key", value="new")]
|
||||
|
||||
monkeypatch.setattr(SystemConfig, "list", load_new_snapshot)
|
||||
thread = threading.Thread(target=oper.load_snapshot)
|
||||
thread.start()
|
||||
assert entered.wait(1)
|
||||
|
||||
assert oper.get("key") == "old"
|
||||
|
||||
release.set()
|
||||
thread.join(1)
|
||||
assert thread.is_alive() is False
|
||||
assert oper.get("key") == "new"
|
||||
|
||||
|
||||
def test_read_does_not_wait_for_slow_write_transaction(monkeypatch):
|
||||
"""数据库写入期间,内存读取仍返回最近一次已提交值。"""
|
||||
oper = _fresh_oper()
|
||||
key = _unique_key()
|
||||
oper.set(key, "old")
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def slow_write(_operation):
|
||||
entered.set()
|
||||
release.wait(1)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(oper, "_execute_sync_write", slow_write)
|
||||
thread = threading.Thread(target=lambda: oper.set(key, "new"))
|
||||
thread.start()
|
||||
assert entered.wait(1)
|
||||
|
||||
assert oper.get(key) == "old"
|
||||
|
||||
release.set()
|
||||
thread.join(1)
|
||||
assert thread.is_alive() is False
|
||||
assert oper.get(key) == "new"
|
||||
|
||||
|
||||
def test_failed_write_keeps_committed_snapshot(monkeypatch):
|
||||
"""事务失败时内存快照保持最近一次已提交值。"""
|
||||
oper = _fresh_oper()
|
||||
key = _unique_key()
|
||||
oper.set(key, "old")
|
||||
|
||||
def fail_write(_operation):
|
||||
raise RuntimeError("write failed")
|
||||
|
||||
monkeypatch.setattr(oper, "_execute_sync_write", fail_write)
|
||||
|
||||
with pytest.raises(RuntimeError, match="write failed"):
|
||||
oper.set(key, "new")
|
||||
assert oper.get(key) == "old"
|
||||
|
||||
|
||||
def test_increment_serializes_concurrent_counter_updates(monkeypatch):
|
||||
"""并发递增系统计数时不应丢失更新。"""
|
||||
oper = object.__new__(SystemConfigOper)
|
||||
oper._rlock = threading.RLock()
|
||||
oper._snapshot_lock = threading.RLock()
|
||||
oper._write_lock = threading.RLock()
|
||||
oper._loaded = True
|
||||
stored_value = {"value": 0}
|
||||
|
||||
monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"])
|
||||
@@ -51,7 +142,9 @@ def test_increment_serializes_concurrent_counter_updates(monkeypatch):
|
||||
def test_increment_supports_custom_step(monkeypatch):
|
||||
"""整数系统计数应支持指定递增步长。"""
|
||||
oper = object.__new__(SystemConfigOper)
|
||||
oper._rlock = threading.RLock()
|
||||
oper._snapshot_lock = threading.RLock()
|
||||
oper._write_lock = threading.RLock()
|
||||
oper._loaded = True
|
||||
stored_value = {"value": 4}
|
||||
|
||||
monkeypatch.setattr(oper, "get", lambda _key: stored_value["value"])
|
||||
@@ -95,7 +188,17 @@ async def test_async_set_persists_falsy_value_on_existing_record():
|
||||
oper = _fresh_oper()
|
||||
|
||||
oper.set(key, True)
|
||||
assert await oper.async_set(key, False) is True
|
||||
from app.application.configuration import SystemConfigService
|
||||
|
||||
class _InlineDatabaseExecutor:
|
||||
async def run(self, operation):
|
||||
return operation()
|
||||
|
||||
service = SystemConfigService(
|
||||
repository=oper,
|
||||
async_executor=_InlineDatabaseExecutor(),
|
||||
)
|
||||
assert await service.async_set(key, False) is True
|
||||
assert oper.get(key) is False
|
||||
assert SystemConfig.get_by_key(oper._db, key).value is False
|
||||
|
||||
@@ -106,7 +209,17 @@ async def test_async_set_creates_record_for_falsy_value():
|
||||
key = _unique_key()
|
||||
oper = _fresh_oper()
|
||||
|
||||
assert await oper.async_set(key, 0) is True
|
||||
from app.application.configuration import SystemConfigService
|
||||
|
||||
class _InlineDatabaseExecutor:
|
||||
async def run(self, operation):
|
||||
return operation()
|
||||
|
||||
service = SystemConfigService(
|
||||
repository=oper,
|
||||
async_executor=_InlineDatabaseExecutor(),
|
||||
)
|
||||
assert await service.async_set(key, 0) is True
|
||||
assert oper.get(key) == 0
|
||||
assert SystemConfig.get_by_key(oper._db, key).value == 0
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""用户配置 API 的异步应用端口测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints import user as user_endpoint
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_config_waits_for_async_configuration_write(monkeypatch) -> None:
|
||||
"""更新接口等待用户配置事务完成后再返回成功。"""
|
||||
service = MagicMock()
|
||||
service.async_set = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
user_endpoint,
|
||||
"get_configured_user_configuration",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
response = await user_endpoint.set_config(
|
||||
"theme",
|
||||
"dark",
|
||||
current_user=SimpleNamespace(name="alice"),
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
service.async_set.assert_awaited_once_with(
|
||||
username="alice",
|
||||
key="theme",
|
||||
value="dark",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_reads_loaded_snapshot(monkeypatch) -> None:
|
||||
"""查询接口直接读取已加载的用户配置快照。"""
|
||||
service = MagicMock()
|
||||
service.get.return_value = "dark"
|
||||
monkeypatch.setattr(
|
||||
user_endpoint,
|
||||
"get_configured_user_configuration",
|
||||
lambda: service,
|
||||
)
|
||||
|
||||
response = await user_endpoint.get_config(
|
||||
"theme",
|
||||
current_user=SimpleNamespace(name="alice"),
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data == {"value": "dark"}
|
||||
service.get.assert_called_once_with(username="alice", key="theme")
|
||||
@@ -0,0 +1,93 @@
|
||||
"""用户配置快照与异步写入合同测试。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.security.userconfig import UserConfigurationService
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.db.oper.userconfig import UserConfigOper
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
class _ThreadDatabaseExecutor:
|
||||
"""在线程中执行测试事务。"""
|
||||
|
||||
async def run(self, operation):
|
||||
"""执行并返回操作结果。"""
|
||||
return await asyncio.to_thread(operation)
|
||||
|
||||
|
||||
def _fresh_oper() -> UserConfigOper:
|
||||
"""重置单例并显式加载用户配置快照。"""
|
||||
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
|
||||
oper = UserConfigOper()
|
||||
oper.load_snapshot()
|
||||
return oper
|
||||
|
||||
|
||||
def test_constructor_does_not_query_database(monkeypatch):
|
||||
"""构造用户配置对象时不打开数据库会话。"""
|
||||
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
|
||||
monkeypatch.setattr(
|
||||
UserConfig,
|
||||
"list",
|
||||
lambda _db: pytest.fail("构造阶段不应查询数据库"),
|
||||
)
|
||||
|
||||
oper = UserConfigOper()
|
||||
|
||||
with pytest.raises(RuntimeError, match="快照尚未加载"):
|
||||
oper.get("alice", "theme")
|
||||
|
||||
|
||||
def test_load_snapshot_publishes_complete_dictionary(monkeypatch):
|
||||
"""重新加载期间读取方不会看到逐项构造的用户配置。"""
|
||||
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
|
||||
oper = UserConfigOper()
|
||||
monkeypatch.setattr(
|
||||
UserConfig,
|
||||
"list",
|
||||
lambda _db: [SimpleNamespace(username="alice", key="theme", value="old")],
|
||||
)
|
||||
oper.load_snapshot()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def load_new_snapshot(_db):
|
||||
entered.set()
|
||||
release.wait(1)
|
||||
return [SimpleNamespace(username="alice", key="theme", value="new")]
|
||||
|
||||
monkeypatch.setattr(UserConfig, "list", load_new_snapshot)
|
||||
thread = threading.Thread(target=oper.load_snapshot)
|
||||
thread.start()
|
||||
assert entered.wait(1)
|
||||
|
||||
assert oper.get("alice", "theme") == "old"
|
||||
|
||||
release.set()
|
||||
thread.join(1)
|
||||
assert thread.is_alive() is False
|
||||
assert oper.get("alice", "theme") == "new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_write_uses_same_repository_rule() -> None:
|
||||
"""异步入口提交后同步读取立即看到相同结果。"""
|
||||
oper = _fresh_oper()
|
||||
service = UserConfigurationService(
|
||||
oper,
|
||||
async_executor=_ThreadDatabaseExecutor(),
|
||||
)
|
||||
|
||||
await service.async_set("async-user", "theme", "dark")
|
||||
|
||||
assert service.get("async-user", "theme") == "dark"
|
||||
assert UserConfig.get_by_key(
|
||||
oper._db,
|
||||
username="async-user",
|
||||
key="theme",
|
||||
).value == "dark"
|
||||
Reference in New Issue
Block a user