"""Tests for ingest-time orientation normalization.""" import hashlib import io import pytest from PIL import Image from PIL import JpegImagePlugin from transcription.config import Settings from transcription.services.normalization import normalize_orientation from transcription.services.normalization import normalize_orientation_async from transcription.services.store import store_source_file def _oriented_jpeg(orientation: int) -> bytes: image = Image.new("RGB", (2, 3)) image.putdata( [ (255, 0, 0), (255, 0, 0), (0, 255, 0), (0, 255, 0), (0, 0, 255), (0, 0, 255), ] ) exif = Image.Exif() exif[274] = orientation buffer = io.BytesIO() image.save(buffer, format="JPEG", quality=100, subsampling=0, exif=exif) return buffer.getvalue() def _oriented_image(orientation: int, image_format: str) -> bytes: image = Image.new("RGB", (2, 3), color="white") exif = Image.Exif() exif[274] = orientation buffer = io.BytesIO() image.save(buffer, format=image_format, exif=exif) return buffer.getvalue() @pytest.mark.unit def test_orientation_three_is_physically_rotated_and_metadata_removed(): result = normalize_orientation(_oriented_jpeg(3), media_type="image/jpeg") assert result is not None assert result.original_orientation == 3 assert result.applied_rotation_degrees == 180 with Image.open(io.BytesIO(result.content)) as normalized: assert normalized.getexif().get(274, 1) == 1 pixel = normalized.getpixel((0, 0)) assert isinstance(pixel, tuple) assert pixel[2] > pixel[0] @pytest.mark.unit def test_orientation_one_is_noop(): assert normalize_orientation(_oriented_jpeg(1), media_type="image/jpeg") is None @pytest.mark.unit def test_normalization_is_idempotent(): once = normalize_orientation(_oriented_jpeg(3), media_type="image/jpeg") assert once is not None assert normalize_orientation(once.content, media_type="image/jpeg") is None @pytest.mark.unit def test_jpeg_reencode_reuses_source_quantization_tables(): """Reusing the source tables is what keeps the rewrite small and near-lossless.""" original = _oriented_jpeg(3) result = normalize_orientation(original, media_type="image/jpeg") assert result is not None with ( Image.open(io.BytesIO(original)) as before, Image.open(io.BytesIO(result.content)) as after, ): assert isinstance(before, JpegImagePlugin.JpegImageFile) assert isinstance(after, JpegImagePlugin.JpegImageFile) assert after.quantization == before.quantization @pytest.mark.unit @pytest.mark.parametrize( ("image_format", "media_type"), [("PNG", "image/png"), ("TIFF", "image/tiff")], ) def test_supported_non_jpeg_orientation_is_normalized(image_format, media_type): result = normalize_orientation(_oriented_image(6, image_format), media_type=media_type) assert result is not None assert result.applied_rotation_degrees == 90 with Image.open(io.BytesIO(result.content)) as normalized: assert normalized.getexif().get(274, 1) == 1 @pytest.mark.unit def test_unsupported_media_type_is_left_alone(): assert normalize_orientation(b"not-an-image", media_type="application/pdf") is None @pytest.mark.asyncio async def test_async_wrapper_matches_sync_result(): """[MED-01]: Pillow work runs off the event loop.""" original = _oriented_jpeg(3) result = await normalize_orientation_async(original, media_type="image/jpeg") expected = normalize_orientation(original, media_type="image/jpeg") assert result is not None assert expected is not None assert result.content == expected.content @pytest.mark.integration @pytest.mark.asyncio @pytest.mark.parametrize("orientation", [3, 6, 8]) async def test_stored_source_never_retains_exif_orientation(tmp_path, orientation): settings = Settings( openrouter_api_key="test-key", upload_dir=tmp_path / "uploads", provider_models=None, ) stored = await store_source_file( filename="page.jpg", file_bytes=_oriented_jpeg(orientation), settings=settings, ) stored_bytes = stored.path.read_bytes() with Image.open(io.BytesIO(stored_bytes)) as image: assert image.getexif().get(274, 1) == 1 assert stored.file_hash == hashlib.sha256(stored_bytes).hexdigest() assert stored.file_size_bytes == len(stored_bytes) @pytest.mark.integration @pytest.mark.asyncio async def test_upright_source_is_stored_byte_for_byte(tmp_path): settings = Settings( openrouter_api_key="test-key", upload_dir=tmp_path / "uploads", provider_models=None, ) original = _oriented_jpeg(1) stored = await store_source_file( filename="page.jpg", file_bytes=original, settings=settings, ) assert stored.path.read_bytes() == original assert stored.file_hash == hashlib.sha256(original).hexdigest()