mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
244 lines
7.8 KiB
Python
244 lines
7.8 KiB
Python
"""类型化 HostRuntime 与 FastAPI AppState 注入测试。"""
|
|
|
|
import ast
|
|
from dataclasses import FrozenInstanceError
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import Depends, FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.api.context import (
|
|
get_agent_chat_repository,
|
|
get_agent_chat_transaction,
|
|
)
|
|
from app.api.dependencies.agent import get_agent_chat_persistence
|
|
from app.startup import lifecycle
|
|
from app.startup.composition.context import (
|
|
AgentChatRuntime,
|
|
AuthenticationRuntime,
|
|
HistoryRuntime,
|
|
HostRuntime,
|
|
MessagingRuntime,
|
|
PersistenceRuntime,
|
|
SiteRuntime,
|
|
SubscriptionRuntime,
|
|
WorkflowRuntime,
|
|
)
|
|
from app.application.configuration import (
|
|
ApiRuntimeConfig,
|
|
ChainRuntimeConfig,
|
|
RuntimeConfiguration,
|
|
RuntimeSettingsService,
|
|
SchedulerRuntimeConfig,
|
|
)
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).parents[1]
|
|
|
|
|
|
class _Repository:
|
|
"""记录绑定会话的 Agent 会话仓储替身。"""
|
|
|
|
def __init__(self, session: object) -> None:
|
|
"""保存由类型化运行时提供的请求会话。"""
|
|
self.session = session
|
|
|
|
|
|
class _UnitOfWork:
|
|
"""记录绑定会话的异步事务替身。"""
|
|
|
|
def __init__(self, session: object) -> None:
|
|
"""保存与仓储相同的请求会话。"""
|
|
self.session = session
|
|
|
|
async def commit(self) -> None:
|
|
"""模拟提交。"""
|
|
|
|
async def rollback(self) -> None:
|
|
"""模拟回滚。"""
|
|
|
|
|
|
class _AgentChatPersistence:
|
|
"""提供 AgentChat 运行时所需的最小写端口。"""
|
|
|
|
|
|
class _SyncUnitOfWork:
|
|
"""记录绑定会话的同步事务替身。"""
|
|
|
|
def __init__(self, session: object) -> None:
|
|
"""保存与仓储相同的请求会话。"""
|
|
self.session = session
|
|
|
|
def commit(self) -> None:
|
|
"""模拟提交。"""
|
|
|
|
def rollback(self) -> None:
|
|
"""模拟回滚。"""
|
|
|
|
|
|
class _Outbox:
|
|
"""记录绑定会话的异步 outbox 替身。"""
|
|
|
|
def __init__(self, session: object) -> None:
|
|
"""保存与订阅仓储相同的请求会话。"""
|
|
self.session = session
|
|
|
|
async def stage(self, intent, now) -> None:
|
|
"""模拟暂存 durable intent。"""
|
|
|
|
async def complete_by_event_key(self, event_key, completed_at) -> None:
|
|
"""模拟收口 durable intent。"""
|
|
|
|
|
|
class _RuntimeSettings:
|
|
"""提供 HostRuntime 设置服务所需的最小测试合同。"""
|
|
|
|
def model_dump(self, *, include=None, exclude=None):
|
|
"""返回空设置快照。"""
|
|
return {}
|
|
|
|
def update_settings(self, env):
|
|
"""返回批量更新成功结果。"""
|
|
return {key: (True, "") for key in env}
|
|
|
|
def update_setting(self, key, value):
|
|
"""返回单项更新成功结果。"""
|
|
return True, ""
|
|
|
|
|
|
def _runtime() -> HostRuntime:
|
|
"""构造不加载数据库引擎或 PluginManager 的假宿主运行时。"""
|
|
async def async_session():
|
|
"""生成一个可被 FastAPI 依赖缓存的会话标记。"""
|
|
yield object()
|
|
|
|
def sync_session():
|
|
"""提供兼容 ApiDataPorts 所需的空同步生成器。"""
|
|
if False:
|
|
yield object()
|
|
|
|
return HostRuntime(
|
|
agent_chat=AgentChatRuntime(
|
|
async_session=async_session,
|
|
repository=_Repository,
|
|
transaction=_UnitOfWork,
|
|
persistence=_AgentChatPersistence(),
|
|
),
|
|
persistence=PersistenceRuntime(
|
|
sync_session=sync_session,
|
|
async_session=async_session,
|
|
sync_transaction=_SyncUnitOfWork,
|
|
async_transaction=_UnitOfWork,
|
|
),
|
|
authentication=AuthenticationRuntime(
|
|
user_repository=_Repository,
|
|
standalone_user=lambda: _Repository(object()),
|
|
system_config=lambda: _Repository(object()),
|
|
passkey=lambda: _Repository(object()),
|
|
),
|
|
messaging=MessagingRuntime(repository=_Repository),
|
|
history=HistoryRuntime(
|
|
download_repository=_Repository,
|
|
transfer_repository=_Repository,
|
|
media_server_repository=_Repository,
|
|
),
|
|
site=SiteRuntime(repository=_Repository),
|
|
subscription=SubscriptionRuntime(
|
|
async_session=async_session,
|
|
repository=_Repository,
|
|
history_repository=_Repository,
|
|
transaction=_UnitOfWork,
|
|
outbox=_Outbox,
|
|
),
|
|
workflow=WorkflowRuntime(
|
|
repository=_Repository,
|
|
system_config=lambda: _Repository(object()),
|
|
),
|
|
configuration=RuntimeConfiguration(
|
|
api=lambda: ApiRuntimeConfig(False, 60, False, True),
|
|
scheduler=lambda: SchedulerRuntimeConfig(
|
|
False, "Asia/Shanghai", 1, False, "", None, None,
|
|
False, 24, "rss", 30, False, None, None, True, 1, False, None,
|
|
),
|
|
chain=lambda: ChainRuntimeConfig(media_extensions=(".mkv",)),
|
|
),
|
|
settings=RuntimeSettingsService(_RuntimeSettings()),
|
|
)
|
|
|
|
|
|
def test_host_runtime_is_frozen_slotted_and_covers_all_api_domains() -> None:
|
|
"""运行时不可动态扩字段,且全部正式 API 领域都有命名能力。"""
|
|
runtime = _runtime()
|
|
|
|
assert not hasattr(runtime, "__dict__")
|
|
assert runtime.authentication.user_repository is _Repository
|
|
assert runtime.messaging.repository is _Repository
|
|
assert runtime.history.download_repository is _Repository
|
|
assert runtime.site.repository is _Repository
|
|
assert runtime.subscription.repository is _Repository
|
|
assert runtime.workflow.repository is _Repository
|
|
with pytest.raises(FrozenInstanceError):
|
|
runtime.agent_chat = runtime.agent_chat
|
|
|
|
|
|
def test_fastapi_dependencies_use_fake_runtime_without_real_services() -> None:
|
|
"""请求依赖可只注入假 Runtime,且仓储与 UoW 共享同一请求会话。"""
|
|
app = FastAPI()
|
|
app.state.host_runtime = _runtime()
|
|
|
|
@app.get("/probe")
|
|
async def probe(
|
|
repository=Depends(get_agent_chat_repository),
|
|
unit_of_work=Depends(get_agent_chat_transaction),
|
|
persistence=Depends(get_agent_chat_persistence),
|
|
) -> dict[str, bool]:
|
|
"""返回两个类型化能力是否绑定同一请求会话。"""
|
|
return {
|
|
"same_session": repository.session is unit_of_work.session,
|
|
"has_persistence": persistence is app.state.host_runtime.agent_chat.persistence,
|
|
}
|
|
|
|
with TestClient(app) as client:
|
|
response = client.get("/probe")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"same_session": True, "has_persistence": True}
|
|
|
|
|
|
def test_string_api_data_locator_is_confined_to_compatibility_boundary() -> None:
|
|
"""字符串数据注册表只能由 startup 注入并经旧 Facade 转发。"""
|
|
importers = set()
|
|
for path in (PROJECT_ROOT / "app").rglob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
imported_modules = {
|
|
node.module
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.ImportFrom) and node.module
|
|
}
|
|
if imported_modules & {"app.api.data", "app.api.dependencies.data"}:
|
|
importers.add(path.relative_to(PROJECT_ROOT).as_posix())
|
|
|
|
assert importers == {
|
|
"app/api/dependencies/data.py",
|
|
"app/startup/initializers/modules.py",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lifecycle_component_attaches_init_modules_result(monkeypatch) -> None:
|
|
"""模块组件把 init_modules 的构建结果发布到当前 AppState。"""
|
|
runtime = _runtime()
|
|
app = FastAPI()
|
|
|
|
async def init_modules() -> HostRuntime:
|
|
"""返回不触发真实启动副作用的假运行时。"""
|
|
return runtime
|
|
|
|
monkeypatch.setattr(lifecycle, "init_modules", init_modules)
|
|
|
|
await lifecycle.initialize_modules_component(app)
|
|
|
|
assert app.state.host_runtime is runtime
|