mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
225 lines
7.6 KiB
Python
225 lines
7.6 KiB
Python
"""
|
|
Event Contract Registry 完整性和兼容校验测试。
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.application.outbox import (
|
|
DURABLE_EVENT_TOPICS,
|
|
REQUIRED_OUTBOX_TOPICS,
|
|
validate_durable_event_handlers,
|
|
)
|
|
from app.domain.context import Context, MediaInfo
|
|
from app.domain.metainfo import MetaInfo
|
|
from app.runtime.event.contracts import (
|
|
EVENT_CONTRACTS,
|
|
EventDelivery,
|
|
get_event_contract,
|
|
validate_event_payload,
|
|
)
|
|
from app.runtime.events import Event
|
|
from app.schemas.event import (
|
|
ConfigChangeEventData,
|
|
ResourceDownloadInputContractData,
|
|
ResourceDownloadOutputContractData,
|
|
ResourceSelectionInputContractData,
|
|
ResourceSelectionOutputContractData,
|
|
)
|
|
from app.schemas.file import FileItem
|
|
from app.schemas.transfer import TransferInfo
|
|
from app.schemas.types import ChainEventType, EventType, MediaType, SystemConfigKey
|
|
|
|
|
|
def test_every_event_enum_has_complete_contract() -> None:
|
|
"""
|
|
53 个广播/链式事件必须全部登记并绑定 typed payload。
|
|
"""
|
|
expected = {*EventType, *ChainEventType}
|
|
|
|
assert set(EVENT_CONTRACTS) == expected
|
|
assert len(EVENT_CONTRACTS) == 53
|
|
for contract in EVENT_CONTRACTS.values():
|
|
assert contract.mode in {"broadcast", "chain"}
|
|
assert contract.payload_contract
|
|
assert contract.payload_model is not None
|
|
assert contract.payload_contract != "legacy_dict"
|
|
assert contract.legacy_reason is None
|
|
|
|
|
|
def test_extensible_plugin_payload_accepts_custom_fields_without_shape_change() -> None:
|
|
"""
|
|
插件动作 contract 只校验公共字段,插件自定义字段和原始 dict 均保持不变。
|
|
"""
|
|
payload = {
|
|
"plugin_id": "DemoPlugin",
|
|
"action": "refresh",
|
|
"plugin_owned_field": {"value": 1},
|
|
}
|
|
|
|
assert validate_event_payload(EventType.PluginAction, payload) == ()
|
|
event = Event(EventType.PluginAction, payload)
|
|
|
|
assert event.event_data is payload
|
|
assert event.event_data["plugin_owned_field"] == {"value": 1}
|
|
|
|
|
|
def test_typed_payload_is_validated_without_changing_public_shape() -> None:
|
|
"""
|
|
旧 dict 通过 model 校验后仍以同一个 dict 对象投递给插件。
|
|
"""
|
|
payload = {"key": "PROXY_HOST", "value": "http://proxy"}
|
|
|
|
assert validate_event_payload(EventType.ConfigChanged, payload) == ()
|
|
event = Event(EventType.ConfigChanged, payload)
|
|
|
|
assert event.event_data is payload
|
|
assert isinstance(event.event_data, dict)
|
|
|
|
|
|
def test_config_change_payload_normalizes_system_config_enum_key() -> None:
|
|
"""
|
|
SystemConfigKey 事件键必须使用持久化值,才能命中运行期 watch。
|
|
"""
|
|
payload = ConfigChangeEventData(key=SystemConfigKey.CustomIdentifiers)
|
|
|
|
assert payload.key == {SystemConfigKey.CustomIdentifiers.value}
|
|
|
|
|
|
def test_invalid_typed_payload_is_diagnostic_only() -> None:
|
|
"""
|
|
兼容阶段坏 payload 产生诊断但不阻断既有投递。
|
|
"""
|
|
payload = {"value": "missing-key"}
|
|
|
|
with patch("app.runtime.events.logger.warning") as warning:
|
|
event = Event(EventType.ConfigChanged, payload)
|
|
|
|
assert event.event_data is payload
|
|
warning.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize("event_type,payload", [
|
|
(ChainEventType.AuthVerification, {"username": 123, "password": "secret-password", "token": "secret-token"}),
|
|
(ChainEventType.AgentLLMProvider, {"provider": {"api_key": "secret-api-key"}, "api_key": "secret-api-key"}),
|
|
])
|
|
def test_invalid_event_diagnostics_never_include_raw_credentials(event_type, payload) -> None:
|
|
"""
|
|
字段校验失败时,诊断日志不能通过整个输入字典泄露合法或非法字段里的密钥。
|
|
"""
|
|
with patch("app.runtime.events.logger.warning") as warning:
|
|
event = Event(event_type, payload)
|
|
assert event.event_data is payload
|
|
warning.assert_called_once()
|
|
diagnostics = str(warning.call_args)
|
|
assert "validation error" in diagnostics
|
|
assert "secret-" not in diagnostics
|
|
|
|
|
|
def test_selected_user_side_effects_are_marked_durable_required() -> None:
|
|
"""
|
|
所有 durable-required 事件必须一一登记唯一的恢复 topic。
|
|
"""
|
|
durable_events = {
|
|
event_type
|
|
for event_type, contract in EVENT_CONTRACTS.items()
|
|
if contract.delivery is EventDelivery.DURABLE_REQUIRED
|
|
}
|
|
|
|
assert set(DURABLE_EVENT_TOPICS) == durable_events
|
|
assert len(durable_events) == 11
|
|
assert len(set(DURABLE_EVENT_TOPICS.values())) == len(durable_events)
|
|
|
|
|
|
def test_durable_event_dispatcher_requires_every_recovery_handler() -> None:
|
|
"""
|
|
恢复 dispatcher 缺少任一登记 topic 时必须在构造边界失败。
|
|
"""
|
|
handlers = {
|
|
topic: lambda _message: None
|
|
for topic in REQUIRED_OUTBOX_TOPICS
|
|
}
|
|
validate_durable_event_handlers(handlers)
|
|
|
|
handlers.pop(next(iter(REQUIRED_OUTBOX_TOPICS)))
|
|
with pytest.raises(RuntimeError, match="缺少 durable 事件 handler"):
|
|
validate_durable_event_handlers(handlers)
|
|
|
|
|
|
def test_download_and_transfer_typed_contracts_accept_legacy_runtime_objects() -> None:
|
|
"""
|
|
新增 typed contract 只做诊断,不把插件收到的领域对象替换成 dict。
|
|
"""
|
|
meta = MetaInfo("Demo.2026.mkv")
|
|
media = MediaInfo(type=MediaType.MOVIE, title="Demo", year="2026")
|
|
context = Context(meta_info=meta, media_info=media)
|
|
fileitem = FileItem(storage="local", path="/downloads/Demo.mkv", type="file")
|
|
transferinfo = TransferInfo(success=True, fileitem=fileitem)
|
|
|
|
assert validate_event_payload(
|
|
EventType.DownloadAdded,
|
|
{
|
|
"hash": "hash-1",
|
|
"context": context,
|
|
"downloader": "qb",
|
|
"episodes": [],
|
|
},
|
|
) == ()
|
|
for event_type in (EventType.TransferComplete, EventType.TransferFailed):
|
|
assert validate_event_payload(
|
|
event_type,
|
|
{
|
|
"fileitem": fileitem,
|
|
"meta": meta,
|
|
"mediainfo": media,
|
|
"transferinfo": transferinfo,
|
|
"transfer_history_id": 1,
|
|
},
|
|
) == ()
|
|
|
|
|
|
def test_music_runtime_objects_are_checked_as_typed_snapshots() -> None:
|
|
"""
|
|
音乐运行时对象保留给插件,同时通过快照契约明确 type/music_type。
|
|
"""
|
|
from app.domain.context import MusicInfo
|
|
|
|
media = MusicInfo(title="Song", artists=["Artist"], album="Album")
|
|
context = Context(media_info=media)
|
|
|
|
assert validate_event_payload(
|
|
EventType.DownloadAdded,
|
|
{"hash": "music-hash", "context": context},
|
|
) == ()
|
|
assert validate_event_payload(
|
|
EventType.TransferComplete,
|
|
{"mediainfo": media},
|
|
) == ()
|
|
|
|
|
|
def test_snapshot_contract_metadata_declares_mutable_chain_boundaries() -> None:
|
|
"""
|
|
核心链式事件公开快照输入输出模型,但仍处于兼容诊断模式。
|
|
"""
|
|
selection = get_event_contract(ChainEventType.ResourceSelection)
|
|
download = get_event_contract(ChainEventType.ResourceDownload)
|
|
|
|
assert selection.schema_version == 1
|
|
assert selection.payload_mode.value == "snapshot"
|
|
assert selection.input_model is ResourceSelectionInputContractData
|
|
assert selection.output_model is ResourceSelectionOutputContractData
|
|
assert download.input_model is ResourceDownloadInputContractData
|
|
assert download.output_model is ResourceDownloadOutputContractData
|
|
assert selection.validation_mode.value == "diagnostic"
|
|
|
|
|
|
def test_model_instance_remains_mutable_chain_payload() -> None:
|
|
"""
|
|
链式处理器继续接收原 model 实例,确保输出字段可原地接力。
|
|
"""
|
|
payload = ConfigChangeEventData(key={"PROXY_HOST"})
|
|
event = Event(EventType.ConfigChanged, payload)
|
|
|
|
assert event.event_data is payload
|