Files
tfm_ainventory/backend/tests/test_photo_endpoints.py

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