250 lines
8.2 KiB
Python
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
|
|
)
|