mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-05 14:48:40 +08:00
refactor(startup): add symmetric composition resets
This commit is contained in:
@@ -11,6 +11,7 @@ from app.application.agent import AgentDataContext
|
||||
from app.application.agenttask import (
|
||||
AgentTaskExecutionService,
|
||||
configure_agent_task_execution,
|
||||
reset_agent_task_execution,
|
||||
)
|
||||
from app.application.history import DownloadHistoryRepository, TransferHistoryRepository
|
||||
from app.application.messaging.chat import (
|
||||
@@ -18,6 +19,8 @@ from app.application.messaging.chat import (
|
||||
AgentChatService,
|
||||
configure_agent_chat_persistence,
|
||||
configure_agent_chat_service,
|
||||
reset_agent_chat_persistence,
|
||||
reset_agent_chat_service,
|
||||
)
|
||||
from app.application.site.contract import SiteRepository
|
||||
from app.application.subscription.contract import (
|
||||
@@ -49,6 +52,7 @@ from app.startup.composition.subscription import (
|
||||
)
|
||||
|
||||
AgentDataContextRegistrar = Callable[[AgentDataContext], None]
|
||||
AgentDataContextResetter = Callable[[], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -128,3 +132,14 @@ def publish_agent_services(
|
||||
configure_agent_chat_persistence(composition.data.chat_persistence)
|
||||
configure_agent_task_execution(composition.execution)
|
||||
data_context_registrar(composition.data)
|
||||
|
||||
|
||||
def reset_agent_services(
|
||||
*,
|
||||
data_context_resetter: AgentDataContextResetter,
|
||||
) -> None:
|
||||
"""按发布逆序撤销当前 lifespan 的 Agent 数据、任务与会话服务。"""
|
||||
data_context_resetter()
|
||||
reset_agent_task_execution()
|
||||
reset_agent_chat_persistence()
|
||||
reset_agent_chat_service()
|
||||
|
||||
@@ -8,7 +8,10 @@ from app.application.chain.context import (
|
||||
configure_chain_runtime_context_provider,
|
||||
)
|
||||
from app.application.configuration import ChainRuntimeConfig
|
||||
from app.application.image import configure_wallpaper_providers
|
||||
from app.application.image import (
|
||||
configure_wallpaper_providers,
|
||||
reset_wallpaper_providers,
|
||||
)
|
||||
from app.application.messaging.message import MessageHelper, MessageQueueManager
|
||||
from app.application.transfer.execution import TransferExecutionRepository
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
@@ -139,3 +142,9 @@ def configure_chain_runtime_context(
|
||||
configuration=configuration,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def reset_chain_services() -> None:
|
||||
"""按发布逆序撤销 Chain 无参上下文和壁纸来源。"""
|
||||
configure_chain_runtime_context_provider(None)
|
||||
reset_wallpaper_providers()
|
||||
|
||||
@@ -11,16 +11,19 @@ from app.application.image import (
|
||||
ImageTransport,
|
||||
InternalAddressPort,
|
||||
configure_image_ports,
|
||||
reset_image_ports,
|
||||
)
|
||||
from app.application.messaging.ingress import (
|
||||
MessageIngressPort,
|
||||
configure_message_ingress_port,
|
||||
reset_message_ingress_port,
|
||||
)
|
||||
from app.application.network import (
|
||||
NetworkTestResponse,
|
||||
NetworkTestService,
|
||||
NetworkTestTransport,
|
||||
configure_network_test_service,
|
||||
reset_network_test_service,
|
||||
)
|
||||
from app.runtime.log import logger
|
||||
|
||||
@@ -155,3 +158,10 @@ def configure_application_network_ports() -> None:
|
||||
internal_address=internal_address,
|
||||
)
|
||||
configure_message_ingress_port(message_ingress)
|
||||
|
||||
|
||||
def reset_application_network_ports() -> None:
|
||||
"""按发布逆序撤销消息回环、图片与网络探测应用端口。"""
|
||||
reset_message_ingress_port()
|
||||
reset_image_ports()
|
||||
reset_network_test_service()
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.application.outbox import (
|
||||
ClaimedOutboxMessage,
|
||||
OutboxDispatcher,
|
||||
durable_event_topic,
|
||||
reset_outbox_dispatcher,
|
||||
validate_durable_event_handlers,
|
||||
)
|
||||
from app.command import CommandChain
|
||||
@@ -154,3 +155,8 @@ def build_outbox_dispatcher() -> OutboxDispatcher:
|
||||
owner="outbox",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def reset_outbox_services() -> None:
|
||||
"""撤销当前 lifespan 发布的 Outbox dispatcher 工厂。"""
|
||||
reset_outbox_dispatcher()
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""认证、用户与 PassKey 服务的启动组合。"""
|
||||
|
||||
from app.adapters.web.security.access import set_superuser_token_payload_provider
|
||||
from app.adapters.web.security.access import (
|
||||
reset_superuser_token_payload_provider,
|
||||
set_superuser_token_payload_provider,
|
||||
)
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.application.security.auth import (
|
||||
AuthService,
|
||||
build_superuser_token_payload,
|
||||
configure_auth_service,
|
||||
reset_auth_service,
|
||||
)
|
||||
from app.application.security.passkey import (
|
||||
PASSKEY_CHALLENGE_TTL_SECONDS,
|
||||
PasskeyService,
|
||||
configure_passkey_challenge_cache,
|
||||
configure_passkey_service,
|
||||
reset_passkey_challenge_cache,
|
||||
reset_passkey_service,
|
||||
)
|
||||
from app.application.security.user import configure_user_lookups
|
||||
from app.application.security.user import configure_user_lookups, reset_user_lookups
|
||||
from app.db.adapters.user import SqlAlchemyUserRepository
|
||||
from app.db.oper.passkey import PassKeyOper
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
@@ -57,3 +63,16 @@ def configure_security_services() -> AuthenticationRuntime:
|
||||
def configure_security_access() -> None:
|
||||
"""登记 Web 认证边界所需的超级用户令牌载荷提供器。"""
|
||||
set_superuser_token_payload_provider(build_superuser_token_payload)
|
||||
|
||||
|
||||
def reset_security_services() -> None:
|
||||
"""按发布逆序撤销认证、用户查询和 PassKey 服务。"""
|
||||
reset_passkey_service()
|
||||
reset_passkey_challenge_cache()
|
||||
reset_auth_service()
|
||||
reset_user_lookups()
|
||||
|
||||
|
||||
def reset_security_access() -> None:
|
||||
"""撤销 Web 认证边界的超级用户令牌载荷提供器及其缓存。"""
|
||||
reset_superuser_token_payload_provider()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from app.adapters.external.server import (
|
||||
MoviePilotServerHelper,
|
||||
configure_server_application_services,
|
||||
reset_server_application_services,
|
||||
)
|
||||
from app.application.configuration import get_configured_system_config
|
||||
from app.application.server.report import ServerReportService
|
||||
@@ -48,3 +49,8 @@ def configure_server_services(
|
||||
workflow_cache_clearer=MoviePilotServerHelper._clear_workflow_share_cache,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def reset_server_services() -> None:
|
||||
"""撤销当前 lifespan 发布的中心服务上报与分享用例。"""
|
||||
reset_server_application_services()
|
||||
|
||||
261
tests/test_composition_owner_resets.py
Normal file
261
tests/test_composition_owner_resets.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""验证 composition owner 对称撤销其发布的 lifespan provider。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.adapters.external import server as server_provider
|
||||
from app.adapters.web.security import access as access_provider
|
||||
from app.application import agenttask as task_provider
|
||||
from app.application import image as image_provider
|
||||
from app.application import network as network_provider
|
||||
from app.application import outbox as outbox_provider
|
||||
from app.application.chain import context as chain_provider
|
||||
from app.application.messaging import chat as chat_provider
|
||||
from app.application.messaging import ingress as ingress_provider
|
||||
from app.application.security import auth as auth_provider
|
||||
from app.application.security import passkey as passkey_provider
|
||||
from app.application.security import user as user_provider
|
||||
from app.startup.composition import agent as agent_composition
|
||||
from app.startup.composition import chain as chain_composition
|
||||
from app.startup.composition import network as network_composition
|
||||
from app.startup.composition import outbox as outbox_composition
|
||||
from app.startup.composition import security as security_composition
|
||||
from app.startup.composition import server as server_composition
|
||||
|
||||
|
||||
def test_agent_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Agent owner 撤销数据上下文、任务执行与两类会话服务。"""
|
||||
monkeypatch.setattr(chat_provider, "_configured_agent_chat_service", None)
|
||||
monkeypatch.setattr(chat_provider, "_configured_agent_chat_persistence", None)
|
||||
monkeypatch.setattr(task_provider, "_service", None)
|
||||
data_state: dict[str, object] = {}
|
||||
composition = SimpleNamespace(
|
||||
data=SimpleNamespace(chat=object(), chat_persistence=object()),
|
||||
execution=object(),
|
||||
)
|
||||
|
||||
agent_composition.publish_agent_services(
|
||||
composition,
|
||||
data_context_registrar=lambda data: data_state.update(current=data),
|
||||
)
|
||||
assert chat_provider.get_configured_agent_chat_service() is composition.data.chat
|
||||
assert (
|
||||
chat_provider.get_configured_agent_chat_persistence()
|
||||
is composition.data.chat_persistence
|
||||
)
|
||||
assert task_provider.get_agent_task_execution_service() is composition.execution
|
||||
assert data_state["current"] is composition.data
|
||||
|
||||
def reset_data() -> None:
|
||||
"""撤销测试中的 Agent 数据上下文。"""
|
||||
data_state.pop("current", None)
|
||||
|
||||
agent_composition.reset_agent_services(data_context_resetter=reset_data)
|
||||
agent_composition.reset_agent_services(data_context_resetter=reset_data)
|
||||
|
||||
assert data_state == {}
|
||||
for getter in (
|
||||
chat_provider.get_configured_agent_chat_service,
|
||||
chat_provider.get_configured_agent_chat_persistence,
|
||||
task_provider.get_agent_task_execution_service,
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
getter()
|
||||
|
||||
|
||||
def test_security_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Security owner 撤销身份查询、认证、PassKey 与 Web 访问 provider。"""
|
||||
monkeypatch.setattr(user_provider, "_configured_user_id_lookup", None)
|
||||
monkeypatch.setattr(user_provider, "_configured_user_name_lookup", None)
|
||||
monkeypatch.setattr(user_provider, "_configured_user_channel_lookup", None)
|
||||
monkeypatch.setattr(auth_provider, "_configured_auth_service", None)
|
||||
monkeypatch.setattr(passkey_provider.PasskeyChallengeStore, "_cache", None)
|
||||
monkeypatch.setattr(passkey_provider, "_configured_passkey_service", None)
|
||||
monkeypatch.setattr(access_provider, "_superuser_token_payload_provider", None)
|
||||
access_provider._create_superuser_token_payload.cache_clear()
|
||||
|
||||
user_provider.configure_user_lookups(
|
||||
by_id=lambda _user_id: object(),
|
||||
by_name=lambda _username: object(),
|
||||
by_channel=lambda **_bindings: "user",
|
||||
)
|
||||
auth_provider.configure_auth_service(object()) # type: ignore[arg-type]
|
||||
passkey_provider.configure_passkey_challenge_cache(object()) # type: ignore[arg-type]
|
||||
passkey_provider.configure_passkey_service(object()) # type: ignore[arg-type]
|
||||
access_provider.set_superuser_token_payload_provider(lambda: object())
|
||||
|
||||
security_composition.reset_security_access()
|
||||
security_composition.reset_security_access()
|
||||
security_composition.reset_security_services()
|
||||
security_composition.reset_security_services()
|
||||
|
||||
for getter in (
|
||||
user_provider.get_configured_user_id_lookup,
|
||||
user_provider.get_configured_user_name_lookup,
|
||||
user_provider.get_configured_user_channel_lookup,
|
||||
auth_provider.get_configured_auth_service,
|
||||
passkey_provider.get_configured_passkey_service,
|
||||
passkey_provider.PasskeyChallengeStore._get_cache,
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
getter()
|
||||
with pytest.raises(HTTPException) as error:
|
||||
access_provider._create_superuser_token_payload()
|
||||
assert error.value.status_code == 503
|
||||
|
||||
|
||||
def test_network_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Network owner 撤销探测、图片和消息回环端口。"""
|
||||
monkeypatch.setattr(network_provider, "_configured_network_test_service", None)
|
||||
monkeypatch.setattr(image_provider, "_image_transport", None)
|
||||
monkeypatch.setattr(image_provider, "_internal_address", None)
|
||||
monkeypatch.setattr(ingress_provider, "_message_ingress_port", None)
|
||||
|
||||
network_provider.configure_network_test_service(object()) # type: ignore[arg-type]
|
||||
image_provider.configure_image_ports(
|
||||
transport=object(), # type: ignore[arg-type]
|
||||
internal_address=object(), # type: ignore[arg-type]
|
||||
)
|
||||
ingress_provider.configure_message_ingress_port(object()) # type: ignore[arg-type]
|
||||
|
||||
network_composition.reset_application_network_ports()
|
||||
network_composition.reset_application_network_ports()
|
||||
|
||||
for getter in (
|
||||
network_provider.get_configured_network_test_service,
|
||||
image_provider._image_ports_snapshot,
|
||||
ingress_provider._message_ingress_snapshot,
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
getter()
|
||||
|
||||
|
||||
def test_outbox_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Outbox owner 撤销 dispatcher 工厂并恢复显式未配置错误。"""
|
||||
monkeypatch.setattr(outbox_provider, "_configured_dispatcher", None)
|
||||
outbox_provider.configure_outbox_dispatcher(Mock())
|
||||
|
||||
outbox_composition.reset_outbox_services()
|
||||
outbox_composition.reset_outbox_services()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Outbox dispatcher 尚未配置"):
|
||||
outbox_provider.dispatch_pending_outbox()
|
||||
|
||||
|
||||
def test_server_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Server owner 同时撤销上报与分享服务。"""
|
||||
monkeypatch.setattr(server_provider, "_server_report_service", None)
|
||||
monkeypatch.setattr(server_provider, "_server_sharing_service", None)
|
||||
server_provider.configure_server_application_services(
|
||||
report_service=object(),
|
||||
sharing_service=object(),
|
||||
)
|
||||
|
||||
server_composition.reset_server_services()
|
||||
server_composition.reset_server_services()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
server_provider.MoviePilotServerHelper._report_service()
|
||||
with pytest.raises(RuntimeError):
|
||||
server_provider.MoviePilotServerHelper._sharing_service()
|
||||
|
||||
|
||||
def test_chain_owner_reset_is_symmetric_and_idempotent(monkeypatch) -> None:
|
||||
"""Chain owner 撤销无参上下文,并把壁纸来源恢复为空结果。"""
|
||||
monkeypatch.setattr(
|
||||
chain_provider,
|
||||
"_context_provider",
|
||||
chain_provider._unconfigured_chain_runtime_context,
|
||||
)
|
||||
chain_provider.configure_chain_runtime_context_provider(lambda: object())
|
||||
image_provider.configure_wallpaper_providers(
|
||||
tmdb_wallpaper=lambda: "tmdb",
|
||||
tmdb_wallpapers=lambda _count: ["tmdb"],
|
||||
mediaserver_wallpaper=lambda: "media",
|
||||
mediaserver_wallpapers=lambda _count: ["media"],
|
||||
)
|
||||
|
||||
chain_composition.reset_chain_services()
|
||||
chain_composition.reset_chain_services()
|
||||
|
||||
with pytest.raises(RuntimeError, match="Chain 运行上下文尚未由启动组合根配置"):
|
||||
chain_provider.get_chain_runtime_context()
|
||||
wallpaper = image_provider.WallpaperHelper()
|
||||
assert wallpaper.get_tmdb_wallpaper() is None
|
||||
assert wallpaper.get_tmdb_wallpapers() == []
|
||||
assert wallpaper.get_mediaserver_wallpaper() is None
|
||||
assert wallpaper.get_mediaserver_wallpapers() == []
|
||||
|
||||
|
||||
def test_composition_owner_resets_run_in_reverse_publication_order(monkeypatch) -> None:
|
||||
"""多 provider owner 必须按发布逆序撤销,避免依赖先于消费者消失。"""
|
||||
calls: list[str] = []
|
||||
|
||||
for module, names in (
|
||||
(
|
||||
agent_composition,
|
||||
(
|
||||
"reset_agent_task_execution",
|
||||
"reset_agent_chat_persistence",
|
||||
"reset_agent_chat_service",
|
||||
),
|
||||
),
|
||||
(
|
||||
security_composition,
|
||||
(
|
||||
"reset_passkey_service",
|
||||
"reset_passkey_challenge_cache",
|
||||
"reset_auth_service",
|
||||
"reset_user_lookups",
|
||||
),
|
||||
),
|
||||
(
|
||||
network_composition,
|
||||
(
|
||||
"reset_message_ingress_port",
|
||||
"reset_image_ports",
|
||||
"reset_network_test_service",
|
||||
),
|
||||
),
|
||||
(
|
||||
chain_composition,
|
||||
(
|
||||
"configure_chain_runtime_context_provider",
|
||||
"reset_wallpaper_providers",
|
||||
),
|
||||
),
|
||||
):
|
||||
for name in names:
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
name,
|
||||
lambda *args, _name=name, **kwargs: calls.append(_name),
|
||||
)
|
||||
|
||||
agent_composition.reset_agent_services(
|
||||
data_context_resetter=lambda: calls.append("reset_agent_data_context"),
|
||||
)
|
||||
security_composition.reset_security_services()
|
||||
network_composition.reset_application_network_ports()
|
||||
chain_composition.reset_chain_services()
|
||||
|
||||
assert calls == [
|
||||
"reset_agent_data_context",
|
||||
"reset_agent_task_execution",
|
||||
"reset_agent_chat_persistence",
|
||||
"reset_agent_chat_service",
|
||||
"reset_passkey_service",
|
||||
"reset_passkey_challenge_cache",
|
||||
"reset_auth_service",
|
||||
"reset_user_lookups",
|
||||
"reset_message_ingress_port",
|
||||
"reset_image_ports",
|
||||
"reset_network_test_service",
|
||||
"configure_chain_runtime_context_provider",
|
||||
"reset_wallpaper_providers",
|
||||
]
|
||||
Reference in New Issue
Block a user