""" 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