"""订阅执行状态合并、批次权限和操作能力测试。""" 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"]