Files
links/tests/test_collections.py
OpenClaw Sub-agent e5fea82fd2 fix: drop drag&drop upload zone (button only) + direct-first R2 backfill download
- 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)
2026-08-03 16:34:14 +10:00

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