Files
MemeMeow/tests/test_task_http.py
2026-08-25 00:39:04 +08:00

220 lines
8.9 KiB
Python

"""公共核心任务控制 HTTP 边界测试。"""
from __future__ import annotations
import ast
import asyncio
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
import api
import backend.task_http as task_http
from backend.tasks import TaskRecord
def _request(*, tasks: object | None = None, metadata: object | None = None, settings: object | None = None) -> SimpleNamespace:
"""构造不启动 lifespan 的最小任务请求。"""
state = SimpleNamespace(tasks=tasks, metadata=metadata, settings=settings, agent_activity=None)
return SimpleNamespace(app=SimpleNamespace(state=state), state=SimpleNamespace())
def _error(status: int, code: str, message: str) -> HTTPException:
"""构造任务入口使用的稳定错误 detail。"""
return HTTPException(status_code=status, detail={"error": code, "message": message})
def _repository(_request: object) -> SimpleNamespace:
"""构造没有图片处理父 Job 的 repository。"""
return SimpleNamespace(snapshot=lambda _task_id: None)
def test_task_http_module_keeps_one_way_dependency_and_legacy_aliases() -> None:
"""新模块不反向导入入口,旧任务符号仍保留。"""
source = Path(task_http.__file__).read_text(encoding="utf-8")
tree = ast.parse(source)
imported_modules = {
alias.name.split(".", 1)[0]
for node in ast.walk(tree)
if isinstance(node, ast.Import)
for alias in node.names
}
imported_modules.update(
node.module.split(".", 1)[0]
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module
)
assert "api" not in imported_modules
assert "server_api" not in imported_modules
assert api.list_tasks.__name__ == "list_tasks"
assert api.get_task.__name__ == "get_task"
assert api.cancel_task.__name__ == "cancel_task"
assert api.retry_task.__name__ == "retry_task"
assert api._task_summary.__name__ == "_task_summary"
def test_task_routes_keep_metadata_and_order() -> None:
"""四个任务 route 保持 tasks tag、method、status 和相对顺序。"""
paths = {"/search", "/generate-cache", "/tasks", "/tasks/{task_id}", "/tasks/{task_id}/cancel", "/tasks/{task_id}/retry"}
routes = [route for route in api.app.routes if getattr(route, "path", None) in paths]
assert [route.path for route in routes] == [
"/search",
"/generate-cache",
"/tasks",
"/tasks/{task_id}",
"/tasks/{task_id}/cancel",
"/tasks/{task_id}/retry",
]
assert [route.methods for route in routes] == [{"POST"}, {"POST"}, {"GET"}, {"GET"}, {"POST"}, {"POST"}]
assert [route.status_code for route in routes] == [None, 202, None, None, None, 202]
assert all(route.tags == (["search"] if route.path == "/search" else ["tasks"]) for route in routes)
class _Tasks:
"""记录任务 service 的读取、取消和重试调用。"""
def __init__(self, records: list[TaskRecord]) -> None:
self.records = {record.task_id: record for record in records}
self.calls: list[tuple[str, object]] = []
def list(self, **kwargs: object) -> tuple[list[TaskRecord], str | None]:
"""返回固定任务页并记录分页参数。"""
self.calls.append(("list", kwargs))
return list(self.records.values()), "next-page"
def get(self, task_id: str) -> TaskRecord | None:
"""按任务 ID 返回当前 scope 任务。"""
self.calls.append(("get", task_id))
return self.records.get(task_id)
def cancel(self, task_id: str) -> bool:
"""记录取消并把任务收束为失败状态。"""
self.calls.append(("cancel", task_id))
record = self.records.get(task_id)
if record is None:
return False
record.status = "failed"
return True
def retry(self, task_id: str) -> TaskRecord:
"""返回重试后的任务,或抛出稳定错误。"""
self.calls.append(("retry", task_id))
record = self.records.get(task_id)
if record is None:
raise RuntimeError("task_not_found")
if record.status != "failed":
raise RuntimeError("task_not_failed: current status")
record.status = "queued"
return record
def test_list_reads_current_service_once_and_projects_metadata() -> None:
"""任务列表只读取当前 scope service,并不返回 payload。"""
record = TaskRecord(task_id="task-1", task_type="cache_generation", payload={"secret": "hidden"})
tasks = _Tasks([record])
metadata = SimpleNamespace(image_for_meme=lambda _meme_id: (None, Path("sample.png")))
request = _request(tasks=tasks, metadata=metadata)
service_calls: list[str] = []
def service(_request: object, name: str) -> object:
"""记录 service 名称并只暴露 fake scope service。"""
service_calls.append(name)
return tasks if name == "tasks" else metadata
response = asyncio.run(
task_http.list_tasks(
request,
status=["queued"],
task_type=["cache_generation"],
cursor="cursor-1",
limit=10,
service=service,
processing_repository=_repository,
)
)
assert response["next_cursor"] == "next-page"
assert response["items"][0]["task_id"] == "task-1"
assert "payload" not in response["items"][0]
assert service_calls == ["tasks"]
assert tasks.calls[0] == (
"list",
{"statuses": {"queued"}, "task_types": {"cache_generation"}, "cursor": "cursor-1", "limit": 10},
)
def test_missing_task_uses_processing_fallback_before_stable_404() -> None:
"""普通任务缺失时查询图片处理回退,均缺失才返回 task_not_found。"""
tasks = _Tasks([])
request = _request(tasks=tasks)
snapshots = {"job-1": SimpleNamespace(as_dict=lambda: {"task_id": "job-1", "status": "running"})}
repository_calls: list[str] = []
def repository(_request: object) -> SimpleNamespace:
"""记录父 Job 查询并返回受控快照。"""
return SimpleNamespace(snapshot=lambda task_id: repository_calls.append(task_id) or snapshots.get(task_id))
def service(_request: object, name: str) -> object:
"""只返回当前 scope task service。"""
assert name == "tasks"
return tasks
fallback = asyncio.run(task_http.get_task(request, "job-1", service=service, error=_error, processing_repository=repository))
assert fallback["task_type"] == "visual_embedding_generation"
assert fallback["image_stage"] == "visual"
assert repository_calls == ["job-1"]
with pytest.raises(HTTPException) as caught:
asyncio.run(task_http.get_task(request, "missing", service=service, error=_error, processing_repository=repository))
assert caught.value.status_code == 404
assert caught.value.detail["error"] == "task_not_found"
def test_cancel_calls_agent_adapter_only_for_running_context_task() -> None:
"""取消语境任务时只向适配器传递当前 task id。"""
record = TaskRecord(task_id="agent-1", task_type="meme_context_generation", status="running")
tasks = _Tasks([record])
request = _request(tasks=tasks)
cancelled: list[str] = []
def service(_request: object, name: str) -> object:
"""返回当前 scope task service。"""
assert name == "tasks"
return tasks
response = asyncio.run(
task_http.cancel_task(
request,
"agent-1",
service=service,
error=_error,
processing_repository=_repository,
cancel_agent=lambda _request, task_id: cancelled.append(task_id),
)
)
assert response["status"] == "failed"
assert cancelled == ["agent-1"]
assert [call[0] for call in tasks.calls] == ["get", "cancel", "get"]
@pytest.mark.parametrize(
("task_id", "status", "expected_status", "expected_code"),
[("missing", "failed", 404, "task_not_found"), ("queued", "queued", 409, "task_not_failed")],
)
def test_retry_maps_stable_task_errors(task_id: str, status: str, expected_status: int, expected_code: str) -> None:
"""重试错误只返回稳定 HTTP code,不泄露底层诊断文本。"""
records = [] if task_id == "missing" else [TaskRecord(task_id=task_id, task_type="cache_generation", status=status)]
tasks = _Tasks(records)
request = _request(tasks=tasks)
def service(_request: object, name: str) -> object:
"""返回当前 scope task service。"""
assert name == "tasks"
return tasks
with pytest.raises(HTTPException) as caught:
asyncio.run(task_http.retry_task(request, task_id, service=service, error=_error, processing_repository=_repository))
assert caught.value.status_code == expected_status
assert caught.value.detail == {"error": expected_code, "message": "任务不存在" if expected_code == "task_not_found" else "只有失败任务可以重试"}