mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
311 lines
10 KiB
Python
311 lines
10 KiB
Python
from unittest.mock import Mock, patch
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.chain import transfer as transfer_module
|
||
from app.chain.transfer import TransferChain
|
||
from app.application.transfer import (
|
||
TransferFailureNotification,
|
||
TransferFailureNotificationAggregator,
|
||
TransferTask,
|
||
build_transfer_failure_group_key,
|
||
)
|
||
from app.domain.context import MediaInfo
|
||
from app.domain.metainfo import MetaInfo
|
||
from app.runtime.config import ConfigModel
|
||
from app.schemas.file import FileItem
|
||
from app.schemas.transfer import TransferInfo
|
||
from app.schemas.types import MediaSource, MediaType
|
||
|
||
|
||
class _Timer:
|
||
"""记录静默窗口是否因新失败到达而取消。"""
|
||
|
||
def __init__(self, callback, args):
|
||
"""保存定时回调与参数。"""
|
||
self.callback = callback
|
||
self.args = args
|
||
self.cancelled = False
|
||
|
||
def cancel(self):
|
||
"""标记当前定时器已取消。"""
|
||
self.cancelled = True
|
||
|
||
|
||
class _Loop:
|
||
"""同步执行线程安全入队,并保留延迟回调供测试触发。"""
|
||
|
||
def __init__(self):
|
||
"""初始化定时器清单。"""
|
||
self.timers = []
|
||
|
||
def call_soon_threadsafe(self, callback, *args):
|
||
"""同步执行本应投递到事件循环的回调。"""
|
||
callback(*args)
|
||
|
||
def call_later(self, _delay, callback, *args):
|
||
"""保存延迟回调并返回可取消句柄。"""
|
||
timer = _Timer(callback, args)
|
||
self.timers.append(timer)
|
||
return timer
|
||
|
||
@staticmethod
|
||
def is_running() -> bool:
|
||
"""该替身代表由生命周期持有的运行中循环。"""
|
||
return True
|
||
|
||
@staticmethod
|
||
def is_closed() -> bool:
|
||
"""该替身在用例期间保持可用。"""
|
||
return False
|
||
|
||
|
||
class _DeferredLoop(_Loop):
|
||
"""延迟执行线程安全回调,用于覆盖关闭与入环之间的竞态。"""
|
||
|
||
def __init__(self):
|
||
"""初始化延迟回调和定时器清单。"""
|
||
super().__init__()
|
||
self.soon_callbacks = []
|
||
|
||
def call_soon_threadsafe(self, callback, *args):
|
||
"""保存线程安全回调,直到测试显式执行。"""
|
||
self.soon_callbacks.append((callback, args))
|
||
|
||
def run_soon_callbacks(self):
|
||
"""执行并清空已保存的线程安全回调。"""
|
||
callbacks = list(self.soon_callbacks)
|
||
self.soon_callbacks.clear()
|
||
for callback, args in callbacks:
|
||
callback(*args)
|
||
|
||
|
||
def _task(*, episode: int, download_hash: str = "hash-1") -> TransferTask:
|
||
"""构造同一媒体不同剧集的整理任务。"""
|
||
return TransferTask(
|
||
fileitem=FileItem(
|
||
storage="local",
|
||
path=f"/downloads/Show/Show.S01E{episode:02d}.mkv",
|
||
type="file",
|
||
name=f"Show.S01E{episode:02d}.mkv",
|
||
),
|
||
meta=MetaInfo(f"Show S01E{episode:02d}"),
|
||
mediainfo=MediaInfo(
|
||
media_source=MediaSource.TMDB,
|
||
media_id="100",
|
||
tmdb_id=100,
|
||
title="测试剧",
|
||
type=MediaType.TV,
|
||
year="2026",
|
||
),
|
||
download_hash=download_hash,
|
||
username="tester",
|
||
)
|
||
|
||
|
||
def test_failure_group_key_prefers_media_identity_and_season():
|
||
"""同一媒体同一季应跨文件共享分组键。"""
|
||
first = build_transfer_failure_group_key(_task(episode=1, download_hash="hash-a"))
|
||
second = build_transfer_failure_group_key(_task(episode=2, download_hash="hash-b"))
|
||
|
||
assert first == second
|
||
assert first == "media:themoviedb:100:season:1:user:tester"
|
||
|
||
|
||
def test_failure_notification_aggregation_defaults_on():
|
||
"""整理失败通知聚合默认开启。"""
|
||
field = ConfigModel.model_fields["TRANSFER_FAILURE_NOTIFICATION_AGGREGATION"]
|
||
|
||
assert field.default is True
|
||
|
||
|
||
def test_aggregator_debounces_same_group_and_flushes_once():
|
||
"""同组失败应重置定时器并一次性回调全部快照。"""
|
||
loop = _Loop()
|
||
aggregator = TransferFailureNotificationAggregator()
|
||
callback = Mock()
|
||
notices = [
|
||
TransferFailureNotification("测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"),
|
||
TransferFailureNotification("测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"),
|
||
]
|
||
|
||
for notice in notices:
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=notice,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
|
||
assert loop.timers[0].cancelled is True
|
||
assert loop.timers[1].cancelled is False
|
||
loop.timers[1].callback(*loop.timers[1].args)
|
||
callback.assert_called_once_with(notices)
|
||
|
||
|
||
def test_aggregator_old_timer_cannot_flush_before_renewal_is_armed():
|
||
"""新通知已接收时,旧 timer 不得抢在事件循环重置静默窗前发送。"""
|
||
loop = _DeferredLoop()
|
||
aggregator = TransferFailureNotificationAggregator()
|
||
callback = Mock()
|
||
first = TransferFailureNotification(
|
||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||
)
|
||
second = TransferFailureNotification(
|
||
"测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"
|
||
)
|
||
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=first,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
loop.run_soon_callbacks()
|
||
old_timer = loop.timers[0]
|
||
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=second,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
old_timer.callback(*old_timer.args)
|
||
callback.assert_not_called()
|
||
|
||
loop.run_soon_callbacks()
|
||
renewed_timer = loop.timers[1]
|
||
assert old_timer.cancelled is True
|
||
renewed_timer.callback(*renewed_timer.args)
|
||
callback.assert_called_once_with([first, second])
|
||
|
||
|
||
def test_aggregator_close_flushes_accepted_notification_before_timer_is_armed():
|
||
"""关闭应发送已接收但尚未进入事件循环的通知,且延迟回调不能重新建 timer。"""
|
||
loop = _DeferredLoop()
|
||
aggregator = TransferFailureNotificationAggregator()
|
||
callback = Mock()
|
||
notice = TransferFailureNotification(
|
||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||
)
|
||
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=notice,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
aggregator.close()
|
||
aggregator.close()
|
||
loop.run_soon_callbacks()
|
||
|
||
callback.assert_called_once_with([notice])
|
||
assert loop.timers == []
|
||
|
||
|
||
def test_aggregator_close_cancels_timer_and_rejects_new_notification():
|
||
"""关闭应取消已建 timer,并让调用方明确感知后续投递被拒绝。"""
|
||
loop = _Loop()
|
||
aggregator = TransferFailureNotificationAggregator()
|
||
callback = Mock()
|
||
notice = TransferFailureNotification(
|
||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||
)
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=notice,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
|
||
aggregator.close()
|
||
|
||
assert loop.timers[0].cancelled is True
|
||
callback.assert_called_once_with([notice])
|
||
with pytest.raises(RuntimeError, match="正在关闭"):
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=notice,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
|
||
|
||
def test_aggregator_close_observes_flush_callback_error():
|
||
"""关闭阶段同步刷新失败时应记录异常而不是让通知静默丢失。"""
|
||
loop = _DeferredLoop()
|
||
aggregator = TransferFailureNotificationAggregator()
|
||
callback = Mock(side_effect=RuntimeError("send failed"))
|
||
notice = TransferFailureNotification(
|
||
"测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"
|
||
)
|
||
aggregator.schedule(
|
||
group_key="media:test",
|
||
notification=notice,
|
||
callback=callback,
|
||
loop=loop,
|
||
)
|
||
|
||
with patch("app.application.transfer.logger.error") as log_error:
|
||
aggregator.close()
|
||
|
||
callback.assert_called_once_with([notice])
|
||
log_error.assert_called_once()
|
||
|
||
|
||
def test_aggregated_message_contains_count_reason_stats_and_batch_entry():
|
||
"""聚合消息应给出失败数、原因统计、历史 ID 和批量处理入口。"""
|
||
chain = object.__new__(TransferChain)
|
||
chain.runtime_config = SimpleNamespace(history_url="#/history")
|
||
sent = []
|
||
chain.post_message = sent.append
|
||
notices = [
|
||
TransferFailureNotification("测试剧 (2026)", "S01E01", "未识别到媒体信息", 11, None, "tester"),
|
||
TransferFailureNotification("测试剧 (2026)", "S01E02", "目标已存在", 12, None, "tester"),
|
||
TransferFailureNotification("测试剧 (2026)", "S01E03", "目标已存在", 13, None, "tester"),
|
||
]
|
||
|
||
chain._send_transfer_failure_notifications(notices)
|
||
|
||
assert len(sent) == 1
|
||
message = sent[0]
|
||
assert message.title == "测试剧 (2026) 入库失败(3 个文件)"
|
||
assert "失败文件:3 个" in message.text
|
||
assert "- 目标已存在 × 2" in message.text
|
||
assert "整理记录:#11、#12、#13" in message.text
|
||
assert message.buttons == [[{
|
||
"text": "批量处理",
|
||
"url": "#/history",
|
||
}]]
|
||
|
||
|
||
def test_enabled_queue_uses_shared_group_key():
|
||
"""开启聚合后公开通知入口应投递到聚合器而不是立即发送。"""
|
||
chain = object.__new__(TransferChain)
|
||
chain.runtime_config = SimpleNamespace(
|
||
transfer_failure_notification_aggregation=True,
|
||
)
|
||
chain.failure_notification_aggregator = Mock()
|
||
chain.post_message = Mock()
|
||
task = _task(episode=1)
|
||
transferinfo = TransferInfo(
|
||
success=False,
|
||
fileitem=task.fileitem,
|
||
message="整理失败",
|
||
transfer_type="copy",
|
||
)
|
||
loop = _Loop()
|
||
with patch.object(transfer_module.global_vars, "CURRENT_EVENT_LOOP", loop):
|
||
chain.queue_failed_transfer_notification(
|
||
task=task,
|
||
transferinfo=transferinfo,
|
||
history_id=22,
|
||
)
|
||
|
||
chain.failure_notification_aggregator.schedule.assert_called_once()
|
||
kwargs = chain.failure_notification_aggregator.schedule.call_args.kwargs
|
||
assert kwargs["group_key"] == build_transfer_failure_group_key(task)
|
||
assert kwargs["loop"] is loop
|
||
chain.post_message.assert_not_called()
|