mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-18 05:36:50 +08:00
220 lines
8.9 KiB
Python
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 "只有失败任务可以重试"}
|