API responses now include file_url and thumbnail_url fields. When S3_PUBLIC_BASE_URL is configured, these point to the CDN domain; when unset, they fall back to the existing API proxy paths so local dev requires no additional setup. UI updated to use response URL fields directly instead of constructing proxy URLs client-side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
5.7 KiB
Python
182 lines
5.7 KiB
Python
"""
|
|
T026 — valid JPEG upload → 201, record in DB, object in MinIO
|
|
T027 — same image uploaded twice → 200, duplicate: true, no second MinIO object
|
|
T028 — invalid MIME type → 422 invalid_mime_type (error envelope with code field)
|
|
T029 — file > MAX_UPLOAD_BYTES → 422 file_too_large
|
|
T079 — GET /api/v1/images/{id} 404 → error envelope shape
|
|
"""
|
|
import io
|
|
import uuid
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from PIL import Image as PILImage
|
|
|
|
|
|
def _real_jpeg(color: tuple = (100, 150, 200), size: tuple = (200, 150)) -> bytes:
|
|
buf = io.BytesIO()
|
|
PILImage.new("RGB", size, color=color).save(buf, format="JPEG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def _minimal_jpeg() -> bytes:
|
|
# Minimal valid JPEG bytes (SOI + APP0 + EOI)
|
|
return (
|
|
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
|
|
b"\xff\xd9"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_new_image_returns_201(authed_client):
|
|
client, token = authed_client
|
|
data = _minimal_jpeg()
|
|
response = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["duplicate"] is False
|
|
assert body["filename"] == "test.jpg"
|
|
assert body["mime_type"] == "image/jpeg"
|
|
assert "id" in body
|
|
assert "hash" in body
|
|
assert len(body["hash"]) == 64
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_duplicate_returns_200_with_flag(authed_client):
|
|
client, token = authed_client
|
|
data = _minimal_jpeg()
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
# First upload
|
|
r1 = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers=headers,
|
|
)
|
|
assert r1.status_code in (200, 201)
|
|
|
|
# Second upload of same bytes
|
|
r2 = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("test.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers=headers,
|
|
)
|
|
assert r2.status_code == 200
|
|
body = r2.json()
|
|
assert body["duplicate"] is True
|
|
assert body["id"] == r1.json()["id"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_invalid_mime_type_returns_422(authed_client):
|
|
client, token = authed_client
|
|
response = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("doc.pdf", io.BytesIO(b"%PDF-1.4"), "application/pdf")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "invalid_mime_type"
|
|
assert "detail" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_oversized_file_returns_422(authed_client):
|
|
import os
|
|
|
|
from app.config import get_settings
|
|
|
|
client, token = authed_client
|
|
os.environ["MAX_UPLOAD_BYTES"] = "10"
|
|
get_settings.cache_clear()
|
|
|
|
try:
|
|
response = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("big.jpg", io.BytesIO(b"x" * 11), "image/jpeg")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 422
|
|
body = response.json()
|
|
assert body["code"] == "file_too_large"
|
|
finally:
|
|
del os.environ["MAX_UPLOAD_BYTES"]
|
|
get_settings.cache_clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_unknown_image_returns_404_with_envelope(client):
|
|
response = await client.get(f"/api/v1/images/{uuid.uuid4()}")
|
|
assert response.status_code == 404
|
|
body = response.json()
|
|
assert body["code"] == "image_not_found"
|
|
assert "detail" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_returns_thumbnail_key(authed_client):
|
|
client, token = authed_client
|
|
data = _real_jpeg(color=(100, 150, 200))
|
|
response = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("thumb_test.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert "thumbnail_key" in body
|
|
assert body["thumbnail_key"] is not None
|
|
assert body["thumbnail_key"].endswith("-thumb")
|
|
assert "file_url" in body
|
|
assert body["file_url"].startswith("/api/v1/images/")
|
|
assert "thumbnail_url" in body
|
|
assert body["thumbnail_url"].startswith("/api/v1/images/")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_upload_reuses_thumbnail_key(authed_client):
|
|
client, token = authed_client
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
data = _real_jpeg(color=(200, 100, 50))
|
|
r1 = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers=headers,
|
|
)
|
|
assert r1.status_code in (200, 201)
|
|
|
|
r2 = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("dup.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers=headers,
|
|
)
|
|
assert r2.status_code == 200
|
|
|
|
tk1 = r1.json()["thumbnail_key"]
|
|
tk2 = r2.json()["thumbnail_key"]
|
|
assert tk1 is not None
|
|
assert tk1 == tk2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_succeeds_when_thumbnail_fails(authed_client):
|
|
client, token = authed_client
|
|
data = _real_jpeg(color=(50, 200, 150))
|
|
with patch("app.routers.images.generate_thumbnail", side_effect=RuntimeError("simulated")):
|
|
response = await client.post(
|
|
"/api/v1/images",
|
|
files={"file": ("no_thumb.jpg", io.BytesIO(data), "image/jpeg")},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code in (200, 201)
|
|
body = response.json()
|
|
assert body["thumbnail_key"] is None
|
|
assert "file_url" in body
|
|
assert body["file_url"].startswith("/api/v1/images/")
|
|
assert body["thumbnail_url"] is None
|