mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
204 lines
11 KiB
Python
204 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import cProfile
|
|
import hashlib
|
|
import os
|
|
import pstats
|
|
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.collection_http import get_collection, list_collections
|
|
from backend.config import Settings
|
|
from backend.image_library_http import list_images, media, thumbnail_media
|
|
from backend.image_processing import ImageProcessingRepository
|
|
from backend.persistence.models import Meme, Scope, ScopeContext, Task
|
|
from backend.persistence.resources import DatabaseResources
|
|
from backend.persistence.storage import StorageCoordinator
|
|
from backend.scope import ScopeServiceFactory
|
|
from backend.services.metadata import PostgresMetadataService
|
|
from backend.services.thumbnails import DerivedThumbnailService
|
|
from backend.services.thumbnails import ThumbnailError
|
|
from backend.visual import identity_from_settings
|
|
|
|
|
|
def test_media_conditionals_reuse_saved_hash_without_reading_files(tmp_path: Path) -> None:
|
|
"""真实数据库与媒体文件验证条件请求不读取完整图片。"""
|
|
url = os.environ.get("MEMEMEOW_TEST_DATABASE_URL")
|
|
if not url:
|
|
pytest.skip("需要显式提供开发测试 PostgreSQL 连接")
|
|
engine = create_engine(url, pool_size=1, max_overflow=0, pool_timeout=3)
|
|
scope = ScopeContext(f"media-cache-validation-{uuid4().hex}")
|
|
resources = DatabaseResources(engine, image_root=tmp_path / "images", data_root=tmp_path, require_local_scope=False)
|
|
with resources.factory() as session, session.begin():
|
|
session.add(Scope(id=scope.scope_id))
|
|
try:
|
|
buffer = BytesIO()
|
|
Image.new("RGB", (16, 8), color="green").save(buffer, format="PNG")
|
|
record = StorageCoordinator(resources, scope_id=scope).upload(buffer.getvalue(), extension=".png", context={}, provenance={})
|
|
services = SimpleNamespace(metadata=PostgresMetadataService(resources, scope_id=scope), thumbnails=DerivedThumbnailService(resources, scope_id=scope))
|
|
assert services.thumbnails.generate(record.id)["status"] == "available"
|
|
|
|
def request(path: str, 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": path, "query_string": b"", "headers": headers})
|
|
|
|
def provide_services(_request: Request):
|
|
"""返回本次 scope 的真实文件与数据库服务。"""
|
|
return services
|
|
|
|
def error(status: int, code: str, message: str) -> HTTPException:
|
|
"""生成媒体入口使用的 HTTP 错误。"""
|
|
return HTTPException(status_code=status, detail={"error": code, "message": message})
|
|
|
|
original_url = f"/media/{record.id}"
|
|
thumbnail_url = f"{original_url}/thumbnail"
|
|
original = asyncio.run(media(request(original_url), meme_id=str(record.id), services=provide_services, error=error))
|
|
thumbnail = asyncio.run(thumbnail_media(request(thumbnail_url), meme_id=str(record.id), services=provide_services, error=error))
|
|
assert original.status_code == thumbnail.status_code == 200
|
|
assert original.headers["etag"] == f'"{record.sha256}"'
|
|
assert original.headers["cache-control"] == thumbnail.headers["cache-control"] == "private, no-cache"
|
|
assert thumbnail.headers["etag"] != original.headers["etag"]
|
|
|
|
observed = []
|
|
|
|
def observe_hash_calls(frame, event, argument):
|
|
"""记录媒体请求中的图片哈希计算入口。"""
|
|
if event == "call" and frame.f_code.co_name in {"image_sha256", "_identity", "_output_is_valid"}:
|
|
observed.append(frame.f_code.co_name)
|
|
|
|
sys.setprofile(observe_hash_calls)
|
|
try:
|
|
original_cached = asyncio.run(media(request(original_url, original.headers["etag"]), meme_id=str(record.id), services=provide_services, error=error))
|
|
thumbnail_cached = asyncio.run(thumbnail_media(request(thumbnail_url, thumbnail.headers["etag"]), meme_id=str(record.id), services=provide_services, error=error))
|
|
finally:
|
|
sys.setprofile(None)
|
|
assert original_cached.status_code == thumbnail_cached.status_code == 304
|
|
assert original_cached.body == thumbnail_cached.body == b""
|
|
assert observed == []
|
|
finally:
|
|
with resources.factory() as session, session.begin():
|
|
session.execute(delete(Scope).where(Scope.id == scope.scope_id))
|
|
engine.dispose()
|
|
|
|
|
|
def test_lists_use_database_without_reading_images(tmp_path: Path) -> None:
|
|
"""使用真实 PostgreSQL 和图片验证列表、分页、合集及缩略图任务提交。"""
|
|
url = os.environ.get("MEMEMEOW_TEST_DATABASE_URL")
|
|
if not url:
|
|
pytest.skip("需要显式提供开发测试 PostgreSQL 连接")
|
|
engine = create_engine(url, pool_size=1, max_overflow=0, pool_timeout=3)
|
|
scope = ScopeContext(f"list-validation-{uuid4().hex}")
|
|
resources = DatabaseResources(engine, image_root=tmp_path / "images", data_root=tmp_path, require_local_scope=False)
|
|
settings = Settings(data_root=tmp_path, image_root=tmp_path / "images")
|
|
factory = ScopeServiceFactory(resources, settings)
|
|
try:
|
|
with resources.factory() as session, session.begin():
|
|
session.add(Scope(id=scope.scope_id))
|
|
services = factory.for_scope(scope)
|
|
records = []
|
|
for index, color in enumerate(("red", "blue")):
|
|
buffer = BytesIO()
|
|
Image.new("RGB", (16, 8), color=color).save(buffer, format="PNG")
|
|
content = buffer.getvalue()
|
|
digest = hashlib.sha256(content).hexdigest()
|
|
record = Meme(
|
|
id=uuid4(), scope_id=scope.scope_id, storage_key=f"{digest}.png",
|
|
display_name=f"list validation {index}", extension=".png",
|
|
size_bytes=len(content), sha256=digest, context_status="pending",
|
|
meme_context={"title": "测试图片"},
|
|
)
|
|
with resources.factory() as session, session.begin():
|
|
session.add(record)
|
|
source = services.metadata.blob_store.root / record.storage_key
|
|
source.write_bytes(content)
|
|
records.append(record)
|
|
assert services.thumbnails.generate(records[0].id)["status"] == "available"
|
|
thumbnail_path, _, _ = services.thumbnails.media_path(records[0].id)
|
|
# 修改测试文件以确认列表展示数据库状态,媒体读取仍执行自身的检查。
|
|
thumbnail_path.write_bytes(b"changed test thumbnail")
|
|
for record in records:
|
|
(services.metadata.blob_store.root / record.storage_key).write_bytes(b"changed test source")
|
|
with resources.environment(scope) as environment:
|
|
collection = environment.collections.create("list validation")
|
|
environment.collections.add_members(collection.id, [record.id for record in records])
|
|
request = Request({"type": "http", "method": "GET", "path": "/images", "query_string": b"", "headers": []})
|
|
processing = ImageProcessingRepository(resources, scope)
|
|
|
|
def environment_for_request(_request: Request):
|
|
"""向真实 HTTP handler 提供当前测试 scope 的数据库环境。"""
|
|
return resources.environment(scope)
|
|
|
|
def error(status: int, code: str, message: str) -> HTTPException:
|
|
"""按公共 API 格式构造错误响应。"""
|
|
return HTTPException(status, detail={"error": code, "message": message})
|
|
|
|
async def read_lists():
|
|
"""执行完整列表 handler,保留真实数据库服务和缩略图任务写入。"""
|
|
images = await list_images(
|
|
request, search="", page=1, page_size=10,
|
|
services=lambda _request: services, environment=environment_for_request,
|
|
processing_repository=lambda _request: processing,
|
|
visual_identity=lambda _request: identity_from_settings(settings), error=error,
|
|
)
|
|
page = await list_images(
|
|
request, search="", page=2, page_size=1,
|
|
services=lambda _request: services, environment=environment_for_request,
|
|
processing_repository=lambda _request: processing,
|
|
visual_identity=lambda _request: identity_from_settings(settings), error=error,
|
|
)
|
|
detail = await get_collection(
|
|
request, str(collection.id), page=1, page_size=10,
|
|
environment=environment_for_request, metadata_service=lambda _request: services.metadata,
|
|
thumbnail_service=lambda _request: services.thumbnails, error=error,
|
|
)
|
|
collections = await list_collections(
|
|
request, page=1, page_size=10, environment=environment_for_request,
|
|
thumbnail_service=lambda _request: services.thumbnails, error=error,
|
|
)
|
|
return images, page, detail, collections
|
|
|
|
profiler = cProfile.Profile()
|
|
images, page, detail, collections = profiler.runcall(asyncio.run, read_lists())
|
|
file_operations = {"image_sha256", "_identity", "_read_identity", "exists_with_identity", "_output_is_valid", "read_bytes"}
|
|
file_modules = {"metadata.py", "thumbnails.py", "storage.py"}
|
|
observed = {(filename, name) for (filename, _line, name) in pstats.Stats(profiler).stats if "/repositories/" not in filename and Path(filename).name in file_modules and name in file_operations}
|
|
assert not observed
|
|
assert images["total"] == 2
|
|
assert len(images["items"]) == 2
|
|
assert {item["thumbnail"]["status"] for item in images["items"]} == {"available", "pending"}
|
|
assert page["total"] == 2 and len(page["items"]) == 1
|
|
assert detail["total"] == 2 and len(detail["members"]) == 2
|
|
assert all(member["metadata"]["title"] == "测试图片" for member in detail["members"])
|
|
expected = {str(record.id): record for record in records}
|
|
for member in detail["members"]:
|
|
record = expected[member["meme_id"]]
|
|
assert member["display_name"] == record.display_name
|
|
assert member["filename"] == member["saved_filename"] == f"{record.display_name}.png"
|
|
assert member["extension"] == ".png" and member["size"] == record.size_bytes
|
|
assert member["media_url"] == f"/media/{record.id}"
|
|
assert collections["total"] == 1
|
|
assert "cover_thumbnail" in collections["items"][0]
|
|
with resources.factory() as session:
|
|
tasks = list(session.scalars(select(Task).where(Task.scope_id == scope.scope_id)))
|
|
assert tasks
|
|
assert all(task.task_type == "derived_thumbnail_generation" for task in tasks)
|
|
assert all(task.payload["meme_id"] == str(records[1].id) for task in tasks)
|
|
# 媒体读取以持久化内容版本为准,文件完整性由维护流程检查。
|
|
assert services.thumbnails.media_path(records[0].id)[0] == thumbnail_path
|
|
finally:
|
|
factory.shutdown()
|
|
with resources.factory() as session, session.begin():
|
|
session.execute(delete(Scope).where(Scope.id == scope.scope_id))
|
|
engine.dispose()
|