""" Test suite for _auto_save_photo_from_extraction helper function. Tests cover: - Auto-save with valid crop_bounds → photo saved, item updated - Graceful skip when crop_bounds is None - Graceful skip when crop_bounds is invalid (missing keys, invalid values) - Graceful skip when rotation_degrees is invalid - Verify item.photo_path, photo_thumbnail_path, photo_upload_date set correctly - Error handling (missing item, no image_bytes, processing failures) - Logging of warnings for skipped saves """ import io import json from datetime import datetime, timezone 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 from backend.routers.items import _auto_save_photo_from_extraction # ============================================================================ # FIXTURES # ============================================================================ @pytest.fixture def test_item_for_extraction(test_db: Session): """Create a test item in the database for extraction tests.""" item = models.Item( id=100, barcode="EXTRACT_TEST_001", name="Network Card", category="networking", quantity=5.0 ) test_db.add(item) test_db.commit() test_db.refresh(item) return item @pytest.fixture def sample_image_bytes(): """Create a minimal valid JPEG image for testing.""" from PIL import Image # Create a simple 200x200 red image img = Image.new('RGB', (200, 200), color='red') img_bytes = io.BytesIO() img.save(img_bytes, format='JPEG') img_bytes.seek(0) return img_bytes.getvalue() @pytest.fixture def valid_crop_bounds(): """Valid crop bounds dict.""" return { 'x': 10, 'y': 20, 'width': 150, 'height': 160 } # ============================================================================ # TESTS: AUTO-SAVE WITH VALID CROP BOUNDS # ============================================================================ def test_auto_save_with_valid_crop_bounds( test_db: Session, test_item_for_extraction, sample_image_bytes, valid_crop_bounds ): """Test auto-save succeeds with valid crop_bounds.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees=0, db=test_db ) # Verify result status assert result["status"] == "ok" # Verify item was updated updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.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 # Verify paths are valid assert "/images/" in updated_item.photo_path assert "/images/" in updated_item.photo_thumbnail_path assert updated_item.photo_path.endswith(".jpg") assert updated_item.photo_thumbnail_path.endswith(".jpg") # Cleanup Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) def test_auto_save_with_rotation_degrees( test_db: Session, test_item_for_extraction, sample_image_bytes, valid_crop_bounds ): """Test auto-save works with rotation_degrees.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees=90, db=test_db ) assert result["status"] == "ok" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is not None assert updated_item.photo_upload_date is not None # Cleanup Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) def test_auto_save_with_negative_rotation( test_db: Session, test_item_for_extraction, sample_image_bytes, valid_crop_bounds ): """Test auto-save handles negative rotation degrees.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees=-45, db=test_db ) assert result["status"] == "ok" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is not None # Cleanup Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) # ============================================================================ # TESTS: GRACEFUL SKIP WHEN CROP_BOUNDS IS NONE # ============================================================================ def test_auto_save_skip_when_crop_bounds_none( test_db: Session, test_item_for_extraction, sample_image_bytes ): """Test graceful skip when crop_bounds is None.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=None, rotation_degrees=0, db=test_db ) # Should skip gracefully assert result["status"] == "skipped" assert "reason" in result # Item should not be updated updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None assert updated_item.photo_upload_date is None # ============================================================================ # TESTS: GRACEFUL SKIP WHEN CROP_BOUNDS IS INVALID # ============================================================================ def test_auto_save_skip_when_crop_bounds_missing_keys( test_db: Session, test_item_for_extraction, sample_image_bytes ): """Test graceful skip when crop_bounds is missing required keys.""" invalid_bounds = {'x': 10, 'y': 20} # Missing width, height result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=invalid_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" assert "reason" in result updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None def test_auto_save_skip_when_crop_bounds_invalid_values( test_db: Session, test_item_for_extraction, sample_image_bytes ): """Test graceful skip when crop_bounds contains non-integer values.""" invalid_bounds = { 'x': 'not_int', 'y': 20, 'width': 150, 'height': 160 } result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=invalid_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None def test_auto_save_skip_when_crop_bounds_negative_values( test_db: Session, test_item_for_extraction, sample_image_bytes ): """Test graceful skip when crop_bounds contains negative values.""" invalid_bounds = { 'x': -10, 'y': 20, 'width': 150, 'height': 160 } result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=invalid_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None # ============================================================================ # TESTS: GRACEFUL SKIP WHEN ROTATION_DEGREES IS INVALID # ============================================================================ def test_auto_save_skip_when_rotation_out_of_range( test_db: Session, test_item_for_extraction, sample_image_bytes, valid_crop_bounds ): """Test graceful skip when rotation_degrees exceeds valid range.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees=450, # > 360 db=test_db ) assert result["status"] == "skipped" assert "reason" in result updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None def test_auto_save_skip_when_rotation_is_string( test_db: Session, test_item_for_extraction, sample_image_bytes, valid_crop_bounds ): """Test graceful skip when rotation_degrees is not numeric.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees="not_a_number", db=test_db ) assert result["status"] == "skipped" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None # ============================================================================ # TESTS: ERROR HANDLING # ============================================================================ def test_auto_save_skip_when_item_not_found( test_db: Session, sample_image_bytes, valid_crop_bounds ): """Test graceful skip when item does not exist.""" result = _auto_save_photo_from_extraction( item_id=99999, # Non-existent item image_bytes=sample_image_bytes, crop_bounds=valid_crop_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" assert "reason" in result def test_auto_save_skip_when_image_bytes_empty( test_db: Session, test_item_for_extraction, valid_crop_bounds ): """Test graceful skip when image_bytes is empty.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=b'', crop_bounds=valid_crop_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None def test_auto_save_skip_when_image_bytes_invalid( test_db: Session, test_item_for_extraction, valid_crop_bounds ): """Test graceful skip when image_bytes is not a valid image.""" result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=b'not a valid image', crop_bounds=valid_crop_bounds, rotation_degrees=0, db=test_db ) assert result["status"] == "skipped" updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is None # ============================================================================ # TESTS: LARGE CROP BOUNDS (4K IMAGE SUPPORT) # ============================================================================ def test_auto_save_with_large_crop_bounds( test_db: Session, test_item_for_extraction, sample_image_bytes ): """Test auto-save works with large crop bounds (4K image support).""" large_bounds = { 'x': 100, 'y': 100, 'width': 2000, 'height': 1500 } result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=sample_image_bytes, crop_bounds=large_bounds, rotation_degrees=0, db=test_db ) # Should handle gracefully (may skip due to image being too small for bounds, # but should not crash) assert result["status"] in ["ok", "skipped"] if result["status"] == "ok": updated_item = test_db.query(models.Item).filter( models.Item.id == test_item_for_extraction.id ).first() assert updated_item.photo_path is not None # Cleanup Path(updated_item.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) # ============================================================================ # TESTS: MULTIPLE ITEMS WITH INDEPENDENT IMAGE_PROCESSING DATA # ============================================================================ def test_auto_save_multiple_items_independent( test_db: Session, sample_image_bytes ): """Test auto-save handles multiple items with independent data.""" item1 = models.Item(id=201, barcode="MULTI_001", name="Item1", category="cat1", quantity=1.0) item2 = models.Item(id=202, barcode="MULTI_002", name="Item2", category="cat2", quantity=2.0) test_db.add(item1) test_db.add(item2) test_db.commit() bounds1 = {'x': 10, 'y': 10, 'width': 100, 'height': 100} bounds2 = {'x': 20, 'y': 20, 'width': 150, 'height': 150} result1 = _auto_save_photo_from_extraction( item_id=201, image_bytes=sample_image_bytes, crop_bounds=bounds1, rotation_degrees=0, db=test_db ) result2 = _auto_save_photo_from_extraction( item_id=202, image_bytes=sample_image_bytes, crop_bounds=bounds2, rotation_degrees=90, db=test_db ) assert result1["status"] == "ok" assert result2["status"] == "ok" # Verify both items were updated independently updated1 = test_db.query(models.Item).filter(models.Item.id == 201).first() updated2 = test_db.query(models.Item).filter(models.Item.id == 202).first() assert updated1.photo_path is not None assert updated2.photo_path is not None assert updated1.photo_path != updated2.photo_path # Different files # Cleanup Path(updated1.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated1.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) Path(updated2.photo_path.lstrip("/")).unlink(missing_ok=True) Path(updated2.photo_thumbnail_path.lstrip("/")).unlink(missing_ok=True) # ============================================================================ # TESTS: NO EXCEPTIONS THROWN # ============================================================================ def test_auto_save_never_throws_exceptions( test_db: Session, test_item_for_extraction ): """Test that helper never throws exceptions, always returns status dict.""" # Test with all kinds of bad input - none should throw test_cases = [ (None, None, None), (b'', {}, None), (None, {'x': 'bad'}, 'not_a_number'), (b'bad_image', {'x': 0, 'y': 0, 'width': 100}, 450), ] for image_bytes, crop_bounds, rotation in test_cases: result = _auto_save_photo_from_extraction( item_id=test_item_for_extraction.id, image_bytes=image_bytes, crop_bounds=crop_bounds, rotation_degrees=rotation, db=test_db ) # Must always return a dict with 'status' key assert isinstance(result, dict) assert "status" in result assert result["status"] in ["ok", "skipped"] # ============================================================================ # INTEGRATION TESTS: FULL FLOW (create_item endpoint with auto-save) # ============================================================================ def test_create_item_with_image_processing_integration( admin_client, sample_image_bytes ): """Integration test: create item with extracted image → photo auto-saved with crop/rotation.""" import base64 # Encode image as base64 for API payload image_base64 = base64.b64encode(sample_image_bytes).decode() item_data = { "name": "NVMe Storage Drive", "category": "Storage", "type": "NVMe", "quantity": 1, "barcode": "NVM-2024-001", "part_number": "P66093-002", "extracted_image_bytes": image_base64, "image_processing": { "crop_bounds": {"x": 45, "y": 80, "width": 350, "height": 220}, "rotation_degrees": 12, "confidence": 0.94 } } response = admin_client.post("/items/", json=item_data) # Verify item was created assert response.status_code == 201 data = response.json() assert data["id"] is not None assert data["name"] == "NVMe Storage Drive" assert data["barcode"] == "NVM-2024-001" # Verify photo was auto-saved assert data["photo_path"] is not None assert data["photo_thumbnail_path"] is not None assert data["photo_upload_date"] is not None # Verify photo paths are valid assert "/images/" in data["photo_path"] assert "/images/" in data["photo_thumbnail_path"] assert data["photo_path"].endswith(".jpg") assert data["photo_thumbnail_path"].endswith(".jpg") # Cleanup Path(data["photo_path"].lstrip("/")).unlink(missing_ok=True) Path(data["photo_thumbnail_path"].lstrip("/")).unlink(missing_ok=True) def test_create_item_with_invalid_image_processing( admin_client, sample_image_bytes ): """Integration test: Item created even if image_processing is invalid, photo skipped gracefully.""" import base64 image_base64 = base64.b64encode(sample_image_bytes).decode() item_data = { "name": "Test Item Invalid", "category": "Storage", "type": "SSD", "quantity": 1, "barcode": "TEST-INVALID-001", "extracted_image_bytes": image_base64, "image_processing": { # Missing crop_bounds or invalid values "rotation_degrees": 999, # Invalid (out of range) "confidence": 1.5 # Invalid (>1.0) } } response = admin_client.post("/items/", json=item_data) # Item should still be created successfully assert response.status_code == 201 data = response.json() assert data["id"] is not None assert data["name"] == "Test Item Invalid" # Photo should not be saved (invalid image_processing) assert data["photo_path"] is None assert data["photo_thumbnail_path"] is None assert data["photo_upload_date"] is None def test_create_item_without_image_processing( admin_client ): """Integration test: Backward compatibility - old clients without image_processing work.""" item_data = { "name": "Old Style Item", "category": "Storage", "type": "SSD", "quantity": 1, "barcode": "OLD-STYLE-001" } response = admin_client.post("/items/", json=item_data) # Item should be created assert response.status_code == 201 data = response.json() assert data["id"] is not None assert data["name"] == "Old Style Item" # No photo expected (no extracted_image_bytes provided) assert data["photo_path"] is None assert data["photo_thumbnail_path"] is None assert data["photo_upload_date"] is None def test_create_item_with_image_bytes_but_no_processing( admin_client, sample_image_bytes ): """Integration test: Image bytes without image_processing → item created, photo not saved.""" import base64 image_base64 = base64.b64encode(sample_image_bytes).decode() item_data = { "name": "Image Bytes Only", "category": "Storage", "type": "SATA", "quantity": 2, "barcode": "BYTES-ONLY-001", "extracted_image_bytes": image_base64 # No image_processing field } response = admin_client.post("/items/", json=item_data) # Item should be created assert response.status_code == 201 data = response.json() assert data["id"] is not None # Photo not saved (no image_processing means no crop info) assert data["photo_path"] is None assert data["photo_thumbnail_path"] is None assert data["photo_upload_date"] is None