|
@@ -74,6 +74,16 @@ class OverwriteStyleMemoryStorage(Storage):
|
|
|
return f'https://example.invalid/{name}'
|
|
return f'https://example.invalid/{name}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class UnreadableSizeMemoryStorage(OverwriteStyleMemoryStorage):
|
|
|
|
|
+ """
|
|
|
|
|
+ Like OverwriteStyleMemoryStorage, but size() raises OSError to model a storage backend that is
|
|
|
|
|
+ transiently unavailable (e.g. an S3 outage) when reading file size.
|
|
|
|
|
+ """
|
|
|
|
|
+
|
|
|
|
|
+ def size(self, name):
|
|
|
|
|
+ raise OSError('storage unavailable')
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class ImageAttachmentTestCase(TestCase):
|
|
class ImageAttachmentTestCase(TestCase):
|
|
|
@classmethod
|
|
@classmethod
|
|
|
def setUpTestData(cls):
|
|
def setUpTestData(cls):
|
|
@@ -191,6 +201,204 @@ class ImageAttachmentTestCase(TestCase):
|
|
|
|
|
|
|
|
self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
|
|
self.assertCountEqual(storage.files.keys(), {base_name, suffixed_name})
|
|
|
|
|
|
|
|
|
|
+ def test_save_populates_image_size_on_create(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ save() populates image_size from the uploaded file on creation.
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('size-on-create.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNotNone(ia.image_size)
|
|
|
|
|
+ self.assertEqual(ia.image_size, ia.image.size)
|
|
|
|
|
+
|
|
|
|
|
+ def test_size_property_returns_stored_value_without_storage_access(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ The size property returns the cached image_size rather than the file's actual size. The stub's empty
|
|
|
|
|
+ file reports size 0, so asserting the distinct stored value proves the property used the cached value.
|
|
|
|
|
+ """
|
|
|
|
|
+ ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_no-file.png')
|
|
|
|
|
+ self.assertEqual(ia._read_image_size(), 0) # the stub's empty file genuinely reports 0
|
|
|
|
|
+ ia.image_size = 9999
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(ia.size, 9999)
|
|
|
|
|
+
|
|
|
|
|
+ def test_size_property_falls_back_to_storage_when_unset(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ For legacy rows where image_size is NULL, the size property falls back to reading storage
|
|
|
|
|
+ (rather than reporting 0 bytes).
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('fallback.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+
|
|
|
|
|
+ # Simulate a legacy row that predates the image_size field.
|
|
|
|
|
+ ia.image_size = None
|
|
|
|
|
+ self.assertEqual(ia.size, ia.image.size)
|
|
|
|
|
+ self.assertGreater(ia.size, 0)
|
|
|
|
|
+
|
|
|
|
|
+ def test_save_does_not_clobber_existing_size_on_storage_error(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ When the storage backend raises on a size read (modeled by a real Storage subclass, not a mock),
|
|
|
|
|
+ save() must not overwrite an existing image_size with None.
|
|
|
|
|
+ """
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ # Create a row with a real, readable size.
|
|
|
|
|
+ with patch.object(field, 'storage', OverwriteStyleMemoryStorage()):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('keep-size.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+ original_size = ia.image_size
|
|
|
|
|
+ self.assertIsNotNone(original_size)
|
|
|
|
|
+
|
|
|
|
|
+ # Reload from the DB so the FieldFile has no cached size and must consult storage (as it would for a
|
|
|
|
|
+ # row loaded fresh in production). With the backend unable to report size, the read fails (returns None),
|
|
|
|
|
+ # and save() must keep the previously-stored value rather than clobbering it with None.
|
|
|
|
|
+ with patch.object(field, 'storage', UnreadableSizeMemoryStorage()):
|
|
|
|
|
+ reloaded = ImageAttachment.objects.get(pk=ia.pk)
|
|
|
|
|
+ self.assertIsNone(reloaded._read_image_size()) # the read genuinely fails (returns None)
|
|
|
|
|
+ # Make the image look replaced by perturbing the cached identity (different name component).
|
|
|
|
|
+ reloaded._orig_image_key = ('image-attachments/site_1_old.png', reloaded.image_height, reloaded.image_width)
|
|
|
|
|
+ reloaded.save()
|
|
|
|
|
+
|
|
|
|
|
+ # In-memory value is preserved, and the persisted value is unchanged.
|
|
|
|
|
+ self.assertEqual(reloaded.image_size, original_size)
|
|
|
|
|
+ self.assertEqual(ImageAttachment.objects.get(pk=ia.pk).image_size, original_size)
|
|
|
|
|
+
|
|
|
|
|
+ def test_save_recomputes_image_size_when_image_replaced(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ Replacing the image on an existing row recomputes image_size (Cable-style change detection).
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('original.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+ original_size = ia.image_size
|
|
|
|
|
+ self.assertIsNotNone(original_size)
|
|
|
|
|
+
|
|
|
|
|
+ # Replace the image with a larger file and save again.
|
|
|
|
|
+ larger = SimpleUploadedFile(
|
|
|
|
|
+ name='replacement.png',
|
|
|
|
|
+ content=self._uploaded_png('replacement.png').read() + b'\x00' * 100,
|
|
|
|
|
+ content_type='image/png',
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.image = larger
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(ia.image_size, ia.image.size)
|
|
|
|
|
+ self.assertNotEqual(ia.image_size, original_size)
|
|
|
|
|
+
|
|
|
|
|
+ def test_image_identity_includes_dimensions(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ The change-detection key combines the image name with its dimensions, so a replacement that reuses the
|
|
|
|
|
+ same name but changes dimensions produces a different key (which name alone would not).
|
|
|
|
|
+ """
|
|
|
|
|
+ ia = self._stub_image_attachment(self.site.pk, 'image-attachments/site_1_same.png')
|
|
|
|
|
+ ia.image_height, ia.image_width = 10, 10
|
|
|
|
|
+ key_small = ia._image_identity()
|
|
|
|
|
+
|
|
|
|
|
+ # Same name, different dimensions (as Django would set when a same-named file is replaced).
|
|
|
|
|
+ ia.image_height, ia.image_width = 40, 40
|
|
|
|
|
+ key_large = ia._image_identity()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(key_small[0], key_large[0]) # name component unchanged
|
|
|
|
|
+ self.assertNotEqual(key_small, key_large) # but the key differs, so save() will recompute
|
|
|
|
|
+
|
|
|
|
|
+ def test_save_recomputes_image_size_when_dimensions_change_under_same_name(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ When the image is replaced by a file with the same stored name but different dimensions, save()
|
|
|
|
|
+ recomputes image_size. Name-only detection would miss this; the dimension component catches it.
|
|
|
|
|
+ Simulates the same-name case by priming the cached identity with the old dimensions.
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('same-name.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+ name = ia.image.name
|
|
|
|
|
+
|
|
|
|
|
+ # Force the cached identity to reflect the SAME name but different (old) dimensions, then bump the
|
|
|
|
|
+ # current dimensions to mimic a same-name replacement with a differently-sized image.
|
|
|
|
|
+ ia._orig_image_key = (name, ia.image_height + 5, ia.image_width + 5)
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(ia.image.name, name) # name unchanged
|
|
|
|
|
+ self.assertEqual(ia.image_size, ia.image.size) # size recomputed despite same name
|
|
|
|
|
+
|
|
|
|
|
+ def test_save_without_touching_image_does_not_recompute_or_read_storage(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ Editing an existing row without replacing the image leaves image_size untouched and does not
|
|
|
|
|
+ hit storage. Directly guards against the change-detection comparison misfiring.
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ name='Original',
|
|
|
|
|
+ image=self._uploaded_png('untouched.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+ stored_size = ia.image_size
|
|
|
|
|
+
|
|
|
|
|
+ # Reload from the DB so the cached image identity is set from the persisted value, then edit only the name.
|
|
|
|
|
+ reloaded = ImageAttachment.objects.get(pk=ia.pk)
|
|
|
|
|
+ reloaded.name = 'Renamed'
|
|
|
|
|
+ with patch.object(ImageAttachment, '_read_image_size', side_effect=AssertionError('storage accessed')):
|
|
|
|
|
+ reloaded.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertEqual(reloaded.image_size, stored_size)
|
|
|
|
|
+
|
|
|
|
|
+ def test_save_populates_image_size_via_constructor_kwarg(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ The non-UI create path (constructor kwarg / REST / bulk) populates image_size correctly,
|
|
|
|
|
+ confirming change detection behaves when image is passed as a FieldFile.
|
|
|
|
|
+ """
|
|
|
|
|
+ storage = OverwriteStyleMemoryStorage()
|
|
|
|
|
+ field = ImageAttachment._meta.get_field('image')
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(field, 'storage', storage):
|
|
|
|
|
+ ia = ImageAttachment(
|
|
|
|
|
+ object_type=self.ct_site,
|
|
|
|
|
+ object_id=self.site.pk,
|
|
|
|
|
+ image=self._uploaded_png('kwarg.png'),
|
|
|
|
|
+ )
|
|
|
|
|
+ ia.save()
|
|
|
|
|
+
|
|
|
|
|
+ self.assertIsNotNone(ia.image_size)
|
|
|
|
|
+ self.assertEqual(ia.image_size, ia.image.size)
|
|
|
|
|
+
|
|
|
|
|
|
|
|
class TableConfigTestCase(TestCase):
|
|
class TableConfigTestCase(TestCase):
|
|
|
@classmethod
|
|
@classmethod
|