Files
photoanalyzer/tests/integration/test_thumbnails.py

250 lines
8.2 KiB
Python

"""Thumbnail service + endpoint: orientation, transparency, caching, concurrency,
corruption, invalidation, eviction, and path safety.
Fixtures are inlined (not in a sibling conftest.py) to avoid shadowing the
characterization suite's bare ``conftest`` import under pytest's prepend mode.
"""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from types import SimpleNamespace
import numpy as np
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from sqlalchemy import select
from photo_pipeline.api.app import create_app
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.models import Asset, Thumbnail
from photo_pipeline.services.inventory import InventoryService
from photo_pipeline.services.thumbnails import (
ImageTooLarge,
InvalidSize,
PathNotAllowed,
ThumbnailNotFound,
ThumbnailService,
UnsupportedImage,
)
@pytest.fixture
def env(tmp_path):
data = tmp_path / "data"
data.mkdir()
lib = tmp_path / "lib"
lib.mkdir()
config = Config.from_env(
{
"PHOTO_PIPELINE_DATA_DIR": str(data),
"PHOTO_PIPELINE_LIBRARY_ROOTS": str(lib),
}
)
run_migrations(config.database_url)
engines = []
def factory():
engine = create_db_engine(config.database_url)
engines.append(engine)
return create_session_factory(engine)
sf = factory()
yield SimpleNamespace(config=config, lib=lib, data=data, sf=sf, factory=factory)
for engine in engines:
engine.dispose()
@pytest.fixture
def thumbs(env):
return ThumbnailService(env.sf, env.config)
@pytest.fixture
def inventory(env):
return InventoryService(env.sf)
def plain_jpeg(path, seed, size=(200, 150)):
path.parent.mkdir(parents=True, exist_ok=True)
arr = np.random.default_rng(seed).integers(0, 256, (size[1], size[0], 3), dtype=np.uint8)
Image.fromarray(arr).save(path, quality=90)
return path
def oriented_jpeg(path, w, h, orientation, seed=1):
arr = np.random.default_rng(seed).integers(0, 256, (h, w, 3), dtype=np.uint8)
img = Image.fromarray(arr)
exif = img.getexif()
exif[274] = orientation # 274 = Orientation
img.save(path, exif=exif, quality=90)
return path
def transparent_png(path, size=(120, 90)):
arr = np.random.default_rng(2).integers(0, 256, (size[1], size[0], 4), dtype=np.uint8)
Image.fromarray(arr, "RGBA").save(path)
return path
def first_asset_id(scan_result):
return next(iter(scan_result.asset_ids.values()))
def insert_asset(factory, asset_id, current_path, pixel="pix", sha="sha"):
with factory() as session:
session.add(
Asset(
id=asset_id,
original_path=current_path,
current_path=current_path,
current_sha256=sha,
pixel_sha256=pixel,
hash_version=1,
discovered_at=datetime.now(timezone.utc),
availability_state="active",
)
)
session.commit()
def test_applies_orientation_and_downsizes(env, thumbs, inventory):
# 1000x600 landscape tagged "rotate" -> displayed 600x1000 portrait.
oriented_jpeg(env.lib / "rot.jpg", 1000, 600, orientation=6)
asset_id = first_asset_id(inventory.scan(env.lib))
path = thumbs.generate(asset_id, 512)
with Image.open(path) as image:
assert image.format == "WEBP"
assert max(image.size) == 512 # long edge bounded by requested size
assert image.height > image.width # orientation was applied before resize
def test_preserves_transparency(env, thumbs, inventory):
transparent_png(env.lib / "t.png")
asset_id = first_asset_id(inventory.scan(env.lib))
path = thumbs.generate(asset_id, 256)
with Image.open(path) as image:
assert "A" in image.getbands() # alpha survived into the WebP
def test_cache_hit_returns_same_file(env, thumbs, inventory):
plain_jpeg(env.lib / "a.jpg", 1)
asset_id = first_asset_id(inventory.scan(env.lib))
first = thumbs.generate(asset_id, 512)
second = thumbs.generate(asset_id, 512)
assert first == second
def test_concurrent_generation_yields_one_valid_file(env, thumbs, inventory):
plain_jpeg(env.lib / "a.jpg", 1, size=(800, 600))
asset_id = first_asset_id(inventory.scan(env.lib))
with ThreadPoolExecutor(max_workers=8) as pool:
paths = list(pool.map(lambda _: str(thumbs.generate(asset_id, 512)), range(8)))
assert len(set(paths)) == 1
with Image.open(paths[0]) as image:
assert image.format == "WEBP"
def test_corrupt_source_errors_and_is_not_retried(env, thumbs, inventory):
(env.lib / "broken.jpg").write_bytes(b"this is not an image")
asset_id = first_asset_id(inventory.scan(env.lib))
with pytest.raises(UnsupportedImage):
thumbs.generate(asset_id, 512)
with env.factory()() as session:
rows = list(session.execute(select(Thumbnail)).scalars())
assert rows and rows[0].state == "error"
# The persisted failure is replayed rather than re-rendered.
with pytest.raises(UnsupportedImage):
thumbs.generate(asset_id, 512)
def test_pixel_change_invalidates_cache(env, thumbs, inventory):
a = plain_jpeg(env.lib / "a.jpg", 1)
asset_id = first_asset_id(inventory.scan(env.lib))
before = thumbs.generate(asset_id, 512)
plain_jpeg(a, seed=99) # different pixels at the same path
inventory.scan(env.lib) # refreshes pixel_sha256
after = thumbs.generate(asset_id, 512)
assert before != after # new pixel identity -> new cache key
def test_quota_evicts_least_recently_used(env, inventory):
for i in range(3):
plain_jpeg(env.lib / f"{i}.jpg", seed=i, size=(400, 300))
ids = list(inventory.scan(env.lib).asset_ids.values())
tiny = env.config.model_copy(update={"thumbnail_cache_quota_bytes": 100})
service = ThumbnailService(env.sf, tiny)
last = None
for asset_id in ids:
last = service.generate(asset_id, 512)
remaining = list((env.config.thumbnail_cache_dir).rglob("*.webp"))
assert len(remaining) < 3 # eviction happened
assert last is not None and last.exists() # the just-created file is pinned
def test_path_outside_roots_is_denied(env, thumbs, tmp_path):
outside = tmp_path / "outside.jpg"
plain_jpeg(outside, 1)
insert_asset(env.sf, "a-out", str(outside))
with pytest.raises(PathNotAllowed):
thumbs.generate("a-out", 512)
def test_excluded_path_is_denied(env, thumbs):
hidden = env.lib / "_IGNORE" / "secret.jpg"
plain_jpeg(hidden, 1)
insert_asset(env.sf, "a-ign", str(hidden))
with pytest.raises(PathNotAllowed):
thumbs.generate("a-ign", 512)
def test_unknown_asset_and_bad_size(env, thumbs):
with pytest.raises(ThumbnailNotFound):
thumbs.generate("ghost", 512)
with pytest.raises(InvalidSize):
thumbs.generate("ghost", 999)
def test_oversized_image_is_rejected(env, inventory):
plain_jpeg(env.lib / "big.jpg", 1, size=(400, 300))
asset_id = first_asset_id(inventory.scan(env.lib))
tiny = env.config.model_copy(update={"thumbnail_max_pixels": 1000})
service = ThumbnailService(env.sf, tiny)
with pytest.raises(ImageTooLarge):
service.generate(asset_id, 512)
def test_api_endpoint_serves_and_reports_errors(env):
plain_jpeg(env.lib / "a.jpg", 1, size=(800, 600))
app = create_app(env.config)
with TestClient(app) as client:
InventoryService(app.state.session_factory).scan(env.lib)
asset_id = next(
iter(
{
a.id
for a in app.state.session_factory().execute(select(Asset)).scalars()
}
)
)
ok = client.get(f"/api/v1/assets/{asset_id}/thumbnail?size=512")
assert ok.status_code == 200
assert ok.headers["content-type"] == "image/webp"
assert "immutable" in ok.headers.get("cache-control", "")
assert client.get("/api/v1/assets/ghost/thumbnail?size=512").status_code == 404
assert (
client.get(f"/api/v1/assets/{asset_id}/thumbnail?size=999").status_code == 422
)