mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-26 03:39:24 +08:00
186 lines
9.0 KiB
Python
186 lines
9.0 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
|
|
from backend import image_upload_http
|
|
from backend.operation_policy import OperationPolicyError
|
|
|
|
|
|
class _Upload:
|
|
"""提供受控文件名和异步读取行为的 multipart 文件替身。"""
|
|
|
|
def __init__(self, filename: str, content: bytes) -> None:
|
|
"""保存单文件输入和读取游标。"""
|
|
self.filename = filename
|
|
self._content = content
|
|
self._read = False
|
|
|
|
async def read(self, size: int) -> bytes:
|
|
"""按受限读取接口返回内容,第二次读取返回 EOF。"""
|
|
if self._read:
|
|
return b""
|
|
self._read = True
|
|
return self._content[:size]
|
|
|
|
|
|
class _Metadata:
|
|
"""记录 scope-bound metadata 访问和 durable 上传副作用。"""
|
|
|
|
def __init__(self, root: Path, events: list[str]) -> None:
|
|
"""初始化测试文件根目录及事件记录。"""
|
|
self.root = root
|
|
self.events = events
|
|
self.blob_store = SimpleNamespace(resolve=self.resolve)
|
|
|
|
def resolve(self, key: str, *, must_exist: bool = False) -> Path:
|
|
"""返回当前 scope 根目录下的受控目标。"""
|
|
self.events.append("resolve")
|
|
return self.root / key
|
|
|
|
def find_existing_upload(self, key: str, *, sha256: str, size_bytes: int):
|
|
"""默认表示目标尚未存在。"""
|
|
self.events.append("find_existing_upload")
|
|
return None
|
|
|
|
def upload_bytes(self, content: bytes, *, target_key: str):
|
|
"""写入测试文件并返回旧 metadata service 形状。"""
|
|
self.events.append("upload_bytes")
|
|
path = self.root / target_key
|
|
path.write_bytes(content)
|
|
return "meme-1", path
|
|
|
|
def status(self, image: Path) -> dict[str, str]:
|
|
"""返回稳定 metadata 状态。"""
|
|
self.events.append("status")
|
|
return {"status": "pending"}
|
|
|
|
|
|
def _request(tmp_path: Path, events: list[str], files: list[_Upload], *, fields: set[str] | None = None) -> SimpleNamespace:
|
|
"""构造只满足上传 callback 所需字段的最小 Request 替身。"""
|
|
settings = SimpleNamespace(max_files_per_request=20, max_request_bytes=None, max_upload_size=1024)
|
|
state = SimpleNamespace(settings=settings)
|
|
request = SimpleNamespace(app=SimpleNamespace(state=state), fields=fields or {"files"}, events=events)
|
|
request.form = SimpleNamespace(keys=lambda: request.fields, get=lambda key: None, getlist=lambda key: files if key == "files" else [])
|
|
return request
|
|
|
|
|
|
def _error(status: int, code: str, message: str) -> HTTPException:
|
|
"""构造与入口相同 detail 形状的测试异常。"""
|
|
return HTTPException(status_code=status, detail={"error": code, "message": message})
|
|
|
|
|
|
def _options(*_args, **_kwargs) -> SimpleNamespace:
|
|
"""返回默认图片处理选项。"""
|
|
return SimpleNamespace(reverse_image_policy="forbid", auto_name=False)
|
|
|
|
|
|
def _common_kwargs(request: SimpleNamespace, metadata: _Metadata, events: list[str], tmp_path: Path) -> dict[str, object]:
|
|
"""为模块函数注入最小、显式且 scope-bound 的依赖。"""
|
|
request.form = SimpleNamespace(keys=lambda: request.fields, get=lambda key: None, getlist=lambda key: request._files if key == "files" else [])
|
|
return {
|
|
"settings": lambda received: received.app.state.settings,
|
|
"metadata_service": lambda received: metadata,
|
|
"task_service": lambda received: SimpleNamespace(seal_batch=lambda _batch: events.append("seal"), schedule=lambda _task: events.append("schedule")),
|
|
"normalize_processing_options": _options,
|
|
"parse_multipart_bool": lambda value, default=False: default if value is None else bool(value),
|
|
"sanitize_filename": lambda value: value,
|
|
"validate_image": lambda content, suffix: events.append("validate_image"),
|
|
"calculate_sha256": lambda content: "a" * 64,
|
|
"processing_worker": lambda received: None,
|
|
"submit_processing_job": lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("no_worker")),
|
|
"processing_config": lambda received: {},
|
|
"submit_visual_task": lambda *args, **kwargs: SimpleNamespace(task_id="task-1", status="pending"),
|
|
"context_enqueue_error": lambda exc: "enqueue_failed",
|
|
"acquire_operation": lambda *args, **kwargs: (events.append("acquire") or "grant"),
|
|
"commit_operation": lambda received, grant: events.append("commit"),
|
|
"release_operation": lambda received, grant: events.append("release"),
|
|
"invalidate_search": lambda received: events.append("invalidate"),
|
|
"error": _error,
|
|
}
|
|
|
|
|
|
def test_upload_route_and_module_dependency_are_single_directional() -> None:
|
|
"""上传 canonical route 只注册一次,公共模块不反向依赖入口。"""
|
|
matching = [route for route in api.app.routes if route.path == "/images/upload"]
|
|
assert len(matching) == 1
|
|
assert matching[0].methods == {"POST"}
|
|
assert matching[0].tags == ["images"]
|
|
source = Path(image_upload_http.__file__).read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
imported = {node.module.split(".", 1)[0] for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module}
|
|
imported.update(alias.name.split(".", 1)[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names)
|
|
assert "api" not in imported
|
|
assert "server_api" not in imported
|
|
assert api.upload_images.__name__ == "upload_images"
|
|
assert api._parse_upload_form is image_upload_http._parse_upload_form
|
|
assert api._read_upload_content is image_upload_http._read_upload_content
|
|
|
|
|
|
def test_upload_rejects_unknown_field_before_scope_service(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""未知 multipart 字段在任何 metadata 或文件副作用前被拒绝。"""
|
|
events: list[str] = []
|
|
request = _request(tmp_path, events, [_Upload("a.png", b"png")], fields={"files", "directory"})
|
|
request._files = [_Upload("a.png", b"png")]
|
|
metadata = _Metadata(tmp_path, events)
|
|
async def parse_form(*_args, **_kwargs):
|
|
return request.form
|
|
monkeypatch.setattr(image_upload_http, "_parse_upload_form", parse_form)
|
|
with pytest.raises(HTTPException) as caught:
|
|
asyncio.run(image_upload_http.upload_images(request, **_common_kwargs(request, metadata, events, tmp_path)))
|
|
assert caught.value.status_code == 400
|
|
assert caught.value.detail["error"] == "invalid_request"
|
|
assert events == []
|
|
|
|
|
|
def test_upload_commits_before_processing_and_search_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""新图片必须按 acquire、durable、commit、任务和检索失效顺序收束。"""
|
|
events: list[str] = []
|
|
request = _request(tmp_path, events, [_Upload("a.png", b"png")])
|
|
request._files = [_Upload("a.png", b"png")]
|
|
metadata = _Metadata(tmp_path, events)
|
|
async def parse_form(*_args, **_kwargs):
|
|
return request.form
|
|
monkeypatch.setattr(image_upload_http, "_parse_upload_form", parse_form)
|
|
result = asyncio.run(image_upload_http.upload_images(request, **_common_kwargs(request, metadata, events, tmp_path)))
|
|
assert result["results"][0]["ok"] is True
|
|
assert events.index("find_existing_upload") < events.index("acquire") < events.index("upload_bytes") < events.index("commit")
|
|
assert events.index("status") < events.index("invalidate")
|
|
|
|
|
|
def test_upload_policy_rejection_is_fail_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""policy 拒绝不得触碰 durable upload。"""
|
|
events: list[str] = []
|
|
request = _request(tmp_path, events, [_Upload("a.png", b"png")])
|
|
request._files = [_Upload("a.png", b"png")]
|
|
metadata = _Metadata(tmp_path, events)
|
|
async def parse_form(*_args, **_kwargs):
|
|
return request.form
|
|
monkeypatch.setattr(image_upload_http, "_parse_upload_form", parse_form)
|
|
kwargs = _common_kwargs(request, metadata, events, tmp_path)
|
|
kwargs["acquire_operation"] = lambda *args, **kwargs: (_ for _ in ()).throw(OperationPolicyError("operation_forbidden"))
|
|
kwargs["operation_error"] = lambda exc: _error(403, exc.code, "forbidden")
|
|
with pytest.raises(HTTPException) as caught:
|
|
asyncio.run(image_upload_http.upload_images(request, **kwargs))
|
|
assert caught.value.status_code == 403
|
|
assert "upload_bytes" not in events
|
|
assert "commit" not in events
|
|
|
|
|
|
def test_public_upload_filename_omits_hash_fallback_without_display_record() -> None:
|
|
"""旧 facade 只返回 meme_id 时,上传结果不能把内容寻址 key 当文件名返回。"""
|
|
digest = "a" * 64
|
|
record = SimpleNamespace(sha256=digest, extension=".png")
|
|
|
|
assert image_upload_http._public_saved_filename(SimpleNamespace(), record, f"{digest}.png") is None
|
|
assert image_upload_http._public_saved_filename(SimpleNamespace(), "meme-1", f"{digest}.png") is None
|
|
assert image_upload_http._public_saved_filename(SimpleNamespace(), "meme-1", "原名.png") == "原名.png"
|