pdf-quiz-generator/backend/tests/test_thumbnails.py
Daniel cedab6e91e feat: thumbnails for uploaded images, at two widths and no others
A question's stem image is two to four megabytes of scanned radiograph, and a
media grid is forty of those pulled at full size to draw forty postage stamps.
`?w=256` and `?w=640` now serve a WebP copy instead, made on the first ask and
kept beside the original under `thumbs/{width}/{key}` — same bucket, so nothing
new has to be configured for them to be backed up or thrown away.

Three rules, all about not making this a way to spend the server's afternoon.
Those two widths and no others: any other `?w=` is refused with a 400, because
an endpoint that resizes to whatever the query string asks for is a CPU sink
anybody can point at. Never enlarged: a 180px image asked for at 640 is served
as it is, since scaling up invents detail and charges bytes for it. And best
effort throughout — a PDF, an SVG, a truncated upload or a file that is not the
image its name claims all serve their original rather than failing, because a
preview must never take down the page that wanted it.

Authorisation is unchanged and still runs first: a thumbnail of a file you may
not read is a file you may not read. They stay `private, no-store` like
everything else here — they are behind authentication, so there is nothing for
a shared cache to do with them, and the win is the byte count.

EXIF rotation is read before anything measures the image. Every phone stores a
portrait photograph sideways with a flag; a thumbnail made without reading it
is a sideways thumbnail.

Pillow rather than sharp, which is Node. It is not pinned in requirements: the
pin invalidates the pip layer, and that layer no longer builds because
litellm==1.28.13 has been withdrawn from PyPI. Re-pinning litellm is a
deliberate upgrade of the AI layer, not something to slip into this. Noted in
the TODO.

Also: the article hover-card excerpt was printing `[[288|eczema]]` at readers.
The generic markdown-link rule does not know our own cross-reference syntax, so
it left the brackets and the id behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 08:49:20 +02:00

112 lines
4.5 KiB
Python

"""Smaller copies of an uploaded image: which widths exist, and what refuses.
Disposable temp directory for the upload root; Pillow does the real work, so
these are real images in and real WebP out rather than mocks agreeing with
themselves.
"""
import io
import os
import tempfile
import unittest
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
from PIL import Image
from app.config import settings
from app.services import storage_service, thumbnails
def png(width, height, colour=(200, 30, 40)):
buffer = io.BytesIO()
Image.new("RGB", (width, height), colour).save(buffer, format="PNG")
return buffer.getvalue()
class ThumbnailTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.TemporaryDirectory()
self.previous = (settings.UPLOAD_DIR, settings.STORAGE_BACKEND)
settings.UPLOAD_DIR = self.root.name
# Pinned local, so the test is about resizing rather than about which
# bucket this container happens to be pointed at.
settings.STORAGE_BACKEND = "local"
def tearDown(self):
settings.UPLOAD_DIR, settings.STORAGE_BACKEND = self.previous
self.root.cleanup()
def put(self, key, data):
storage_service.save(key, data)
return key
def test_a_wide_image_comes_back_narrower_and_as_webp(self):
key = self.put("questions/big.png", png(1600, 900))
small = thumbnails.get(key, 256)
self.assertIsNotNone(small)
image = Image.open(io.BytesIO(small))
self.assertEqual(image.width, 256)
self.assertEqual(image.format, "WEBP")
# The aspect ratio is kept rather than the height being guessed.
self.assertEqual(image.height, round(900 * 256 / 1600))
self.assertLess(len(small), len(storage_service.load(key)))
def test_it_is_made_once_and_kept_beside_the_original(self):
key = self.put("questions/kept.png", png(1200, 600))
first = thumbnails.get(key, 640)
self.assertIsNotNone(first)
stored = storage_service.load(thumbnails.thumb_key(key, 640))
self.assertEqual(stored, first)
# Removing the original must not change what a stored derivative
# returns: the second reader is served from the copy, not from a resize.
storage_service.delete(key)
self.assertEqual(thumbnails.get(key, 640), first)
def test_a_small_image_is_never_enlarged(self):
key = self.put("questions/small.png", png(180, 120))
# None means "there is nothing smaller worth serving", and the caller
# sends the original. Scaling up invents detail and costs bytes to do it.
self.assertIsNone(thumbnails.get(key, 256))
self.assertIsNone(thumbnails.get(key, 640))
def test_only_the_two_widths_exist(self):
key = self.put("questions/widths.png", png(1600, 900))
self.assertEqual(thumbnails.WIDTHS, (256, 640))
for width in (1, 255, 512, 641, 4000):
self.assertIsNone(thumbnails.get(key, width))
def test_what_is_not_an_image_is_left_alone(self):
self.assertFalse(thumbnails.is_resizable("documents/handbook.pdf"))
self.assertFalse(thumbnails.is_resizable("figures/diagram.svg"))
key = self.put("documents/handbook.pdf", b"%PDF-1.4 not really")
self.assertIsNone(thumbnails.get(key, 256))
def test_a_file_that_is_not_the_image_it_claims_is_not_an_error(self):
key = self.put("questions/broken.png", b"this is not a PNG")
self.assertIsNone(thumbnails.get(key, 256))
def test_forgetting_removes_every_derivative(self):
key = self.put("questions/gone.png", png(1600, 900))
for width in thumbnails.WIDTHS:
self.assertIsNotNone(thumbnails.get(key, width))
thumbnails.forget(key)
for width in thumbnails.WIDTHS:
self.assertIsNone(storage_service.load(thumbnails.thumb_key(key, width)))
def test_a_sideways_photograph_is_turned_the_right_way_up(self):
# Orientation 6 is "rotate 90° clockwise to view", which is how every
# phone stores a portrait photograph. A thumbnail made without reading
# it is a sideways thumbnail.
buffer = io.BytesIO()
image = Image.new("RGB", (1200, 600), (10, 20, 30))
exif = image.getexif()
exif[274] = 6
image.save(buffer, format="JPEG", exif=exif)
key = self.put("questions/sideways.jpg", buffer.getvalue())
small = Image.open(io.BytesIO(thumbnails.get(key, 256)))
self.assertEqual(small.width, 256)
self.assertGreater(small.height, small.width)
if __name__ == "__main__":
unittest.main()