US01-05: Generate and Serve Managed Thumbnails (#50)
This commit was merged in pull request #50.
This commit is contained in:
249
tests/integration/test_thumbnails.py
Normal file
249
tests/integration/test_thumbnails.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""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
|
||||
)
|
||||
Reference in New Issue
Block a user