Files
MemeMeow/tests/test_image_export.py
2026-10-05 18:04:36 +08:00

252 lines
13 KiB
Python

# 检索尺寸输出的真实图片及 PostgreSQL 验证,覆盖格式、动画、权限和派生清理。
from __future__ import annotations
import asyncio
import os
import subprocess
import sys
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from PIL import Image
from sqlalchemy import create_engine, delete, select
from starlette.requests import Request
from backend.gif_compression import scan_gif
from backend.config import Settings
from backend.image_export import DerivedImageExportService, ImageExportConfig, render_image_export
from backend.image_library_http import media
from backend.persistence.models import DerivedImageThumbnail, Scope, ScopeContext
from backend.persistence.resources import DatabaseResources
from backend.persistence.storage import StorageCoordinator
from backend.services.metadata import PostgresMetadataService
from backend.services.thumbnails import DerivedThumbnailService, ThumbnailError
from backend.scope import ScopeServiceFactory
from backend.system_http import error
def test_encoding_worker_imports_only_image_dependencies() -> None:
"""实际启动解释器验证编码入口保持独立,不加载 NumPy 和数据库服务。"""
subprocess.run(
[sys.executable, "-c", "import sys; import backend.image_export_worker; assert all(name not in sys.modules for name in (\"numpy\", \"backend.services\", \"backend.persistence.models\"))"],
check=True,
)
def image_bytes(size: tuple[int, int], image_format: str = "PNG", **options) -> bytes:
"""生成带透明背景或 RGB 颜色的真实图片,供输出解码测试使用。"""
mode = "RGBA" if image_format == "PNG" else "RGB"
color = (30, 60, 90, 128) if mode == "RGBA" else (30, 60, 90)
with Image.new(mode, size, color) as image:
output = BytesIO()
image.save(output, format=image_format, **options)
return output.getvalue()
def animation_bytes(loop: int | None) -> bytes:
"""生成具有透明区域和不同帧时长的 GIF,供动画缩放测试使用。"""
frames = []
for color in (1, 2, 3):
frame = Image.new("P", (960, 480), color)
frame.putpalette([0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255] + [0] * 756)
frame.paste(0, (0, 0, 960, 100))
frame.info["transparency"] = 0
frames.append(frame)
output = BytesIO()
options = {} if loop is None else {"loop": loop}
frames[0].save(
output, format="GIF", save_all=True, append_images=frames[1:],
duration=[40, 80, 120], transparency=0, disposal=2, optimize=False, **options,
)
for frame in frames:
frame.close()
return output.getvalue()
@pytest.mark.parametrize("max_edge", [60, 120, 240, 360, 480, 640])
@pytest.mark.parametrize("image_format,extension", [("PNG", ".png"), ("JPEG", ".jpeg")])
def test_static_resize_keeps_ratio_and_format(max_edge: int, image_format: str, extension: str) -> None:
"""解码真实输出验证全部档位保持比例、格式和 PNG 透明通道。"""
config = ImageExportConfig(profile=f"export-v1-{max_edge}", max_edge=max_edge)
rendered = render_image_export(image_bytes((960, 480), image_format), extension, config)
with Image.open(BytesIO(rendered.content)) as result:
result.load()
assert result.format == image_format
assert result.size == (max_edge, max_edge // 2) == (rendered.width, rendered.height)
if image_format == "PNG":
assert result.getpixel((10, 10))[3] == 128
@pytest.mark.parametrize("max_edge", [60, 120, 480])
def test_small_image_keeps_original_bytes(max_edge: int) -> None:
"""尺寸小于档位时保留原始字节,图片不放大。"""
content = image_bytes((20, 10))
config = ImageExportConfig(profile=f"export-v1-{max_edge}", max_edge=max_edge)
rendered = render_image_export(content, ".png", config)
assert rendered.content == content
assert (rendered.width, rendered.height) == (20, 10)
def test_small_image_obeys_output_limit() -> None:
"""小图保持尺寸时仍检查输出预算,禁止绕过文件大小限制。"""
content = image_bytes((100, 50))
with pytest.raises(ThumbnailError, match="image_export_output_limit_exceeded"):
render_image_export(content, ".png", ImageExportConfig(max_output_bytes=len(content) - 1))
def test_jpeg_orientation_is_applied() -> None:
"""带 EXIF 方向的 JPEG 输出使用实际显示方向。"""
orientation = Image.Exif()
orientation[274] = 6
rendered = render_image_export(image_bytes((960, 480), "JPEG", exif=orientation), ".jpg", ImageExportConfig())
with Image.open(BytesIO(rendered.content)) as result:
assert result.size == (240, 480)
assert result.getexif().get(274) in {None, 1}
@pytest.mark.parametrize("max_edge", [60, 120, 240, 360, 480, 640])
@pytest.mark.parametrize("loop", [None, 0, 3])
def test_gif_resize_keeps_animation(loop: int | None, max_edge: int) -> None:
"""使用实际 GIF 编码器验证帧数、帧时长、循环和透明区域。"""
content = animation_bytes(loop)
config = ImageExportConfig(profile=f"export-v1-{max_edge}", max_edge=max_edge)
rendered = render_image_export(content, ".gif", config)
with Image.open(BytesIO(content)) as source, Image.open(BytesIO(rendered.content)) as result:
original = scan_gif(source)
final = scan_gif(result)
assert final.size == (max_edge, max_edge // 2)
assert final.durations == original.durations
assert final.loop == original.loop
assert final.transparent_frames == original.transparent_frames == (True, True, True)
@pytest.mark.parametrize("max_edge", [0, 241, 1024, True, "480"])
def test_unlisted_sizes_are_rejected(max_edge) -> None:
"""配置入口拒绝固定档位以外的值和错误类型。"""
with pytest.raises(ValueError, match="image_export_size_invalid"):
ImageExportConfig(profile=f"export-v1-{max_edge}", max_edge=max_edge)
def test_resource_limits_and_format_errors_are_reported() -> None:
"""真实解码与编码触发资源上限及格式错误,保留错误码。"""
content = image_bytes((960, 480))
with pytest.raises(ThumbnailError, match="image_export_temp_limit_exceeded"):
render_image_export(content, ".png", ImageExportConfig(max_temp_bytes=1))
with pytest.raises(ThumbnailError, match="image_export_output_limit_exceeded"):
render_image_export(content, ".png", ImageExportConfig(max_output_bytes=1))
with pytest.raises(ThumbnailError, match="invalid_image"):
render_image_export(content, ".jpg", ImageExportConfig())
def test_export_cache_scope_and_delete(tmp_path: Path) -> None:
"""真实 PostgreSQL 与文件验证缓存复用、scope 隔离、HTTP 条件请求及删除。"""
database_url = os.environ.get("MEMEMEOW_TEST_DATABASE_URL")
if not database_url:
pytest.skip("需要显式提供开发测试 PostgreSQL 连接")
engine = create_engine(database_url, pool_size=1, max_overflow=0, pool_timeout=3)
scope = ScopeContext(f"image-export-validation-{uuid4().hex}")
other_scope = ScopeContext(f"image-export-validation-{uuid4().hex}")
resources = DatabaseResources(engine, image_root=tmp_path / "images", data_root=tmp_path, require_local_scope=False)
service_factory = ScopeServiceFactory(resources, Settings(_env_file=None, thumbnail_concurrency=1))
with resources.factory() as session, session.begin():
session.add_all([Scope(id=scope.scope_id), Scope(id=other_scope.scope_id)])
try:
first_services = service_factory.for_scope(scope)
second_services = service_factory.for_scope(scope)
other_services = service_factory.for_scope(other_scope)
slots = first_services.thumbnails.generation_slots
assert slots is second_services.thumbnails.generation_slots
assert slots is other_services.thumbnails.generation_slots
assert slots.acquire(blocking=False)
try:
assert not second_services.thumbnails.generation_slots.acquire(blocking=False)
assert not other_services.thumbnails.generation_slots.acquire(blocking=False)
finally:
slots.release()
storage = StorageCoordinator(resources, scope_id=scope)
record = storage.upload(image_bytes((960, 480)), extension=".png", context={}, provenance={})
thumbnails = DerivedThumbnailService(resources, scope_id=scope)
services = SimpleNamespace(metadata=PostgresMetadataService(resources, scope_id=scope), thumbnails=thumbnails)
def provide_services(_request: Request):
"""将同一 scope 的真实服务组合提供给媒体边界。"""
return services
def request(etag: str | None = None) -> Request:
"""构造实际 ASGI 媒体请求,按需携带条件缓存标签。"""
headers = [] if etag is None else [(b"if-none-match", etag.encode("ascii"))]
return Request({"type": "http", "method": "GET", "path": f"/media/{record.id}", "query_string": b"max_edge=480", "headers": headers})
source_path = resources.blob_store_for_scope(scope).resolve(record.storage_key, must_exist=True)
source_permissions = source_path.stat().st_mode
try:
source_path.chmod(0)
with pytest.raises(HTTPException) as unreadable:
asyncio.run(media(request(), meme_id=str(record.id), max_edge=480, services=provide_services, error=error))
assert unreadable.value.status_code == 409
assert unreadable.value.detail["error"] == "thumbnail_source_unavailable"
assert "读取权限" in unreadable.value.detail["message"]
finally:
source_path.chmod(source_permissions)
paths = {}
for max_edge in (60, 120, 240, 360, 480, 640):
exports = DerivedImageExportService(thumbnails, max_edge)
assert exports.generation_slots is thumbnails.generation_slots
path, media_type, digest = exports.prepare_media(record.id)
paths[max_edge] = path
identity = (path.stat().st_mtime_ns, path.read_bytes())
assert exports.prepare_media(record.id) == (path, media_type, digest)
assert (path.stat().st_mtime_ns, path.read_bytes()) == identity
assert media_type == "image/png"
with Image.open(path) as result:
assert result.size == (max_edge, max_edge // 2)
first = asyncio.run(media(request(), meme_id=str(record.id), max_edge=480, services=provide_services, error=error))
assert first.status_code == 200
cached = asyncio.run(media(request(first.headers["etag"]), meme_id=str(record.id), max_edge=480, services=provide_services, error=error))
assert cached.status_code == 304
assert cached.headers["cache-control"] == "private, no-cache"
assert cached.headers["vary"] == "Cookie"
with pytest.raises(HTTPException) as invalid:
asyncio.run(media(request(), meme_id=str(record.id), max_edge=321, services=provide_services, error=error))
assert invalid.value.status_code == 422
assert invalid.value.detail["error"] == "image_export_size_invalid"
foreign = DerivedImageExportService(DerivedThumbnailService(resources, scope_id=other_scope), 480)
with pytest.raises(ThumbnailError, match="meme_not_found"):
foreign.prepare_media(record.id)
paths[480].unlink()
assert DerivedImageExportService(thumbnails, 480).prepare_media(record.id)[0].is_file()
for _index in range(thumbnails.config.concurrency):
assert thumbnails.generation_slots.acquire(blocking=False)
try:
with pytest.raises(ThumbnailError, match="thumbnail_backpressure"):
DerivedImageExportService(thumbnails, 480).generate(record.id)
finally:
for _index in range(thumbnails.config.concurrency):
thumbnails.generation_slots.release()
storage.delete(record.id)
assert all(not path.exists() for path in paths.values())
with resources.factory() as session:
assert not session.scalars(select(DerivedImageThumbnail).where(DerivedImageThumbnail.meme_id == record.id)).all()
for content, extension, media_type in [
(image_bytes((480, 960), "JPEG"), ".jpg", "image/jpeg"),
(animation_bytes(3), ".gif", "image/gif"),
]:
formatted = storage.upload(content, extension=extension, context={}, provenance={})
exports = DerivedImageExportService(thumbnails, 480)
path, actual_media_type, digest = exports.prepare_media(formatted.id)
assert actual_media_type == media_type
assert exports.prepare_media(formatted.id) == (path, actual_media_type, digest)
storage.delete(formatted.id)
assert not path.exists()
finally:
service_factory.shutdown()
with resources.factory() as session, session.begin():
session.execute(delete(Scope).where(Scope.id.in_([scope.scope_id, other_scope.scope_id])))
engine.dispose()