import io import shutil import tempfile from datetime import timedelta from PIL import Image from django.core.management import call_command from django.core.files.uploadedfile import SimpleUploadedFile from django.test import RequestFactory from django.test import TestCase, override_settings from django.utils import timezone from apps.blog.api.schemas import AuthorSchema, PostListSchema from apps.blog.models import Post from apps.events.api.schemas import EventGallerySchema, EventListSchema from apps.events.models import Event from apps.gallery.api.schemas import GallerySchema from apps.gallery.models import Gallery from apps.users.api.schemas import UserProfileSchema from apps.users.models import User from core.media import ( BLUR_VARIANT, PREVIEW_VARIANT, THUMBNAIL_VARIANT, delete_image_derivatives, derivative_name, process_public_image, ) class MediaProcessingTests(TestCase): def setUp(self): super().setUp() self.media_root = tempfile.mkdtemp() self.addCleanup(lambda: shutil.rmtree(self.media_root, ignore_errors=True)) self.override = override_settings(MEDIA_ROOT=self.media_root, MEDIA_URL="/media/") self.override.enable() self.addCleanup(self.override.disable) self.user = User.objects.create_user( username="media-user", email="media@example.com", password="TestPass123!", ) self.factory = RequestFactory() def _upload(self, name: str, *, mode: str = "RGB", fmt: str = "JPEG") -> SimpleUploadedFile: buffer = io.BytesIO() Image.new(mode, (2400, 1800), color=(20, 80, 200)).save(buffer, format=fmt) buffer.seek(0) return SimpleUploadedFile(name, buffer.read(), content_type=f"image/{fmt.lower()}") def test_process_public_image_generates_derivatives_for_jpeg(self): gallery = Gallery.objects.create( title="JPEG image", uploaded_by=self.user, image=self._upload("photo.jpg", fmt="JPEG"), ) result = process_public_image(gallery.image, "gallery", force=True) self.assertTrue(result.processed) self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))) self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))) self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))) def test_process_public_image_generates_derivatives_for_png(self): gallery = Gallery.objects.create( title="PNG image", uploaded_by=self.user, image=self._upload("photo.png", mode="RGBA", fmt="PNG"), ) result = process_public_image(gallery.image, "gallery", force=True) self.assertEqual(result.width, 2400) self.assertEqual(result.height, 1800) self.assertTrue(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))) def test_delete_image_derivatives_removes_sidecars(self): gallery = Gallery.objects.create( title="Delete derivatives", uploaded_by=self.user, image=self._upload("cleanup.jpg", fmt="JPEG"), ) process_public_image(gallery.image, "gallery", force=True) delete_image_derivatives(gallery.image, "gallery") self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, THUMBNAIL_VARIANT))) self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, PREVIEW_VARIANT))) self.assertFalse(gallery.image.storage.exists(derivative_name(gallery.image.name, BLUR_VARIANT))) def test_replacing_image_removes_previous_sidecars(self): gallery = Gallery.objects.create( title="Replace derivatives", uploaded_by=self.user, image=self._upload("replace-first.jpg", fmt="JPEG"), ) first_name = gallery.image.name gallery.image = self._upload("replace-second.jpg", fmt="JPEG") gallery.save() self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, THUMBNAIL_VARIANT))) self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, PREVIEW_VARIANT))) self.assertFalse(gallery.image.storage.exists(derivative_name(first_name, BLUR_VARIANT))) def test_backfill_command_is_idempotent(self): gallery = Gallery(title="Backfill image", uploaded_by=self.user) gallery._defer_image_processing = True gallery.image = self._upload("backfill.jpg", fmt="JPEG") gallery.save() first = io.StringIO() second = io.StringIO() call_command("backfill_media_derivatives", stdout=first) call_command("backfill_media_derivatives", stdout=second) self.assertIn("processed=1", first.getvalue()) self.assertIn("skipped=1", second.getvalue()) def test_schema_resolvers_expose_derivative_urls(self): self.user.profile_picture.save("profile.jpg", self._upload("profile.jpg", fmt="JPEG")) gallery = Gallery.objects.create( title="Schema image", uploaded_by=self.user, image=self._upload("schema-gallery.jpg", fmt="JPEG"), ) event = Event.objects.create( title="Schema event", description="Event description", start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), featured_image=self._upload("schema-event.jpg", fmt="JPEG"), ) post = Post.objects.create( title="Schema post", content="Post body", author=self.user, featured_image=self._upload("schema-post.jpg", fmt="JPEG"), status=Post.StatusChoices.PUBLISHED, ) context = {"request": self.factory.get("/")} self.assertIn( derivative_name(event.featured_image.name, THUMBNAIL_VARIANT), EventListSchema.resolve_absolute_featured_image_thumbnail_url(event, context), ) self.assertIn( derivative_name(event.featured_image.name, PREVIEW_VARIANT), EventListSchema.resolve_absolute_featured_image_preview_url(event, context), ) self.assertIn( derivative_name(gallery.image.name, PREVIEW_VARIANT), EventGallerySchema.resolve_absolute_image_preview_url(gallery, context), ) self.assertIn( derivative_name(gallery.image.name, BLUR_VARIANT), GallerySchema.resolve_absolute_image_blur_url(gallery, context), ) self.assertIn( derivative_name(post.featured_image.name, THUMBNAIL_VARIANT), PostListSchema.resolve_absolute_featured_image_thumbnail_url(post, context), ) self.assertIn( derivative_name(self.user.profile_picture.name, THUMBNAIL_VARIANT), AuthorSchema.resolve_profile_picture_thumbnail_url(self.user, context), ) self.assertIn( derivative_name(self.user.profile_picture.name, PREVIEW_VARIANT), UserProfileSchema.resolve_profile_picture_preview_url(self.user, context), )