mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
- Collection detail: remove the 'Drag & drop images here' zone per 大哥; Upload button opens the hidden file picker, progress shown via toast - migrate_image_storage: download via direct boto3 get_object first, presigned URL only as fallback (custom domain 403s for this account even with valid creds)
200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
"""
|
|
Tests for Image Collections: unified files-backend storage, upload/delete
|
|
API flows, URL resolution and the R2→files backfill command.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from django.core.management import call_command
|
|
from PIL import Image as PILImage
|
|
|
|
from links.models import ImageCollection, Image, FileUpload
|
|
|
|
|
|
def _real_png(name="photo.png"):
|
|
"""A fully decodable 8x8 PNG (Pillow can open it → thumbnails work)."""
|
|
buf = io.BytesIO()
|
|
PILImage.new("RGB", (8, 8), (200, 60, 60)).save(buf, format="PNG")
|
|
return SimpleUploadedFile(name, buf.getvalue(), content_type="image/png")
|
|
|
|
|
|
def _text_file():
|
|
return SimpleUploadedFile("note.txt", b"hello", content_type="text/plain")
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestImageFilesBackend:
|
|
def _collection(self):
|
|
return ImageCollection.objects.create(name="Test Collection")
|
|
|
|
def test_upload_images_uses_files_backend(self, api_client):
|
|
coll = self._collection()
|
|
r = api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": _real_png()},
|
|
format="multipart",
|
|
)
|
|
assert r.status_code == 201
|
|
assert r.json()["status"] == "success"
|
|
|
|
image = coll.images.get()
|
|
assert image.file is not None
|
|
# The image is served through the files backend URL, not R2
|
|
assert image.file_key == ""
|
|
assert image.file.mime_type == "image/png"
|
|
assert image.file.size > 0
|
|
assert os.path.exists(image.file.file_path)
|
|
assert image.get_url() == image.file.download_url
|
|
assert image.get_url().startswith("/ui/files/")
|
|
assert image.get_thumbnail_url() == image.file.thumbnail_url
|
|
|
|
def test_upload_rejects_non_image(self, api_client):
|
|
coll = self._collection()
|
|
r = api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": _text_file()},
|
|
format="multipart",
|
|
)
|
|
assert r.status_code == 400
|
|
assert coll.images.count() == 0
|
|
assert FileUpload.objects.count() == 0
|
|
|
|
def test_upload_multiple_images(self, api_client):
|
|
coll = self._collection()
|
|
r = api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": [_real_png("a.png"), _real_png("b.png")]},
|
|
format="multipart",
|
|
)
|
|
assert r.status_code == 201
|
|
assert coll.images.count() == 2
|
|
assert FileUpload.objects.count() == 2
|
|
|
|
def test_serializer_exposes_url_and_thumbnail(self, api_client):
|
|
coll = self._collection()
|
|
api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": _real_png()},
|
|
format="multipart",
|
|
)
|
|
data = api_client.get(f"/api/collections/{coll.pk}").json()
|
|
assert data["image_count"] == 1
|
|
r = api_client.get(f"/api/images/{coll.images.get().pk}")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["url"].startswith("/ui/files/")
|
|
assert body["thumbnail_url"] == "/ui/files/" + str(coll.images.get().file.pk) + "/thumb/"
|
|
|
|
def test_delete_image_removes_file_and_record(self, api_client):
|
|
coll = self._collection()
|
|
api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": _real_png()},
|
|
format="multipart",
|
|
)
|
|
image = coll.images.get()
|
|
path = image.file.file_path
|
|
assert os.path.exists(path)
|
|
|
|
r = api_client.delete(f"/api/images/{image.pk}")
|
|
assert r.status_code == 204 or r.status_code == 200
|
|
assert not Image.objects.filter(pk=image.pk).exists()
|
|
assert not FileUpload.objects.filter(pk=image.file.pk).exists()
|
|
assert not os.path.exists(path)
|
|
|
|
def test_delete_collection_cascades(self, api_client):
|
|
coll = self._collection()
|
|
api_client.post(
|
|
f"/api/collections/{coll.pk}/upload_images",
|
|
{"file": [_real_png("a.png"), _real_png("b.png")]},
|
|
format="multipart",
|
|
)
|
|
paths = [img.file.file_path for img in coll.images.all()]
|
|
assert all(os.path.exists(p) for p in paths)
|
|
|
|
r = api_client.delete(f"/api/collections/{coll.pk}")
|
|
assert r.status_code == 204 or r.status_code == 200
|
|
assert not ImageCollection.objects.filter(pk=coll.pk).exists()
|
|
assert not Image.objects.filter(collection=coll).exists()
|
|
assert FileUpload.objects.count() == 0
|
|
assert not any(os.path.exists(p) for p in paths)
|
|
|
|
def test_legacy_r2_image_falls_back_to_r2_url(self):
|
|
"""Images not yet backfilled (file_key set, no file) still resolve via R2."""
|
|
coll = self._collection()
|
|
image = Image.objects.create(
|
|
collection=coll, title="legacy", file_key="images/legacy/x.jpg",
|
|
content_type="image/jpeg", size=123,
|
|
)
|
|
with mock.patch("links.models.R2Storage") as fake:
|
|
fake.return_value.get_url.return_value = "https://r2.example/x.jpg?sig=1"
|
|
assert image.get_url().startswith("https://r2.example")
|
|
assert fake.return_value.get_url.call_count == 1
|
|
# And thumbnails request the cdn-cgi transformation variant
|
|
with mock.patch("links.models.R2Storage") as fake:
|
|
fake.return_value.get_url.return_value = "https://cdn.example/x.jpg?w=200"
|
|
image.get_thumbnail_url()
|
|
kwargs = fake.return_value.get_url.call_args.kwargs
|
|
assert kwargs.get("width") == 200
|
|
|
|
|
|
@pytest.mark.django_db
|
|
class TestMigrateImageStorageCommand:
|
|
def _legacy_image(self, coll, key="images/old/cat.jpg", raw=None):
|
|
return Image.objects.create(
|
|
collection=coll, title="old.png", file_key=key,
|
|
content_type="image/png", size=len(raw or b"data"),
|
|
)
|
|
|
|
@staticmethod
|
|
def _patch_download(raw=None, exc=None):
|
|
return mock.patch(
|
|
"links.management.commands.migrate_image_storage._download_r2_object",
|
|
return_value=raw if raw is not None else b"\x89PNGdata",
|
|
side_effect=exc,
|
|
)
|
|
|
|
def test_dry_run_does_nothing(self, tmp_path):
|
|
coll = ImageCollection.objects.create(name="C")
|
|
self._legacy_image(coll, raw=b"\x89PNGdata")
|
|
call_command("migrate_image_storage")
|
|
img = Image.objects.get()
|
|
assert img.file is None
|
|
assert img.file_key == "images/old/cat.jpg"
|
|
|
|
def test_backfill_creates_fileupload_and_links(self, tmp_path):
|
|
coll = ImageCollection.objects.create(name="C")
|
|
raw = io.BytesIO()
|
|
PILImage.new("RGB", (8, 8)).save(raw, format="PNG")
|
|
raw = raw.getvalue()
|
|
self._legacy_image(coll, raw=raw)
|
|
|
|
with self._patch_download(raw=raw):
|
|
call_command("migrate_image_storage", "--commit")
|
|
|
|
img = Image.objects.get()
|
|
assert img.file is not None
|
|
assert img.file_key == ""
|
|
assert os.path.exists(img.file.file_path)
|
|
assert img.file.size == len(raw)
|
|
# Idempotent: second run has nothing to do
|
|
with self._patch_download(raw=raw):
|
|
call_command("migrate_image_storage", "--commit")
|
|
assert Image.objects.get().file is not None
|
|
|
|
def test_backfill_failure_leaves_record_for_retry(self, tmp_path):
|
|
coll = ImageCollection.objects.create(name="C")
|
|
self._legacy_image(coll, raw=b"\x89PNGdata")
|
|
|
|
with self._patch_download(exc=Exception("network down")):
|
|
call_command("migrate_image_storage", "--commit")
|
|
|
|
img = Image.objects.get()
|
|
assert img.file is None
|
|
assert img.file_key == "images/old/cat.jpg"
|
|
assert FileUpload.objects.count() == 0
|