"""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 )