mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-11 16:21:09 +08:00
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
"""订阅执行状态合并、批次权限和操作能力测试。"""
|
|
|
|
import asyncio
|
|
|
|
from app.application.download.admission import SubscriptionDownloadSnapshot
|
|
from app.application.subscription.execution import SearchBatchSnapshot, SearchTaskSnapshot
|
|
from app.application.subscription.status import SubscriptionExecutionStatusService
|
|
|
|
|
|
def _task(
|
|
subscription_id: int,
|
|
*,
|
|
state: str = "running",
|
|
phase: str = "searching",
|
|
updated_at: str = "2026-09-01T01:00:00+00:00",
|
|
batch_id: str = "batch-1",
|
|
) -> SearchTaskSnapshot:
|
|
"""构造最小搜索任务快照。"""
|
|
return SearchTaskSnapshot(
|
|
task_id=f"task-{subscription_id}",
|
|
batch_id=batch_id,
|
|
subscription_id=subscription_id,
|
|
source="manual",
|
|
priority=100,
|
|
position=subscription_id,
|
|
state=state,
|
|
phase=phase,
|
|
attempt_count=1,
|
|
cancel_requested=False,
|
|
lease_token="lease" if state == "running" else None,
|
|
created_at="2026-09-01T00:00:00+00:00",
|
|
updated_at=updated_at,
|
|
current_site_id=9 if phase == "waiting_site_budget" else None,
|
|
last_error=" provider\n timeout " if state == "failed" else None,
|
|
)
|
|
|
|
|
|
def _download(subscription_id: int, state: str) -> SubscriptionDownloadSnapshot:
|
|
"""构造一个比搜索任务更早的下载提交快照。"""
|
|
return SubscriptionDownloadSnapshot(
|
|
idempotency_key=f"key-{subscription_id}",
|
|
subscription_id=subscription_id,
|
|
task_id=f"task-{subscription_id}",
|
|
state=state,
|
|
attempt_count=1,
|
|
attempt_token="attempt",
|
|
downloader=None,
|
|
download_hash=None,
|
|
available_at=None,
|
|
last_error="downloader response uncertain" if state == "reconcile_required" else None,
|
|
created_at="2026-09-01T00:30:00+00:00",
|
|
updated_at="2026-09-01T00:59:00+00:00",
|
|
)
|
|
|
|
|
|
class _Repository:
|
|
"""保存测试快照的异步状态仓储。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化可由测试覆盖的快照集合。"""
|
|
self.tasks: dict[int, SearchTaskSnapshot] = {}
|
|
self.downloads: dict[int, SubscriptionDownloadSnapshot] = {}
|
|
self.batch = SearchBatchSnapshot(
|
|
batch_id="batch-1",
|
|
source="manual",
|
|
state="running",
|
|
priority=100,
|
|
total_count=2,
|
|
finished_count=0,
|
|
failed_count=0,
|
|
cancelled_count=0,
|
|
cancel_requested=False,
|
|
created_at="2026-09-01T00:00:00+00:00",
|
|
updated_at="2026-09-01T01:00:00+00:00",
|
|
)
|
|
|
|
async def latest_search_tasks(self, subscription_ids):
|
|
"""返回请求范围内搜索任务。"""
|
|
return {key: value for key, value in self.tasks.items() if key in subscription_ids}
|
|
|
|
async def latest_download_submissions(self, subscription_ids):
|
|
"""返回请求范围内下载提交。"""
|
|
return {key: value for key, value in self.downloads.items() if key in subscription_ids}
|
|
|
|
async def list_batches(self, *, limit):
|
|
"""返回一个测试批次。"""
|
|
return [self.batch][:limit]
|
|
|
|
async def get_batch(self, batch_id):
|
|
"""按 ID 返回测试批次。"""
|
|
return self.batch if batch_id == self.batch.batch_id else None
|
|
|
|
async def list_batch_tasks(self, batch_id):
|
|
"""返回属于测试批次的任务。"""
|
|
return [task for task in self.tasks.values() if task.batch_id == batch_id]
|
|
|
|
|
|
def test_execution_status_exposes_site_wait_and_cancel_capability():
|
|
"""站点预算等待必须保留当前站点和取消能力。"""
|
|
repository = _Repository()
|
|
repository.tasks[1] = _task(1, phase="waiting_site_budget")
|
|
|
|
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((1,)))
|
|
|
|
assert statuses[1].state == "waiting_site_budget"
|
|
assert statuses[1].current_site_id == 9
|
|
assert statuses[1].can_cancel is True
|
|
|
|
|
|
def test_reconciliation_state_overrides_newer_search_terminal():
|
|
"""不确定下载副作用不得被稍晚写入的搜索失败掩盖。"""
|
|
repository = _Repository()
|
|
repository.tasks[2] = _task(2, state="failed", phase="failed")
|
|
repository.downloads[2] = _download(2, "reconcile_required")
|
|
|
|
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((2,)))
|
|
|
|
assert statuses[2].state == "reconcile_required"
|
|
assert statuses[2].requires_reconciliation is True
|
|
assert statuses[2].can_retry is False
|
|
assert statuses[2].error == "downloader response uncertain"
|
|
|
|
|
|
def test_failed_search_exposes_safe_retryable_error():
|
|
"""搜索失败文本必须压平且仅声明安全重试能力。"""
|
|
repository = _Repository()
|
|
repository.tasks[3] = _task(3, state="failed", phase="failed")
|
|
|
|
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((3,)))
|
|
|
|
assert statuses[3].state == "failed"
|
|
assert statuses[3].can_retry is True
|
|
assert statuses[3].error == "provider timeout"
|
|
|
|
|
|
def test_batch_requires_complete_subscription_access():
|
|
"""普通用户不得读取混合其他 owner 订阅的批次聚合。"""
|
|
repository = _Repository()
|
|
repository.tasks = {1: _task(1), 2: _task(2)}
|
|
service = SubscriptionExecutionStatusService(repository)
|
|
|
|
hidden = asyncio.run(service.get_batch("batch-1", accessible_subscription_ids={1}))
|
|
visible = asyncio.run(service.get_batch("batch-1", accessible_subscription_ids={1, 2}))
|
|
|
|
assert hidden is None
|
|
assert visible is not None
|
|
assert visible.current_subscription_id == 1
|
|
assert visible.processed_count == 0
|
|
assert visible.can_cancel is True
|
|
|
|
|
|
def test_request_cancel_uses_injected_execution_boundary():
|
|
"""取消必须通过组合根注入的异步执行边界并返回真实结果。"""
|
|
repository = _Repository()
|
|
requested: list[str] = []
|
|
|
|
async def request_cancel(batch_id: str) -> bool:
|
|
"""记录测试请求并模拟队列接受取消。"""
|
|
requested.append(batch_id)
|
|
return True
|
|
|
|
service = SubscriptionExecutionStatusService(
|
|
repository,
|
|
request_cancel=request_cancel,
|
|
)
|
|
|
|
assert asyncio.run(service.request_cancel("batch-1")) is True
|
|
assert requested == ["batch-1"]
|