mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
208 lines
7.0 KiB
Python
208 lines
7.0 KiB
Python
"""订阅执行状态合并、批次权限和操作能力测试。"""
|
|
|
|
import asyncio
|
|
from dataclasses import replace
|
|
|
|
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",
|
|
source: str = "manual",
|
|
available_at: str | None = None,
|
|
) -> SearchTaskSnapshot:
|
|
"""构造最小搜索任务快照。"""
|
|
return SearchTaskSnapshot(
|
|
task_id=f"task-{subscription_id}",
|
|
batch_id=batch_id,
|
|
subscription_id=subscription_id,
|
|
source=source,
|
|
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,
|
|
available_at=available_at,
|
|
current_site_id=9 if phase == "waiting_site_budget" else None,
|
|
last_error=(
|
|
" provider\n timeout "
|
|
if state == "failed"
|
|
else "站点暂时忙,系统会自动继续搜索"
|
|
if state == "queued" and phase == "waiting_site_budget"
|
|
else None
|
|
),
|
|
)
|
|
|
|
|
|
class _Repository:
|
|
"""保存测试快照的异步状态仓储。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化可由测试覆盖的快照集合。"""
|
|
self.tasks: dict[int, SearchTaskSnapshot] = {}
|
|
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 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_execution_status_exposes_queued_site_wait_without_error():
|
|
"""重新入队的站点繁忙应显示等待状态、说明和继续时间。"""
|
|
repository = _Repository()
|
|
retry_at = "2026-09-01T01:00:10+00:00"
|
|
repository.tasks[2] = _task(
|
|
2,
|
|
state="queued",
|
|
phase="waiting_site_budget",
|
|
available_at=retry_at,
|
|
)
|
|
|
|
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((2,)))
|
|
|
|
assert statuses[2].state == "waiting_site_budget"
|
|
assert statuses[2].phase == "waiting_site_budget"
|
|
assert statuses[2].error == "站点暂时忙,系统会自动继续搜索"
|
|
assert statuses[2].next_run_at == retry_at
|
|
assert statuses[2].can_cancel is True
|
|
|
|
|
|
def test_execution_status_exposes_scheduled_new_search_without_failure():
|
|
"""新订阅编辑等待期应显示为已安排,而不是跳过或失败。"""
|
|
repository = _Repository()
|
|
retry_at = "2026-09-01T01:01:00+00:00"
|
|
repository.tasks[4] = _task(
|
|
4,
|
|
state="queued",
|
|
phase="scheduled",
|
|
source="new",
|
|
available_at=retry_at,
|
|
)
|
|
|
|
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((4,)))
|
|
|
|
assert statuses[4].state == "scheduled"
|
|
assert statuses[4].next_run_at == retry_at
|
|
assert statuses[4].error is None
|
|
|
|
|
|
def test_failed_search_exposes_safe_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].error == "订阅操作失败,请刷新后重试"
|
|
|
|
|
|
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_batch_projection_exposes_skipped_count_as_processed_without_success():
|
|
"""批次跳过应计入处理总数,同时保留独立的完成计数。"""
|
|
repository = _Repository()
|
|
repository.batch = replace(
|
|
repository.batch,
|
|
state="skipped",
|
|
total_count=2,
|
|
finished_count=1,
|
|
skipped_count=1,
|
|
)
|
|
repository.tasks = {
|
|
1: _task(1, state="completed", phase="completed"),
|
|
2: _task(2, state="skipped", phase="skipped"),
|
|
}
|
|
|
|
visible = asyncio.run(
|
|
SubscriptionExecutionStatusService(repository).get_batch(
|
|
"batch-1",
|
|
accessible_subscription_ids={1, 2},
|
|
)
|
|
)
|
|
|
|
assert visible is not None
|
|
assert visible.state == "skipped"
|
|
assert visible.finished_count == 1
|
|
assert visible.skipped_count == 1
|
|
assert visible.processed_count == 2
|
|
|
|
|
|
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"]
|