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()