377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""
|
|
Comprehensive test suite for photo upload/replace API endpoints.
|
|
|
|
Tests cover:
|
|
- Upload success: photo saved, DB updated, URLs returned
|
|
- File replacement: old file deleted, new file saved
|
|
- Auth: user can upload, guest cannot
|
|
- Invalid file: wrong MIME type rejected
|
|
- File too large: >10MB rejected with 413
|
|
- Item not found: 404
|
|
- GET returns photo URLs when set, null when not
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch, MagicMock
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend import models, auth
|
|
|
|
|
|
# ============================================================================
|
|
# FIXTURES
|
|
# ============================================================================
|
|
|
|
@pytest.fixture
|
|
def test_item(test_db: Session):
|
|
"""Create a test item in the database."""
|
|
item = models.Item(
|
|
id=1,
|
|
barcode="TEST123",
|
|
name="Test Item",
|
|
category="networking",
|
|
quantity=5.0
|
|
)
|
|
test_db.add(item)
|
|
test_db.commit()
|
|
test_db.refresh(item)
|
|
return item
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_image():
|
|
"""Create a minimal valid JPEG image for testing."""
|
|
from PIL import Image
|
|
|
|
# Create a simple 100x100 red image
|
|
img = Image.new('RGB', (100, 100), color='red')
|
|
img_bytes = io.BytesIO()
|
|
img.save(img_bytes, format='JPEG')
|
|
img_bytes.seek(0)
|
|
return img_bytes.getvalue()
|
|
|
|
|
|
@pytest.fixture
|
|
def large_image():
|
|
"""Create an image > 10MB to test size limit."""
|
|
from PIL import Image
|
|
|
|
img = Image.new('RGB', (100, 100), color='blue')
|
|
img_bytes = io.BytesIO()
|
|
img.save(img_bytes, format='JPEG')
|
|
img_bytes.seek(0)
|
|
small_img = img_bytes.getvalue()
|
|
|
|
# Pad it to >10MB
|
|
large_img = small_img + b'X' * (11 * 1024 * 1024)
|
|
return large_img
|
|
|
|
|
|
@pytest.fixture
|
|
def invalid_file():
|
|
"""Create an invalid file (text instead of image)."""
|
|
return b"This is not an image file"
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: UPLOAD SUCCESS
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_success(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test successful photo upload."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "ok"
|
|
assert "photo" in data
|
|
assert "thumbnail_url" in data["photo"]
|
|
assert "full_url" in data["photo"]
|
|
assert "uploaded_at" in data["photo"]
|
|
|
|
# Verify DB was updated
|
|
updated_item = test_db.query(models.Item).filter(
|
|
models.Item.id == test_item.id
|
|
).first()
|
|
assert updated_item.photo_path is not None
|
|
assert updated_item.photo_thumbnail_path is not None
|
|
assert updated_item.photo_upload_date is not None
|
|
|
|
# Cleanup
|
|
Path(updated_item.photo_path.lstrip("/")).unlink()
|
|
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink()
|
|
|
|
|
|
def test_upload_photo_creates_files(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test that photo upload creates image files on disk."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
|
|
# Check that files exist
|
|
original_path = Path(data["photo"]["full_url"].lstrip("/"))
|
|
thumbnail_path = Path(data["photo"]["thumbnail_url"].lstrip("/"))
|
|
|
|
assert original_path.exists(), f"Original image not found at {original_path}"
|
|
assert thumbnail_path.exists(), f"Thumbnail not found at {thumbnail_path}"
|
|
|
|
# Cleanup
|
|
original_path.unlink()
|
|
thumbnail_path.unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: FILE REPLACEMENT
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_replace_existing(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test photo replacement with replace_existing=true."""
|
|
# First upload
|
|
response1 = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test1.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
assert response1.status_code == 200
|
|
old_data = response1.json()
|
|
first_url = old_data["photo"]["full_url"]
|
|
|
|
# Create different image for second upload
|
|
from PIL import Image
|
|
img2 = Image.new('RGB', (150, 150), color='green')
|
|
img2_bytes = io.BytesIO()
|
|
img2.save(img2_bytes, format='JPEG')
|
|
img2_bytes.seek(0)
|
|
|
|
# Second upload with replace_existing=true
|
|
response2 = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
data={"replace_existing": "true"},
|
|
files={"file": ("test2.jpg", io.BytesIO(img2_bytes.getvalue()), "image/jpeg")}
|
|
)
|
|
|
|
assert response2.status_code == 200
|
|
new_data = response2.json()
|
|
second_url = new_data["photo"]["full_url"]
|
|
|
|
# URL should have changed (file was replaced)
|
|
assert first_url != second_url, "Photo URL should change when replacing"
|
|
|
|
# New files should exist
|
|
new_original = Path(new_data["photo"]["full_url"].lstrip("/"))
|
|
new_thumbnail = Path(new_data["photo"]["thumbnail_url"].lstrip("/"))
|
|
assert new_original.exists()
|
|
assert new_thumbnail.exists()
|
|
|
|
# Cleanup
|
|
new_original.unlink()
|
|
new_thumbnail.unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: AUTHENTICATION
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_requires_auth(test_client: TestClient, test_item, sample_image):
|
|
"""Test that unauthenticated upload fails with 401."""
|
|
response = test_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 401 # No auth header (unauthorized)
|
|
|
|
|
|
def test_upload_photo_with_valid_token(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test that authenticated user can upload."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
# Cleanup
|
|
data = response.json()
|
|
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
|
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: FILE VALIDATION
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_invalid_mime_type(user_client: TestClient, test_item, invalid_file):
|
|
"""Test that invalid MIME type is rejected."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.txt", io.BytesIO(invalid_file), "text/plain")}
|
|
)
|
|
|
|
assert response.status_code == 415 # Unsupported Media Type
|
|
data = response.json()
|
|
assert "File type not allowed" in data["detail"]
|
|
|
|
|
|
def test_upload_photo_accepted_mime_types(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test all accepted MIME types."""
|
|
mime_types = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
|
|
|
for mime_type in mime_types:
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), mime_type)}
|
|
)
|
|
|
|
assert response.status_code == 200, f"Failed for {mime_type}"
|
|
|
|
# Cleanup
|
|
data = response.json()
|
|
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
|
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: FILE SIZE LIMITS
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_too_large(user_client: TestClient, test_item, large_image):
|
|
"""Test that files > 10MB are rejected with 413."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("large.jpg", io.BytesIO(large_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 413 # Payload Too Large
|
|
data = response.json()
|
|
assert "exceeds 10MB limit" in data["detail"]
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: ITEM NOT FOUND
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_item_not_found(user_client: TestClient, sample_image):
|
|
"""Test that upload to non-existent item returns 404."""
|
|
response = user_client.post(
|
|
f"/items/99999/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
data = response.json()
|
|
assert "Item not found" in data["detail"]
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: GET ENDPOINT PHOTO RESPONSE
|
|
# ============================================================================
|
|
|
|
def test_get_item_includes_photo_urls(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test that GET /items/{id} includes photo URLs when photo is set."""
|
|
# First upload a photo
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
# Get the item
|
|
response = user_client.get(f"/items/{test_item.id}")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "photo" in data
|
|
assert data["photo"] is not None
|
|
assert "thumbnail_url" in data["photo"]
|
|
assert "full_url" in data["photo"]
|
|
assert "uploaded_at" in data["photo"]
|
|
|
|
# Cleanup
|
|
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
|
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
|
|
|
|
|
def test_get_item_no_photo_returns_null(user_client: TestClient, test_item):
|
|
"""Test that GET /items/{id} returns photo: null when no photo is set."""
|
|
response = user_client.get(f"/items/{test_item.id}")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "photo" in data
|
|
assert data["photo"] is None
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: MULTIPLE UPLOADS
|
|
# ============================================================================
|
|
|
|
def test_upload_multiple_photos_without_replace(user_client: TestClient, test_db: Session, test_item, sample_image):
|
|
"""Test multiple uploads without replace_existing (new files created)."""
|
|
from PIL import Image
|
|
|
|
response1 = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test1.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
|
)
|
|
assert response1.status_code == 200
|
|
path1 = response1.json()["photo"]["full_url"]
|
|
|
|
# Create a different image
|
|
img2 = Image.new('RGB', (200, 200), color='yellow')
|
|
img2_bytes = io.BytesIO()
|
|
img2.save(img2_bytes, format='JPEG')
|
|
img2_bytes.seek(0)
|
|
|
|
response2 = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test2.jpg", io.BytesIO(img2_bytes.getvalue()), "image/jpeg")}
|
|
)
|
|
assert response2.status_code == 200
|
|
path2 = response2.json()["photo"]["full_url"]
|
|
|
|
# Paths should be different (new file created)
|
|
assert path1 != path2
|
|
|
|
# Cleanup
|
|
Path(path1.lstrip("/")).unlink()
|
|
Path(path2.lstrip("/")).unlink()
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS: EDGE CASES
|
|
# ============================================================================
|
|
|
|
def test_upload_photo_empty_file(user_client: TestClient, test_item):
|
|
"""Test that empty file is handled gracefully."""
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(b""), "image/jpeg")}
|
|
)
|
|
|
|
# Should fail during image processing
|
|
assert response.status_code >= 400
|
|
|
|
|
|
def test_upload_photo_corrupted_jpeg(user_client: TestClient, test_item):
|
|
"""Test that corrupted JPEG is handled gracefully."""
|
|
corrupted = b"\xFF\xD8\xFF\xE0" + b"corrupted data"
|
|
|
|
response = user_client.post(
|
|
f"/items/{test_item.id}/photos",
|
|
files={"file": ("test.jpg", io.BytesIO(corrupted), "image/jpeg")}
|
|
)
|
|
|
|
# Should fail during image processing
|
|
assert response.status_code >= 400
|