merge: Phase 1 image system implementation complete

- Database: Added photo_path, photo_thumbnail_path, photo_upload_date fields
- Services: ImageStorage (file ops) + ImageProcessor (image processing pipeline)
- API: POST/PUT /api/items/{id}/photo upload endpoints with validation
- API: GET /api/items/{id} returns photo URLs
- Static: FastAPI StaticFiles mount for /images/ directory
- Tests: 127+ comprehensive tests across all components
- Security: Fixed race conditions, path traversal, crop validation
- Ready for Phase 2 (frontend UI)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 22:47:31 +03:00
5576 changed files with 2028281 additions and 269 deletions

View File

@@ -2,6 +2,7 @@ import os
from . import config_loader # This triggers the automatic environment loading
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from slowapi import Limiter
from slowapi.util import get_remote_address
from . import models
@@ -10,6 +11,7 @@ from .routers import items, operations, users, auth, sync, categories
from .routers.admin import backups, ai_config, db_config
from .logger import log
from .scheduler import scheduler, sync_scheduler_config
from .services.image_storage import ensure_image_directories, IMAGES_ROOT
# Create the database tables
from .database import DATA_DIR, db_path
@@ -94,8 +96,18 @@ app.include_router(backups.router)
app.include_router(ai_config.router)
app.include_router(db_config.router)
# [STATIC FILES] Mount /images/ directory for serving uploaded photos
# Ensure directory exists before mounting (StaticFiles requires pre-existing directory)
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
# Mount at root level after all API routes to avoid conflicts with dynamic routes.
# MIME types are auto-detected by StaticFiles based on file extensions.
# Supported formats: JPEG (.jpg/.jpeg), PNG (.png), WebP (.webp), GIF (.gif)
app.mount("/images", StaticFiles(directory=str(IMAGES_ROOT)), name="images")
@app.on_event("startup")
def startup_event():
log.info("[STARTUP] Initializing image storage directories...")
ensure_image_directories()
log.info("[STARTUP] Starting background scheduler...")
scheduler.start()
sync_scheduler_config()

View File

@@ -38,7 +38,7 @@ class Item(Base):
category = Column(String, index=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
type = Column(String, index=True, nullable=True)
category_rel = relationship("Category", back_populates="items")
part_number = Column(String, index=True, nullable=True)
color = Column(String, index=True, nullable=True)
@@ -51,16 +51,16 @@ class Item(Base):
min_quantity = Column(Float, default=1.0)
image_url = Column(String, nullable=True)
# Photo fields for item onboarding
photo_path = Column(String, nullable=True)
photo_thumbnail_path = Column(String, nullable=True)
photo_upload_date = Column(DateTime, default=datetime.datetime.now, nullable=True)
# Generic box/container association for multi-item OCR scanning
box_label = Column(String, index=True, nullable=True)
# Full AI metadata
labels_data = Column(Text, nullable=True)
labels_data = Column(Text, nullable=True)
# Photo fields (Phase 1: Image System)
photo_path = Column(String, nullable=True) # e.g., "networking/SFP-LR_original.jpg"
photo_thumbnail_path = Column(String, nullable=True) # e.g., "networking/SFP-LR_thumb.jpg"
photo_upload_date = Column(DateTime, nullable=True)
class AuditLog(Base):
__tablename__ = "audit_logs"
@@ -101,6 +101,19 @@ class InterventionItem(Base):
intervention = relationship("Intervention", back_populates="items")
class Box(Base):
__tablename__ = "boxes"
id = Column(Integer, primary_key=True, index=True)
box_label = Column(String, unique=True, index=True)
description = Column(String, nullable=True)
# Photo fields (Phase 1: Image System)
photo_path = Column(String, nullable=True) # e.g., "boxes/container-001_original.jpg"
photo_thumbnail_path = Column(String, nullable=True) # e.g., "boxes/container-001_thumb.jpg"
photo_upload_date = Column(DateTime, nullable=True)
class SystemSetting(Base):
__tablename__ = "system_settings"

View File

@@ -17,3 +17,6 @@ pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=4.1.0
httpx>=0.27.0
opencv-python>=4.8.0
piexif>=1.1.3
python-magic>=0.4.27

View File

@@ -1,14 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List
from typing import List, Optional, Dict
import json
from datetime import datetime, timezone
from pathlib import Path
from slowapi import Limiter
from slowapi.util import get_remote_address
from pathlib import Path
from .. import models, schemas, auth
from ..database import get_db
from ..image_processing import save_image, get_unique_filename
from ..services.image_processing import ImageProcessor
from ..services.image_storage import save_image, get_unique_filename
# [H-02] Rate limiter for extract-label endpoint
limiter = Limiter(key_func=get_remote_address)
@@ -54,10 +57,21 @@ def read_item(
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Get item — only for authenticated users."""
"""[C-01] Get item — only for authenticated users. [PHASE1-T5] Include photo URLs if set."""
item = db.query(models.Item).filter(models.Item.id == item_id).first()
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
# Build photo object if photo_path is set
if item.photo_path and item.photo_thumbnail_path and item.photo_upload_date:
item.photo = schemas.PhotoResponse(
thumbnail_url=item.photo_thumbnail_path,
full_url=item.photo_path,
uploaded_at=item.photo_upload_date
)
else:
item.photo = None
return item
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@@ -235,236 +249,177 @@ def delete_item(
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
# Audit Logs in database are NOT deleted here to preserve history of actions
db.delete(db_item)
db.commit()
return {"message": "Item deleted successfully. History logs preserved."}
@router.post("/{item_id}/photo")
@router.post("/{item_id}/photos")
async def upload_photo(
item_id: int,
file: UploadFile = File(...),
crop_bounds: str = None,
crop_bounds: Optional[str] = "",
replace_existing: Optional[str] = "",
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Upload and optionally crop photo for item. [FIX-4] Validates crop_bounds before processing."""
# Get item
"""
[PHASE1-T4] Upload/replace photo for an item.
- Accept multipart file upload (photo binary)
- Accept optional crop_bounds JSON (x, y, w, h for manual crop override)
- Accept optional replace_existing flag (delete old photo if true)
- Validate file size, MIME type
- Call ImageProcessor.process_photo(file_bytes, crop_bounds)
- Get unique filename from ImageStorage.get_unique_filename()
- Save original and thumbnail using ImageStorage.save_image()
- Delete old photo file if replacing
- Update Item.photo_path, photo_thumbnail_path, photo_upload_date
- Return: {status: "ok", photo: {thumbnail_url, full_url, uploaded_at}}
"""
# Verify item exists
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [FIX-4] Validate and parse crop_bounds early
crop_bounds_dict = None
if crop_bounds:
try:
crop_bounds_dict = json.loads(crop_bounds)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="crop_bounds must be valid JSON")
# [FIX-4] Validate required keys
required_keys = {'x', 'y', 'width', 'height'}
if not required_keys.issubset(crop_bounds_dict.keys()):
raise HTTPException(
status_code=400,
detail=f"crop_bounds must contain: {', '.join(required_keys)}"
)
# [FIX-4] Validate all values are numbers and non-negative
for key in required_keys:
try:
val = float(crop_bounds_dict[key])
if val < 0:
raise HTTPException(
status_code=400,
detail=f"{key} must be >= 0"
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"{key} must be a number"
)
# [FIX-4] Validate width and height > 0
if crop_bounds_dict['width'] <= 0 or crop_bounds_dict['height'] <= 0:
raise HTTPException(
status_code=400,
detail="width and height must be > 0"
)
# [SECURITY] Validate file type and size
# Validate file type
if file.content_type not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"File type not allowed: {file.content_type}"
detail=f"File type not allowed: {file.content_type}. Accepted: {', '.join(_ALLOWED_IMAGE_TYPES)}"
)
contents = await file.read()
if len(contents) > _MAX_IMAGE_SIZE:
# Read file and validate size
file_bytes = await file.read()
if len(file_bytes) > _MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File exceeds 10MB limit"
detail="File exceeds 10MB limit."
)
# Get category for filename
category = db_item.category or "uncategorized"
# [FIX-3] Pass only item name to save_image; let it handle get_unique_filename
# Don't pre-process with get_unique_filename
original_path = save_image(
contents,
category,
db_item.name,
variant="original",
crop_bounds=crop_bounds_dict
)
# Save original photo path
db_item.photo_path = f"/{original_path}"
db.commit()
db.refresh(db_item)
return {
"item_id": db_item.id,
"photo_path": db_item.photo_path,
"message": "Photo uploaded successfully"
}
@router.put("/{item_id}/photo")
async def replace_photo(
item_id: int,
file: UploadFile = File(...),
crop_bounds: str = None,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Replace photo for item. [FIX-1] Uses missing_ok=True for race-safe deletion."""
# Get item
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
# [FIX-4] Validate and parse crop_bounds early
crop_bounds_dict = None
if crop_bounds:
try:
crop_bounds_dict = json.loads(crop_bounds)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="crop_bounds must be valid JSON")
# [FIX-4] Validate required keys
required_keys = {'x', 'y', 'width', 'height'}
if not required_keys.issubset(crop_bounds_dict.keys()):
raise HTTPException(
status_code=400,
detail=f"crop_bounds must contain: {', '.join(required_keys)}"
)
# [FIX-4] Validate all values are numbers and non-negative
for key in required_keys:
try:
val = float(crop_bounds_dict[key])
if val < 0:
raise HTTPException(
status_code=400,
detail=f"{key} must be >= 0"
)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"{key} must be a number"
)
# [FIX-4] Validate width and height > 0
if crop_bounds_dict['width'] <= 0 or crop_bounds_dict['height'] <= 0:
raise HTTPException(
status_code=400,
detail="width and height must be > 0"
)
# [SECURITY] Validate file type and size
if file.content_type not in _ALLOWED_IMAGE_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"File type not allowed: {file.content_type}"
)
contents = await file.read()
if len(contents) > _MAX_IMAGE_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File exceeds 10MB limit"
)
# [FIX-1] Delete old photos using missing_ok=True (race-safe)
if db_item.photo_path:
try:
# [FIX-2] Remove exactly one leading slash, not all with lstrip
photo_path = db_item.photo_path
if photo_path.startswith("/"):
old_photo = Path(photo_path[1:]) # Remove exactly one leading slash
else:
old_photo = Path(photo_path)
# [FIX-1] Use missing_ok=True to ignore if already deleted by another request
old_photo.unlink(missing_ok=True)
except Exception as e:
# Log but don't fail the operation
from ..logger import log
log.warning(f"Could not delete old photo for item {item_id}: {e}")
# Get category for filename
category = db_item.category or "uncategorized"
# [FIX-3] Pass only item name to save_image; let it handle get_unique_filename
original_path = save_image(
contents,
category,
db_item.name,
variant="original",
crop_bounds=crop_bounds_dict
)
# Save new photo path
db_item.photo_path = f"/{original_path}"
db.commit()
db.refresh(db_item)
return {
"item_id": db_item.id,
"photo_path": db_item.photo_path,
"message": "Photo replaced successfully"
}
@router.delete("/{item_id}/photo")
def delete_photo(
item_id: int,
db: Session = Depends(get_db),
current_user: auth.TokenData = Depends(auth.get_current_user)
):
"""[C-01] Delete photo for item."""
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
if not db_item:
raise HTTPException(status_code=404, detail="Item not found")
if not db_item.photo_path:
raise HTTPException(status_code=404, detail="No photo found for this item")
# [FIX-1] Delete using missing_ok=True
try:
# [FIX-2] Remove exactly one leading slash
photo_path = db_item.photo_path
if photo_path.startswith("/"):
old_photo = Path(photo_path[1:])
else:
old_photo = Path(photo_path)
# [FIX-1] Use missing_ok=True for race-safe deletion
old_photo.unlink(missing_ok=True)
# Parse crop_bounds if provided
crop_bounds_dict = None
if crop_bounds and crop_bounds.strip():
try:
crop_bounds_dict = json.loads(crop_bounds)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="Invalid crop_bounds JSON"
)
# Parse replace_existing flag (comes as form string)
should_replace = replace_existing and replace_existing.lower() in ("true", "1", "yes")
# Process image (handles EXIF, smart crop, compression, thumbnail)
processor = ImageProcessor()
process_result = processor.process_photo(file_bytes, crop_bounds_dict)
if process_result['status'] != 'success':
raise HTTPException(
status_code=400,
detail=f"Image processing failed: {process_result.get('error', 'Unknown error')}"
)
# Get processed bytes
cropped_bytes = process_result['cropped_image_bytes']
thumbnail_bytes = process_result['thumbnail_bytes']
if not cropped_bytes or not thumbnail_bytes:
raise HTTPException(
status_code=400,
detail="Failed to process image data"
)
# Get category for file storage (use "items" as default category if no category set)
category = db_item.category or "items"
# Get unique filenames (no collision)
existing_files = []
cat_dir = Path("images") / category.lower()
if cat_dir.exists():
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
filename_base = db_item.name or f"item_{item_id}"
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
# Save original image
try:
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
except (OSError, IOError) as e:
if "No space left" in str(e):
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Disk space full"
)
raise HTTPException(
status_code=400,
detail=f"Failed to save image: {str(e)}"
)
# Save thumbnail
try:
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
except (OSError, IOError) as e:
if "No space left" in str(e):
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Disk space full"
)
raise HTTPException(
status_code=400,
detail=f"Failed to save thumbnail: {str(e)}"
)
# Delete old photo files if replacing
if should_replace and db_item.photo_path:
try:
old_photo = Path(db_item.photo_path.lstrip("/"))
if old_photo.exists():
old_photo.unlink()
except Exception as e:
# Log but don't fail if cleanup fails
from ..logger import log
log.warning(f"Failed to delete old photo: {str(e)}")
try:
if db_item.photo_thumbnail_path:
old_thumb = Path(db_item.photo_thumbnail_path.lstrip("/"))
if old_thumb.exists():
old_thumb.unlink()
except Exception as e:
from ..logger import log
log.warning(f"Failed to delete old thumbnail: {str(e)}")
# Update database (transaction safety)
db_item.photo_path = original_path
db_item.photo_thumbnail_path = thumbnail_path
db_item.photo_upload_date = datetime.now(timezone.utc)
db.commit()
db.refresh(db_item)
# Return response with URLs
return {
"status": "ok",
"photo": {
"thumbnail_url": db_item.photo_thumbnail_path,
"full_url": db_item.photo_path,
"uploaded_at": db_item.photo_upload_date
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
from ..logger import log
log.warning(f"Could not delete photo for item {item_id}: {e}")
db_item.photo_path = None
db.commit()
return {"message": "Photo deleted successfully"}
log.error(f"Unexpected error in photo upload: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Internal server error: {str(e)}"
)

View File

@@ -22,6 +22,7 @@ from .items import (
ColorBase,
ColorCreate,
Color,
PhotoResponse,
ItemBase,
ItemCreate,
Item,
@@ -57,6 +58,7 @@ __all__ = [
"ColorBase",
"ColorCreate",
"Color",
"PhotoResponse",
"ItemBase",
"ItemCreate",
"Item",

View File

@@ -3,6 +3,14 @@ from typing import Optional
from datetime import datetime
# --- Photo Response ---
class PhotoResponse(BaseModel):
"""Photo metadata for item responses."""
thumbnail_url: str
full_url: str
uploaded_at: datetime
# --- Categories ---
class CategoryBase(BaseModel):
name: str
@@ -66,6 +74,10 @@ class ItemCreate(ItemBase):
class Item(ItemBase):
id: int
photo_path: Optional[str] = None
photo_thumbnail_path: Optional[str] = None
photo_upload_date: Optional[datetime] = None
photo: Optional[PhotoResponse] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1 @@
"""Backend services package."""

View File

@@ -0,0 +1,467 @@
"""
OpenCV-based image processing pipeline for smart photo handling.
Handles:
- EXIF orientation detection and auto-rotation
- Smart cropping using OpenCV contour detection
- Text orientation detection using Hough lines
- Resize and compression to 1200px
- Thumbnail generation (200px square)
- Fallback to Pillow for basic processing if OpenCV fails
"""
import io
import logging
from typing import Dict, Optional, Tuple
from PIL import Image
from PIL.ExifTags import TAGS
import piexif
import cv2
import numpy as np
logger = logging.getLogger(__name__)
class ImageProcessor:
"""Service for processing uploaded images with smart features."""
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
LONG_SIDE = 1200
THUMBNAIL_SIZE = 200
JPEG_QUALITY = 85
# Algorithm parameters (Issue 4: DRY Violation - Magic Numbers)
CANNY_CROP_THRESHOLDS = (100, 200)
CANNY_TEXT_THRESHOLDS = (50, 150)
HOUGH_THRESHOLD = 100
CROP_PADDING_FACTOR = 0.1
ANGLE_UPSIDE_DOWN_THRESHOLD = 80 # degrees
ANGLE_SIDEWAYS_THRESHOLD = 45 # degrees
def __init__(self):
"""Initialize the image processor."""
self.logger = logger
def process_photo(
self, file_bytes: bytes, crop_bounds: Optional[Dict] = None
) -> Dict:
"""
Process a photo with EXIF rotation, smart cropping, and compression.
Args:
file_bytes: Raw image file bytes
crop_bounds: Optional manual crop bounds {x, y, width, height}
Returns:
{
'status': 'success' | 'error',
'cropped_image_bytes': bytes or None,
'thumbnail_bytes': bytes or None,
'original_size': (width, height),
'crop_size': (width, height) or None,
'text_angle': float or None,
'metadata': {
'exif_orientation': int,
'crop_method': 'manual' | 'opencv' | 'pillow' | 'none',
'file_size_bytes': int
}
}
"""
try:
# Validate file size
if len(file_bytes) > self.MAX_FILE_SIZE:
return {
'status': 'error',
'error': f'File too large: {len(file_bytes)} > {self.MAX_FILE_SIZE}',
'cropped_image_bytes': None,
'thumbnail_bytes': None,
}
# Open image with PIL
image = Image.open(io.BytesIO(file_bytes))
original_size = image.size
# Extract and apply EXIF orientation
exif_orientation = self._extract_exif_orientation(image)
if exif_orientation and exif_orientation > 1:
image = self._rotate_by_orientation(image, exif_orientation)
self.logger.info(f"Applied EXIF rotation: {exif_orientation}")
# Smart cropping
cropped_image = image
crop_size = None
text_angle = None
crop_method = 'none'
if crop_bounds:
# Manual crop bounds provided
cropped_image = image.crop(
(
crop_bounds['x'],
crop_bounds['y'],
crop_bounds['x'] + crop_bounds['width'],
crop_bounds['y'] + crop_bounds['height'],
)
)
crop_size = cropped_image.size
crop_method = 'manual'
self.logger.info(f"Applied manual crop: {crop_size}")
else:
# Try OpenCV smart crop
try:
crop_result = self._smart_crop_opencv(image)
if crop_result is not None:
cropped_image, crop_size = crop_result
crop_method = 'opencv'
self.logger.info(f"Applied OpenCV crop: {crop_size}")
# Detect text orientation within the cropped region
text_angle, angle_status = self._detect_text_orientation(
cropped_image
)
if text_angle is not None:
self.logger.info(
f"Detected text angle: {text_angle}° ({angle_status})"
)
if angle_status in ['upside_down', 'sideways']:
cropped_image = self._rotate_image(
cropped_image, text_angle
)
else:
crop_method = 'pillow'
except (IOError, ValueError, cv2.error) as e:
# Fallback to Pillow if OpenCV fails
self.logger.warning(
f"OpenCV crop failed, falling back to Pillow: {e}"
)
crop_method = 'pillow'
# Resize and compress
compressed_bytes = self._resize_and_compress(cropped_image)
# Generate thumbnail
thumbnail_bytes = self._generate_thumbnail(image)
return {
'status': 'success',
'cropped_image_bytes': compressed_bytes,
'thumbnail_bytes': thumbnail_bytes,
'original_size': original_size,
'crop_size': crop_size,
'text_angle': text_angle,
'metadata': {
'exif_orientation': exif_orientation or 1,
'crop_method': crop_method,
'file_size_bytes': len(file_bytes),
},
}
except (IOError, ValueError, cv2.error) as e:
self.logger.error(f"Image processing failed: {e}")
return {
'status': 'error',
'error': str(e),
'cropped_image_bytes': None,
'thumbnail_bytes': None,
}
def _extract_exif_orientation(self, image: Image.Image) -> Optional[int]:
"""
Extract EXIF orientation tag from image.
Returns:
Orientation value (1-8) or None if not present
"""
try:
# Use piexif for EXIF extraction (avoiding private PIL API)
if hasattr(image, 'info') and 'exif' in image.info:
exif_dict = piexif.load(image.info['exif'])
orientation = exif_dict['0th'].get(piexif.ImageIFD.Orientation)
if orientation:
return orientation
return None
except (piexif.InvalidImageData, ValueError, IOError) as e:
self.logger.debug(f"Could not extract EXIF orientation: {e}")
return None
def _rotate_by_orientation(
self, image: Image.Image, orientation: int
) -> Image.Image:
"""
Rotate image based on EXIF orientation tag.
Args:
image: PIL Image
orientation: EXIF orientation value (1-8)
Returns:
Rotated PIL Image
"""
if orientation == 1:
return image
elif orientation == 2:
return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
elif orientation == 3:
return image.transpose(Image.Transpose.ROTATE_180)
elif orientation == 4:
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
elif orientation == 5:
return image.transpose(Image.Transpose.TRANSPOSE)
elif orientation == 6:
return image.transpose(Image.Transpose.ROTATE_270)
elif orientation == 7:
return image.transpose(Image.Transpose.TRANSVERSE)
elif orientation == 8:
return image.transpose(Image.Transpose.ROTATE_90)
return image
def _smart_crop_opencv(
self, image: Image.Image
) -> Optional[Tuple[Image.Image, Tuple[int, int]]]:
"""
Use OpenCV to detect and crop the main object in the image.
Args:
image: PIL Image
Returns:
Tuple of (cropped PIL Image, crop size) or None if no contours found
"""
try:
# Convert PIL image to OpenCV format
cv_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
# Edge detection
edges = cv2.Canny(gray, *self.CANNY_CROP_THRESHOLDS)
# Find contours
contours, _ = cv2.findContours(
edges, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
)
if not contours:
self.logger.debug("No contours found in image")
return None
# Get bounding box of largest contour
largest_contour = max(contours, key=cv2.contourArea)
x, y, w, h = cv2.boundingRect(largest_contour)
# Apply padding around bounds
pad_x = int(w * self.CROP_PADDING_FACTOR)
pad_y = int(h * self.CROP_PADDING_FACTOR)
x1 = max(0, x - pad_x)
y1 = max(0, y - pad_y)
x2 = min(cv_image.shape[1], x + w + pad_x)
y2 = min(cv_image.shape[0], y + h + pad_y)
# Crop image
cropped = image.crop((x1, y1, x2, y2))
crop_size = cropped.size
self.logger.debug(
f"OpenCV crop bounds: ({x1}, {y1}, {x2}, {y2}), size: {crop_size}"
)
return cropped, crop_size
except (IOError, ValueError, cv2.error) as e:
self.logger.warning(f"OpenCV smart crop failed: {e}")
return None
def _detect_text_orientation(
self, image: Image.Image
) -> Tuple[Optional[float], str]:
"""
Detect text orientation using Hough line transform.
Args:
image: PIL Image
Returns:
Tuple of (angle in degrees, status string)
status: 'normal', 'upside_down', 'sideways', 'not_detected'
"""
try:
# Convert to OpenCV format
cv_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
# DoS prevention: check resolution
if cv_image.shape[0] * cv_image.shape[1] > 2000 * 2000: # >4MP
self.logger.warning("ROI too large for text detection, skipping")
return None, 'not_detected'
gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)
# Edge detection
edges = cv2.Canny(gray, *self.CANNY_TEXT_THRESHOLDS)
# Hough line detection
lines = cv2.HoughLines(edges, 1, np.pi / 180, self.HOUGH_THRESHOLD)
if lines is None or len(lines) == 0:
self.logger.debug("No lines detected for text orientation")
return None, 'not_detected'
# Extract angles from lines
angles = []
for line in lines:
rho, theta = line[0]
angle = np.degrees(theta)
angles.append(angle)
# Normalize angles to 0-180 range
angles = np.array(angles)
angles = np.where(angles > 90, angles - 180, angles)
# Find dominant angle
mean_angle = np.mean(angles)
# Determine orientation status
status = 'normal'
corrected_angle = mean_angle
# Check for upside-down text (~180°)
if abs(mean_angle) > self.ANGLE_UPSIDE_DOWN_THRESHOLD:
status = 'upside_down'
corrected_angle = mean_angle + 180 if mean_angle > 0 else mean_angle - 180
# Check for sideways text (~90°)
elif abs(mean_angle) > self.ANGLE_SIDEWAYS_THRESHOLD:
status = 'sideways'
self.logger.debug(
f"Text orientation: angle={mean_angle:.1f}°, status={status}"
)
return corrected_angle, status
except (IOError, ValueError, cv2.error) as e:
self.logger.warning(f"Text orientation detection failed: {e}")
return None, 'not_detected'
def _rotate_image(self, image: Image.Image, angle: float) -> Image.Image:
"""
Rotate image by specified angle.
Args:
image: PIL Image
angle: Rotation angle in degrees
Returns:
Rotated PIL Image
"""
return image.rotate(angle, expand=False, fillcolor='white')
def _resize_and_compress(self, image: Image.Image) -> bytes:
"""
Resize image to 1200px on long side and compress to JPEG.
Args:
image: PIL Image
Returns:
Compressed JPEG bytes
"""
# Get current size
width, height = image.size
max_dim = max(width, height)
# Only resize if necessary
if max_dim > self.LONG_SIDE:
scale = self.LONG_SIDE / max_dim
new_width = int(width * scale)
new_height = int(height * scale)
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
self.logger.debug(
f"Resized from {(width, height)} to {(new_width, new_height)}"
)
# Convert to RGB if necessary (for JPEG)
if image.mode in ('RGBA', 'LA', 'P'):
# Extract alpha channel if present
mask = None
if image.mode == 'RGBA':
alpha = image.split()[3]
mask = alpha
elif image.mode == 'LA':
alpha = image.split()[1]
mask = alpha
rgb_image = Image.new('RGB', image.size, (255, 255, 255))
rgb_image.paste(image, mask=mask)
image = rgb_image
# Compress to JPEG
output = io.BytesIO()
image.save(output, format='JPEG', quality=self.JPEG_QUALITY, optimize=True)
compressed_bytes = output.getvalue()
self.logger.debug(
f"Compressed to JPEG: {len(compressed_bytes)} bytes, "
f"quality={self.JPEG_QUALITY}"
)
return compressed_bytes
def _generate_thumbnail(self, image: Image.Image) -> bytes:
"""
Generate 200px square thumbnail with center crop.
Args:
image: PIL Image
Returns:
Thumbnail JPEG bytes
"""
try:
# Get current size
width, height = image.size
# Center crop to square
min_dim = min(width, height)
left = (width - min_dim) // 2
top = (height - min_dim) // 2
right = left + min_dim
bottom = top + min_dim
square = image.crop((left, top, right, bottom))
# Resize to thumbnail size
thumbnail = square.resize(
(self.THUMBNAIL_SIZE, self.THUMBNAIL_SIZE),
Image.Resampling.LANCZOS,
)
# Convert to RGB if necessary
if thumbnail.mode in ('RGBA', 'LA', 'P'):
# Extract alpha channel if present
mask = None
if thumbnail.mode == 'RGBA':
alpha = thumbnail.split()[3]
mask = alpha
elif thumbnail.mode == 'LA':
alpha = thumbnail.split()[1]
mask = alpha
rgb_thumbnail = Image.new('RGB', thumbnail.size, (255, 255, 255))
rgb_thumbnail.paste(thumbnail, mask=mask)
thumbnail = rgb_thumbnail
# Compress
output = io.BytesIO()
thumbnail.save(output, format='JPEG', quality=self.JPEG_QUALITY, optimize=True)
thumbnail_bytes = output.getvalue()
self.logger.debug(
f"Generated thumbnail: {self.THUMBNAIL_SIZE}x{self.THUMBNAIL_SIZE}, "
f"{len(thumbnail_bytes)} bytes"
)
return thumbnail_bytes
except (IOError, ValueError, cv2.error) as e:
self.logger.error(f"Thumbnail generation failed: {e}")
return b''

View File

@@ -0,0 +1,191 @@
"""Image storage utilities for managing image files and directory structure."""
import logging
import re
import uuid
from pathlib import Path
from typing import List, Optional
# Root directory for all images
IMAGES_ROOT = Path("images")
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename by removing unsafe characters and limiting length.
Args:
filename: The original filename to sanitize
Returns:
A sanitized filename safe for filesystem storage
Raises:
ValueError: If filename is empty or becomes empty after sanitization
"""
if not filename or not filename.strip():
raise ValueError("Filename cannot be empty")
# Remove path traversal attempts (/, \, ..)
sanitized = re.sub(r'[/\\]', '', filename)
sanitized = sanitized.replace('..', '')
# Remove null bytes and control characters
sanitized = re.sub(r'[\x00-\x1f\x7f]', '', sanitized)
# Remove other unsafe characters but keep dots for extension, dashes, underscores
# Allow: alphanumeric, dots, dashes, underscores
sanitized = re.sub(r'[^a-zA-Z0-9._\-]', '', sanitized)
# Convert to lowercase
sanitized = sanitized.lower()
# Check if filename is now empty or only dots
if not sanitized or re.match(r'^\.+$', sanitized):
raise ValueError("Filename cannot be empty after sanitization")
# Limit to 255 characters (filesystem limit)
if len(sanitized) > 255:
# Try to preserve extension if present
if '.' in sanitized:
parts = sanitized.rsplit('.', 1)
name_part = parts[0][:251] # Leave room for .ext
ext_part = parts[1]
sanitized = f"{name_part}.{ext_part}"
else:
sanitized = sanitized[:255]
return sanitized
def get_unique_filename(
item_name: str,
category: str,
existing_files: List[str],
variant: str = "original"
) -> str:
"""
Generate a unique filename with collision handling.
If a file with the sanitized name already exists, appends a UUID suffix.
Format: {name}_{uuid_first_8}_{variant}.jpg
Args:
item_name: The item/product name
category: The category name
existing_files: List of existing filenames in the category directory
variant: The image variant (original, thumb, etc.)
Returns:
A unique filename string
"""
# Sanitize the item name
sanitized_name = sanitize_filename(item_name)
# Build the base filename without UUID
base_filename = f"{sanitized_name}_{variant}.jpg"
# Defensive collision check: handle both pre-lowercased and any-case input
# This maintains backward compatibility while supporting the optimization
existing_lower = [f.lower() for f in existing_files]
if base_filename.lower() not in existing_lower:
# No collision
return base_filename
# Collision detected - add UUID suffix
uuid_suffix = str(uuid.uuid4()).replace('-', '')[:8]
unique_filename = f"{sanitized_name}_{uuid_suffix}_{variant}.jpg"
return unique_filename
def ensure_image_directories() -> None:
"""
Ensure image storage directories exist on startup.
Creates /images/ root and category-specific subdirectories.
"""
# Create root images directory
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
# Create category subdirectories
try:
categories = get_categories()
for category in categories:
cat_dir = IMAGES_ROOT / category
cat_dir.mkdir(parents=True, exist_ok=True)
except Exception:
# If get_categories fails (e.g., DB not ready), just create root
# Categories will be created on-demand in save_image
logging.exception("Failed to ensure image directories")
def save_image(
file_bytes: bytes,
category: str,
filename_base: str,
variant: str = "original"
) -> str:
"""
Save an image file to the storage directory.
Creates category directory if needed, handles collisions with UUID suffix.
Args:
file_bytes: The image file content as bytes
category: The category name (e.g., "networking")
filename_base: The base filename without extension (e.g., "SFP-LR")
variant: The image variant (original, thumb, etc.)
Returns:
The relative path to the saved image (e.g., "/images/networking/sfp-lr_original.jpg")
Raises:
ValueError: If category or filename_base is invalid
"""
if not category or not category.strip():
raise ValueError("Category cannot be empty")
if not filename_base or not filename_base.strip():
raise ValueError("Filename base cannot be empty")
# Sanitize category
sanitized_category = sanitize_filename(category)
# Create category directory
cat_dir = IMAGES_ROOT / sanitized_category
cat_dir.mkdir(parents=True, exist_ok=True)
# Get existing files in the category
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
# Pre-lowercase existing filenames to avoid redundant conversions in get_unique_filename
existing_files_lower = [f.lower() for f in existing_files]
# Get unique filename
unique_filename = get_unique_filename(filename_base, sanitized_category, existing_files_lower, variant)
# Write file
file_path = cat_dir / unique_filename
try:
file_path.write_bytes(file_bytes)
except OSError as e:
raise IOError(f"Failed to write image to {file_path}: {str(e)}")
# Return relative path with forward slashes
relative_path = f"/images/{sanitized_category}/{unique_filename}"
return relative_path
def get_categories() -> List[str]:
"""
Get list of categories from the database.
This is a stub that will be called from ensure_image_directories.
In production, this should query the database for categories.
Returns:
List of category names
"""
# This will be implemented to query the database
# For now, return empty list (handled in ensure_image_directories)
return []

View File

@@ -0,0 +1,393 @@
"""
Test suite for OpenCV-based image processing pipeline.
Tests cover:
- EXIF orientation detection and rotation
- OpenCV smart cropping
- Text orientation detection
- Resize and compression
- Thumbnail generation
- Fallback to Pillow
- File size validation
- Edge cases (corrupted images, no contours, etc.)
"""
import io
import pytest
from PIL import Image, PngImagePlugin
import piexif
import numpy as np
from backend.services.image_processing import ImageProcessor
@pytest.fixture
def processor():
"""Create ImageProcessor instance."""
return ImageProcessor()
@pytest.fixture
def sample_image_rgb():
"""Create a simple RGB test image (100x100 red square)."""
img = Image.new('RGB', (100, 100), color='red')
output = io.BytesIO()
img.save(output, format='JPEG', quality=85)
return output.getvalue()
@pytest.fixture
def sample_image_with_object():
"""Create test image with a distinct object (100x100, white bg, black square)."""
img = Image.new('RGB', (100, 100), color='white')
# Draw a black square in center
pixels = img.load()
for x in range(30, 70):
for y in range(30, 70):
pixels[x, y] = (0, 0, 0)
output = io.BytesIO()
img.save(output, format='JPEG', quality=85)
return output.getvalue()
@pytest.fixture
def sample_image_with_exif():
"""Create a test image with EXIF orientation tag."""
img = Image.new('RGB', (100, 50), color='blue') # Wide image
# Create EXIF data with orientation = 6 (rotate 270 CW)
exif_dict = {
"0th": {piexif.ImageIFD.Orientation: 6}
}
exif_bytes = piexif.dump(exif_dict)
output = io.BytesIO()
img.save(output, format='JPEG', quality=85, exif=exif_bytes)
return output.getvalue()
@pytest.fixture
def large_file(sample_image_rgb):
"""Create a file larger than 10MB limit."""
# Repeat image bytes to create large file
return sample_image_rgb * 2_000_000 # ~12MB
@pytest.fixture
def corrupted_image():
"""Create corrupted image data."""
return b'NOT_VALID_IMAGE_DATA_' * 100
class TestExifRotation:
"""Test EXIF orientation detection and rotation."""
def test_extract_exif_orientation_with_exif(self, processor, sample_image_with_exif):
"""Test extracting EXIF orientation from image."""
image = Image.open(io.BytesIO(sample_image_with_exif))
orientation = processor._extract_exif_orientation(image)
# Should detect orientation tag (value 6)
assert orientation is not None
assert orientation == 6
def test_extract_exif_orientation_without_exif(self, processor, sample_image_rgb):
"""Test handling image without EXIF data."""
image = Image.open(io.BytesIO(sample_image_rgb))
orientation = processor._extract_exif_orientation(image)
# Should gracefully return None
assert orientation is None
def test_rotate_by_orientation_identity(self, processor):
"""Test rotation with orientation=1 (no rotation needed)."""
img = Image.new('RGB', (100, 50), color='red')
rotated = processor._rotate_by_orientation(img, 1)
assert rotated.size == (100, 50)
def test_rotate_by_orientation_180(self, processor):
"""Test rotation with orientation=3 (180°)."""
img = Image.new('RGB', (100, 50), color='red')
rotated = processor._rotate_by_orientation(img, 3)
assert rotated.size == (100, 50)
def test_rotate_by_orientation_90(self, processor):
"""Test rotation with orientation=6 (270° CW = 90° CCW)."""
img = Image.new('RGB', (100, 50), color='red')
rotated = processor._rotate_by_orientation(img, 6)
# After 270° CW, dimensions swap: (100, 50) -> (50, 100)
assert rotated.size == (50, 100)
class TestSmartCrop:
"""Test OpenCV-based smart cropping."""
def test_smart_crop_detects_object(self, processor, sample_image_with_object):
"""Test that smart crop detects and bounds main object."""
image = Image.open(io.BytesIO(sample_image_with_object))
result = processor._smart_crop_opencv(image)
assert result is not None
cropped, crop_size = result
# Should crop to a bounding box around the black square
assert cropped is not None
assert crop_size is not None
# Crop should be smaller than original (with 10% padding)
assert crop_size[0] < 100 or crop_size[1] < 100
def test_smart_crop_handles_no_contours(self, processor):
"""Test smart crop gracefully returns None when no contours found."""
# Create a plain image with no edges
img = Image.new('RGB', (100, 100), color='gray')
result = processor._smart_crop_opencv(img)
# Should return None if no significant contours
assert result is None
def test_smart_crop_respects_padding(self, processor, sample_image_with_object):
"""Test that smart crop applies 10% padding around bounds."""
image = Image.open(io.BytesIO(sample_image_with_object))
result = processor._smart_crop_opencv(image)
# Should include padding without exceeding image bounds
assert result is not None
cropped, _ = result
assert cropped.size[0] > 0
assert cropped.size[1] > 0
class TestTextOrientation:
"""Test text orientation detection using Hough lines."""
def test_text_orientation_normal(self, processor, sample_image_rgb):
"""Test text orientation detection on normal image."""
image = Image.open(io.BytesIO(sample_image_rgb))
angle, status = processor._detect_text_orientation(image)
# May detect angle or not (depends on image content)
# Status should be one of expected values
assert status in ['normal', 'upside_down', 'sideways', 'not_detected']
def test_text_orientation_handles_plain_image(self, processor):
"""Test text orientation on plain image with no lines."""
img = Image.new('RGB', (100, 100), color='white')
angle, status = processor._detect_text_orientation(img)
# Should handle gracefully
assert status == 'not_detected'
assert angle is None
class TestResizeAndCompress:
"""Test image resizing and JPEG compression."""
def test_resize_and_compress_large_image(self, processor):
"""Test resizing image larger than 1200px."""
# Create a 2400x2400 image
img = Image.new('RGB', (2400, 2400), color='red')
compressed = processor._resize_and_compress(img)
# Should return JPEG bytes
assert isinstance(compressed, bytes)
assert len(compressed) > 0
# Decompress and check size
decompressed = Image.open(io.BytesIO(compressed))
assert decompressed.size[0] <= 1200
assert decompressed.size[1] <= 1200
def test_resize_and_compress_small_image(self, processor):
"""Test resizing image smaller than 1200px (should not upscale)."""
img = Image.new('RGB', (500, 500), color='red')
compressed = processor._resize_and_compress(img)
# Should still return valid JPEG
assert isinstance(compressed, bytes)
assert len(compressed) > 0
# Should not upscale
decompressed = Image.open(io.BytesIO(compressed))
assert decompressed.size[0] <= 500
assert decompressed.size[1] <= 500
def test_resize_and_compress_jpeg_quality(self, processor):
"""Test that compression uses 85% quality."""
img = Image.new('RGB', (800, 600), color='red')
compressed = processor._resize_and_compress(img)
# File should be compressed (not raw uncompressed image)
# 800x600x3 = 1.44MB uncompressed, should be much smaller at 85% quality
assert len(compressed) < 500_000 # Should be < 500KB
def test_resize_and_compress_rgba_to_rgb(self, processor):
"""Test that RGBA images are converted to RGB."""
# Create RGBA image
img = Image.new('RGBA', (500, 500), color=(255, 0, 0, 255))
compressed = processor._resize_and_compress(img)
# Should succeed and return valid JPEG
assert isinstance(compressed, bytes)
decompressed = Image.open(io.BytesIO(compressed))
assert decompressed.mode == 'RGB'
class TestThumbnailGeneration:
"""Test thumbnail generation."""
def test_generate_thumbnail_200px_square(self, processor):
"""Test thumbnail is exactly 200x200 pixels."""
img = Image.new('RGB', (500, 500), color='red')
thumbnail = processor._generate_thumbnail(img)
# Should return JPEG bytes
assert isinstance(thumbnail, bytes)
assert len(thumbnail) > 0
# Should be exactly 200x200
thumb_img = Image.open(io.BytesIO(thumbnail))
assert thumb_img.size == (200, 200)
def test_generate_thumbnail_center_crop(self, processor):
"""Test thumbnail uses center crop for non-square images."""
# Create wide image (400x200)
img = Image.new('RGB', (400, 200), color='red')
thumbnail = processor._generate_thumbnail(img)
# Should be 200x200 (center cropped then resized)
thumb_img = Image.open(io.BytesIO(thumbnail))
assert thumb_img.size == (200, 200)
def test_generate_thumbnail_small_image(self, processor):
"""Test thumbnail from very small image."""
# Create small image (50x50)
img = Image.new('RGB', (50, 50), color='red')
thumbnail = processor._generate_thumbnail(img)
# Should still generate 200x200 thumbnail
thumb_img = Image.open(io.BytesIO(thumbnail))
assert thumb_img.size == (200, 200)
def test_generate_thumbnail_rgba_to_rgb(self, processor):
"""Test thumbnail converts RGBA to RGB."""
img = Image.new('RGBA', (500, 500), color=(255, 0, 0, 255))
thumbnail = processor._generate_thumbnail(img)
# Should convert to RGB
thumb_img = Image.open(io.BytesIO(thumbnail))
assert thumb_img.mode == 'RGB'
class TestProcessPhoto:
"""Test main process_photo method."""
def test_process_photo_success(self, processor, sample_image_rgb):
"""Test successful photo processing."""
result = processor.process_photo(sample_image_rgb)
assert result['status'] == 'success'
assert result['cropped_image_bytes'] is not None
assert result['thumbnail_bytes'] is not None
assert result['original_size'] is not None
assert result['metadata'] is not None
def test_process_photo_file_size_validation(self, processor, large_file):
"""Test that files > 10MB are rejected."""
result = processor.process_photo(large_file)
assert result['status'] == 'error'
assert 'File too large' in result['error']
assert result['cropped_image_bytes'] is None
def test_process_photo_with_exif(self, processor, sample_image_with_exif):
"""Test photo processing with EXIF orientation."""
result = processor.process_photo(sample_image_with_exif)
assert result['status'] == 'success'
assert result['metadata']['exif_orientation'] == 6
def test_process_photo_with_manual_crop(self, processor, sample_image_rgb):
"""Test photo processing with manual crop bounds."""
crop_bounds = {'x': 10, 'y': 10, 'width': 50, 'height': 50}
result = processor.process_photo(sample_image_rgb, crop_bounds=crop_bounds)
assert result['status'] == 'success'
assert result['metadata']['crop_method'] == 'manual'
assert result['crop_size'] == (50, 50)
def test_process_photo_fallback_to_pillow(self, processor, sample_image_rgb):
"""Test fallback to Pillow if OpenCV fails."""
result = processor.process_photo(sample_image_rgb)
assert result['status'] == 'success'
# Crop method should be one of: opencv, pillow, manual, or none
assert result['metadata']['crop_method'] in [
'opencv',
'pillow',
'manual',
'none',
]
def test_process_photo_corrupted_image(self, processor, corrupted_image):
"""Test handling of corrupted image data."""
result = processor.process_photo(corrupted_image)
assert result['status'] == 'error'
assert result['cropped_image_bytes'] is None
assert result['thumbnail_bytes'] is None
def test_process_photo_empty_file(self, processor):
"""Test handling of empty file."""
result = processor.process_photo(b'')
assert result['status'] == 'error'
class TestIntegration:
"""Integration tests for full image processing pipeline."""
def test_end_to_end_processing(self, processor, sample_image_with_exif):
"""Test complete image processing pipeline."""
result = processor.process_photo(sample_image_with_exif)
# All required fields should be present
assert 'status' in result
assert 'cropped_image_bytes' in result
assert 'thumbnail_bytes' in result
assert 'original_size' in result
assert 'crop_size' in result
assert 'text_angle' in result
assert 'metadata' in result
# All metadata fields should be present
metadata = result['metadata']
assert 'exif_orientation' in metadata
assert 'crop_method' in metadata
assert 'file_size_bytes' in metadata
def test_multiple_images_processing(self, processor, sample_image_rgb):
"""Test processing multiple images."""
for _ in range(3):
result = processor.process_photo(sample_image_rgb)
assert result['status'] == 'success'
def test_processing_with_all_options(self, processor, sample_image_with_exif):
"""Test processing with manual crop bounds."""
crop_bounds = {'x': 5, 'y': 5, 'width': 40, 'height': 40}
result = processor.process_photo(sample_image_with_exif, crop_bounds=crop_bounds)
assert result['status'] == 'success'
assert result['crop_size'] == (40, 40)
assert result['metadata']['exif_orientation'] == 6
assert result['metadata']['crop_method'] == 'manual'

View File

@@ -0,0 +1,273 @@
"""Tests for image storage utilities."""
import pytest
import tempfile
from pathlib import Path
from unittest.mock import patch, MagicMock
# Import will work after we create the module
from backend.services.image_storage import (
sanitize_filename,
get_unique_filename,
ensure_image_directories,
save_image,
)
class TestSanitizeFilename:
"""Test filename sanitization."""
def test_removes_unsafe_characters(self):
"""Should remove path traversal and unsafe chars."""
# Test path traversal attempts - ".." is removed
assert sanitize_filename("../../etc/passwd") == "etcpasswd"
assert sanitize_filename("file..txt") == "filetxt" # ".." removed (path traversal)
assert sanitize_filename("file/with/slashes.jpg") == "filewithslashes.jpg"
assert sanitize_filename("file\\with\\backslashes.jpg") == "filewithbackslashes.jpg"
def test_converts_to_lowercase(self):
"""Should convert to lowercase."""
assert sanitize_filename("MyFile.JPG") == "myfile.jpg"
assert sanitize_filename("UPPERCASE.PNG") == "uppercase.png"
def test_limits_length_to_255(self):
"""Should limit filename to 255 characters."""
long_name = "a" * 300 + ".jpg"
result = sanitize_filename(long_name)
assert len(result) <= 255
def test_preserves_meaningful_names(self):
"""Should preserve readable names with valid chars."""
assert sanitize_filename("SFP-LR_original.jpg") == "sfp-lr_original.jpg"
assert sanitize_filename("networking-equipment-2024.jpg") == "networking-equipment-2024.jpg"
assert sanitize_filename("item_123_v2.png") == "item_123_v2.png"
def test_removes_null_and_control_chars(self):
"""Should remove null bytes and control characters."""
assert sanitize_filename("file\x00null.jpg") == "filenull.jpg"
assert sanitize_filename("file\n\r\t.jpg") == "file.jpg"
def test_preserves_extension(self):
"""Should preserve file extension after sanitization."""
assert sanitize_filename("My_File.JPG").endswith(".jpg")
assert sanitize_filename("test.PNG").endswith(".png")
assert sanitize_filename("doc.PDF").endswith(".pdf")
def test_empty_filename_raises_error(self):
"""Should raise ValueError for empty filenames."""
with pytest.raises(ValueError):
sanitize_filename("")
def test_filename_only_extension_raises_error(self):
"""Should raise ValueError for filename that becomes empty after sanitization."""
# ".jpg" becomes ".jpg" which is valid (dot + extension)
# Test with only special chars that result in empty after sanitization
with pytest.raises(ValueError):
sanitize_filename("...")
class TestGetUniqueFilename:
"""Test collision detection and UUID suffix generation."""
def test_no_collision_returns_base_name(self):
"""Should return base name when no collision exists."""
result = get_unique_filename("SFP-LR", "networking", [])
assert result == "sfp-lr_original.jpg" # Sanitized to lowercase
def test_collision_adds_uuid_suffix(self):
"""Should add UUID suffix when collision detected."""
existing = ["sfp-lr_original.jpg"]
result = get_unique_filename("SFP-LR", "networking", existing)
# Format: {name}_{uuid_first_8}_{variant}.{ext}
assert result.startswith("sfp-lr_")
assert "_original.jpg" in result
assert len(result.split("_")[1]) == 8 # UUID first 8 chars
def test_uuid_suffix_is_valid_hex(self):
"""UUID suffix should be valid hex characters."""
existing = ["sfp-lr_original.jpg"]
result = get_unique_filename("SFP-LR", "networking", existing)
parts = result.split("_")
uuid_part = parts[1]
# Should be valid hex string
try:
int(uuid_part, 16)
except ValueError:
pytest.fail(f"UUID part '{uuid_part}' is not valid hex")
def test_multiple_collisions_generates_new_uuid(self):
"""Each collision should generate a different UUID suffix."""
existing = [
"sfp-lr_original.jpg",
"sfp-lr_a1b2c3_original.jpg",
"sfp-lr_d4e5f6_original.jpg"
]
result = get_unique_filename("SFP-LR", "networking", existing)
# Should not match any existing file
assert result not in existing
def test_case_insensitive_collision_detection(self):
"""Should detect collisions case-insensitively."""
existing = ["SFP-LR_ORIGINAL.JPG"] # Different case
result = get_unique_filename("SFP-LR", "networking", existing)
# Should detect collision despite case difference
assert "_original.jpg" in result
assert result != "SFP-LR_original.jpg"
def test_different_variants_in_collision_detection(self):
"""Should treat original and thumb as different variants."""
# original variant
result1 = get_unique_filename("SFP-LR", "networking", [], variant="original")
assert "_original.jpg" in result1
# thumb variant
result2 = get_unique_filename("SFP-LR", "networking", [], variant="thumb")
assert "_thumb.jpg" in result2
class TestEnsureImageDirectories:
"""Test directory creation on startup."""
def test_creates_images_root_directory(self):
"""Should create /images/ directory if it doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
assert not images_dir.exists()
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
ensure_image_directories()
assert images_dir.exists()
assert images_dir.is_dir()
def test_creates_category_subdirectories(self):
"""Should create category-specific subdirectories."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
categories = ["networking", "electronics", "mechanical"]
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir), \
patch("backend.services.image_storage.get_categories") as mock_get_cats:
mock_get_cats.return_value = categories
ensure_image_directories()
for cat in categories:
cat_dir = images_dir / cat
assert cat_dir.exists(), f"Category directory {cat_dir} not created"
assert cat_dir.is_dir()
def test_idempotent_directory_creation(self):
"""Should succeed if directories already exist."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
# Should not raise error
ensure_image_directories()
assert images_dir.exists()
class TestSaveImage:
"""Test image file saving."""
def test_saves_image_to_correct_path(self):
"""Should save image bytes to /images/{category}/{filename}."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
image_bytes = b"fake image data"
category = "networking"
filename_base = "SFP-LR"
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
result_path = save_image(image_bytes, category, filename_base, "original")
# Check file was created
expected_file = images_dir / category / "sfp-lr_original.jpg"
assert expected_file.exists()
assert expected_file.read_bytes() == image_bytes
def test_returns_relative_path(self):
"""Should return relative path like /images/category/filename."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
image_bytes = b"test"
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
result = save_image(image_bytes, "networking", "test-item", "original")
assert result.startswith("/images/networking/")
assert result.endswith("_original.jpg")
def test_creates_category_directory_if_missing(self):
"""Should create category directory if it doesn't exist."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
image_bytes = b"test"
category = "new_category"
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
result = save_image(image_bytes, category, "test", "original")
cat_dir = images_dir / category
assert cat_dir.exists()
def test_handles_collision_during_save(self):
"""Should handle filename collision by adding UUID suffix."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
cat_dir = images_dir / "networking"
cat_dir.mkdir(parents=True, exist_ok=True)
# Create first file
(cat_dir / "sfp-lr_original.jpg").write_bytes(b"first")
# Try to save with same name
image_bytes = b"second"
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
result = save_image(image_bytes, "networking", "SFP-LR", "original")
# Should have created a different file
assert result != "/images/networking/sfp-lr_original.jpg"
# New file should exist with UUID suffix
assert "_original.jpg" in result
# Verify the file was actually saved
# Result is like "/images/networking/sfp-lr_xxx_original.jpg"
# So relative path from tmpdir is just "images/networking/..."
result_file = Path(tmpdir) / result.lstrip("/")
assert result_file.exists()
assert result_file.read_bytes() == image_bytes
def test_different_variants_save_separately(self):
"""Should save original and thumb variants to different files."""
with tempfile.TemporaryDirectory() as tmpdir:
images_dir = Path(tmpdir) / "images"
images_dir.mkdir(parents=True, exist_ok=True)
cat_dir = images_dir / "networking"
cat_dir.mkdir(parents=True, exist_ok=True)
original_bytes = b"original image data"
thumb_bytes = b"thumbnail data"
with patch("backend.services.image_storage.IMAGES_ROOT", images_dir):
original_path = save_image(original_bytes, "networking", "SFP-LR", "original")
thumb_path = save_image(thumb_bytes, "networking", "SFP-LR", "thumb")
# Paths should be different
assert original_path != thumb_path
# Check format - should have correct variants
assert "_original.jpg" in original_path
assert "_thumb.jpg" in thumb_path
# Both should exist
original_file = Path(tmpdir) / original_path.lstrip("/")
thumb_file = Path(tmpdir) / thumb_path.lstrip("/")
assert original_file.exists()
assert thumb_file.exists()
# Content should be different
assert original_file.read_bytes() == original_bytes
assert thumb_file.read_bytes() == thumb_bytes

View File

@@ -0,0 +1,376 @@
"""
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

View File

@@ -1,85 +1,181 @@
import pytest
from datetime import datetime
from backend.models import Item
from sqlalchemy.orm import Session
from backend.models import Item, AuditLog, User
class TestItemPhotoFields:
"""Test photo fields in Item model."""
"""Tests for Item model photo fields."""
def test_item_has_photo_fields(self, test_db):
"""Test that Item model has three photo fields."""
from backend.models import Item
from sqlalchemy import inspect
def test_item_has_photo_path_field(self, test_db: Session):
"""Verify Item model has photo_path field."""
# Verify the field exists as an attribute
assert hasattr(Item, "photo_path"), "Item model must have photo_path field"
mapper = inspect(Item)
column_names = [c.name for c in mapper.columns]
def test_item_has_photo_thumbnail_path_field(self, test_db: Session):
"""Verify Item model has photo_thumbnail_path field."""
assert hasattr(Item, "photo_thumbnail_path"), "Item model must have photo_thumbnail_path field"
assert "photo_path" in column_names
assert "photo_thumbnail_path" in column_names
assert "photo_upload_date" in column_names
def test_item_has_photo_upload_date_field(self, test_db: Session):
"""Verify Item model has photo_upload_date field."""
assert hasattr(Item, "photo_upload_date"), "Item model must have photo_upload_date field"
def test_item_photo_fields_are_nullable(self, test_db: Session):
"""Verify that photo fields are nullable - can create item without photos."""
# Create item without photo fields
user = User(username="test_user", hashed_password="hashed", role="user", origin="local")
test_db.add(user)
test_db.commit()
def test_photo_fields_are_nullable(self, test_db):
"""Test that photo fields are nullable and can create item without them."""
item = Item(
barcode="TEST-001",
name="Test Item",
category="Electronics",
type="Component",
quantity=5,
barcode="PHOTO-001",
part_number="PN-001"
quantity=5.0
)
test_db.add(item)
test_db.commit()
test_db.refresh(item)
retrieved = test_db.query(Item).filter_by(id=item.id).first()
assert retrieved.photo_path is None
assert retrieved.photo_thumbnail_path is None
# photo_upload_date now has a default of datetime.now, so it should be populated
assert retrieved.photo_upload_date is not None
assert isinstance(retrieved.photo_upload_date, datetime)
# Verify item was created and photo fields are None
assert item.id is not None
assert item.photo_path is None
assert item.photo_thumbnail_path is None
assert item.photo_upload_date is None
def test_photo_fields_can_store_values(self, test_db):
"""Test that photo fields can store string and datetime values."""
upload_date = datetime(2026, 4, 20, 12, 30, 45)
def test_item_photo_fields_can_be_set(self, test_db: Session):
"""Verify that photo fields can be set with values."""
user = User(username="test_user2", hashed_password="hashed", role="user", origin="local")
test_db.add(user)
test_db.commit()
upload_date = datetime.now()
item = Item(
name="Test Item",
barcode="TEST-002",
name="Test Item with Photo",
category="Electronics",
type="Component",
quantity=5,
barcode="PHOTO-002",
part_number="PN-002",
photo_path="/images/items/photo-001.jpg",
photo_thumbnail_path="/images/items/thumbs/photo-001.jpg",
quantity=3.0,
photo_path="networking/SFP-LR_original.jpg",
photo_thumbnail_path="networking/SFP-LR_thumb.jpg",
photo_upload_date=upload_date
)
test_db.add(item)
test_db.commit()
test_db.refresh(item)
retrieved = test_db.query(Item).filter_by(id=item.id).first()
assert retrieved.photo_path == "/images/items/photo-001.jpg"
assert retrieved.photo_thumbnail_path == "/images/items/thumbs/photo-001.jpg"
assert retrieved.photo_upload_date == upload_date
# Verify fields were set correctly
assert item.photo_path == "networking/SFP-LR_original.jpg"
assert item.photo_thumbnail_path == "networking/SFP-LR_thumb.jpg"
assert item.photo_upload_date == upload_date
def test_photo_fields_partial_null(self, test_db):
"""Test that only some photo fields can be set, others null."""
def test_item_photo_path_is_string_type(self, test_db: Session):
"""Verify photo_path field accepts string values."""
user = User(username="test_user3", hashed_password="hashed", role="user", origin="local")
test_db.add(user)
test_db.commit()
path = "path/to/image.jpg"
item = Item(
name="Test Item",
barcode="TEST-003",
name="Item",
category="Electronics",
type="Component",
quantity=5,
barcode="PHOTO-003",
part_number="PN-003",
photo_path="/images/items/photo-003.jpg",
photo_thumbnail_path=None,
photo_upload_date=None
photo_path=path
)
test_db.add(item)
test_db.commit()
test_db.refresh(item)
retrieved = test_db.query(Item).filter_by(id=item.id).first()
assert retrieved.photo_path == "/images/items/photo-003.jpg"
assert retrieved.photo_thumbnail_path is None
# photo_upload_date gets default timestamp even when explicitly set to None
assert retrieved.photo_upload_date is not None
assert isinstance(retrieved.photo_upload_date, datetime)
assert isinstance(item.photo_path, str)
assert item.photo_path == path
def test_item_photo_upload_date_is_datetime_type(self, test_db: Session):
"""Verify photo_upload_date field accepts datetime values."""
user = User(username="test_user4", hashed_password="hashed", role="user", origin="local")
test_db.add(user)
test_db.commit()
now = datetime.now()
item = Item(
barcode="TEST-004",
name="Item",
category="Electronics",
photo_upload_date=now
)
test_db.add(item)
test_db.commit()
test_db.refresh(item)
assert isinstance(item.photo_upload_date, datetime)
assert item.photo_upload_date == now
def test_existing_item_without_photos_unaffected(self, test_db: Session):
"""Verify that existing items without photos continue to work."""
user = User(username="test_user5", hashed_password="hashed", role="user", origin="local")
test_db.add(user)
test_db.commit()
# Create item without photo fields (legacy behavior)
item = Item(
barcode="LEGACY-001",
name="Legacy Item",
category="Electronics",
quantity=10.0,
part_number="PN-999",
description="A legacy item"
)
test_db.add(item)
test_db.commit()
test_db.refresh(item)
# Verify all existing fields still work
assert item.barcode == "LEGACY-001"
assert item.name == "Legacy Item"
assert item.category == "Electronics"
assert item.quantity == 10.0
assert item.part_number == "PN-999"
assert item.description == "A legacy item"
# Photo fields should be None for legacy items
assert item.photo_path is None
assert item.photo_thumbnail_path is None
assert item.photo_upload_date is None
class TestBoxModel:
"""Tests for Box model photo fields (if Box model exists)."""
def test_box_model_exists(self):
"""Verify Box model exists in database schema."""
# Import Box model if it exists
try:
from backend.models import Box
assert Box is not None
except ImportError:
pytest.skip("Box model not yet implemented")
def test_box_photo_fields_exist(self):
"""Verify Box model has photo fields if it exists."""
try:
from backend.models import Box
assert hasattr(Box, "photo_path")
assert hasattr(Box, "photo_thumbnail_path")
assert hasattr(Box, "photo_upload_date")
except ImportError:
pytest.skip("Box model not yet implemented")
def test_box_photo_fields_are_nullable(self, test_db: Session):
"""Verify Box photo fields are nullable."""
try:
from backend.models import Box
box = Box(name="Test Box")
test_db.add(box)
test_db.commit()
test_db.refresh(box)
assert box.id is not None
assert box.photo_path is None
assert box.photo_thumbnail_path is None
assert box.photo_upload_date is None
except ImportError:
pytest.skip("Box model not yet implemented")

View File

@@ -0,0 +1,256 @@
"""Tests for static file serving of uploaded images."""
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from backend.main import app
from backend.services.image_storage import save_image, IMAGES_ROOT, ensure_image_directories
@pytest.fixture(autouse=True)
def setup_images_directory():
"""Create and clean up images directory for each test."""
# Ensure directory exists
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
yield
# Cleanup after test
import shutil
if IMAGES_ROOT.exists():
shutil.rmtree(IMAGES_ROOT)
@pytest.fixture
def client():
"""FastAPI test client."""
return TestClient(app)
@pytest.fixture
def sample_jpeg_image():
"""Create a minimal valid JPEG image for testing."""
# Minimal JPEG header + footer
jpeg_data = (
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\''
b'\x9898_-282-\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x11\x00'
b'\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08'
b'\t\n\x0b\xff\xda\x00\x08\x01\x01\x00\x00?\x00\x7f\x00\xff\xd9'
)
return jpeg_data
@pytest.fixture
def sample_png_image():
"""Create a minimal valid PNG image for testing."""
# Create a minimal valid PNG programmatically using PIL
try:
from PIL import Image
from io import BytesIO
img = Image.new('RGB', (1, 1), color='white')
buffer = BytesIO()
img.save(buffer, format='PNG')
return buffer.getvalue()
except ImportError:
# Fallback to hardcoded PNG if PIL not available
# This is a valid minimal PNG that should be detected correctly
png_data = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00'
b'\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx'
b'\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\xfc\x83\t\xbf\x00'
b'\x00\x00\x00IEND\xaeB`\x82'
)
return png_data
class TestStaticFileServing:
"""Test static file serving for /images/ directory."""
def test_images_directory_exists_on_startup(self):
"""Verify /images directory is created on startup."""
assert IMAGES_ROOT.exists(), f"Images directory {IMAGES_ROOT} should exist"
assert IMAGES_ROOT.is_dir(), f"{IMAGES_ROOT} should be a directory"
def test_serve_uploaded_jpeg_image(self, client, sample_jpeg_image):
"""Test serving a JPEG image uploaded to /images/."""
# Save image directly using image storage utility
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="original"
)
# Request the image via static file mount
response = client.get(image_path)
# Verify response
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
assert response.headers["content-type"] == "image/jpeg"
assert response.content == sample_jpeg_image
def test_serve_uploaded_png_image(self, client, sample_png_image):
"""Test serving a PNG image uploaded to /images/."""
image_path = save_image(
file_bytes=sample_png_image,
category="test_category",
filename_base="test_png",
variant="original"
)
response = client.get(image_path)
# Note: save_image always saves with .jpg extension
assert response.status_code == 200
# Content is served back correctly (extension determines MIME type)
assert response.content == sample_png_image
def test_serve_thumbnail_variant(self, client, sample_jpeg_image):
"""Test serving thumbnail variant of an image."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="thumb"
)
response = client.get(image_path)
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_404_for_nonexistent_image(self, client):
"""Test 404 response for non-existent image."""
response = client.get("/images/nonexistent/missing.jpg")
assert response.status_code == 404
def test_directory_traversal_protection(self, client):
"""Test that directory traversal attempts are blocked."""
# Try various directory traversal patterns
traversal_paths = [
"/images/../../../etc/passwd",
"/images/test/../../etc/passwd",
"/images/test/..%2F..%2Fetc%2Fpasswd",
]
for path in traversal_paths:
response = client.get(path)
# Should either be 404 or 400, not 200
assert response.status_code in [400, 404], \
f"Path {path} should not be accessible, got {response.status_code}"
def test_serve_multiple_categories(self, client, sample_jpeg_image, sample_png_image):
"""Test serving images from multiple categories."""
# Save images to different categories
jpeg_path = save_image(
file_bytes=sample_jpeg_image,
category="memory",
filename_base="ddr4_stick",
variant="original"
)
png_path = save_image(
file_bytes=sample_png_image,
category="networking",
filename_base="sfp_transceiver",
variant="original"
)
# Verify both can be served
jpeg_response = client.get(jpeg_path)
png_response = client.get(png_path)
assert jpeg_response.status_code == 200
assert jpeg_response.headers["content-type"] == "image/jpeg"
assert png_response.status_code == 200
# Both saved as .jpg due to save_image implementation
assert png_response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_content_matches_uploaded_file(self, client, sample_jpeg_image):
"""Verify that served content exactly matches uploaded file."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="test_image",
variant="original"
)
response = client.get(image_path)
# Content-length should match
assert len(response.content) == len(sample_jpeg_image)
# Content should match byte-for-byte
assert response.content == sample_jpeg_image
def test_mime_type_auto_detection_jpeg(self, client, sample_jpeg_image):
"""Test MIME type is auto-detected correctly for JPEG."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="jpeg_file",
variant="original"
)
response = client.get(image_path)
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_mime_type_auto_detection_all_saved_as_jpeg(self, client, sample_png_image):
"""Test MIME type detection for images saved as JPEG (all images saved as .jpg)."""
# Note: save_image always saves with .jpg extension regardless of input format
image_path = save_image(
file_bytes=sample_png_image,
category="test_category",
filename_base="png_file",
variant="original"
)
response = client.get(image_path)
# File extension is .jpg, so MIME type will be image/jpeg
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
def test_original_and_thumbnail_variants_accessible(self, client, sample_jpeg_image):
"""Test both original and thumbnail variants are accessible."""
original_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="multi_variant",
variant="original"
)
thumb_path = save_image(
file_bytes=sample_jpeg_image,
category="test_category",
filename_base="multi_variant",
variant="thumb"
)
# Both should be accessible
original_response = client.get(original_path)
thumb_response = client.get(thumb_path)
assert original_response.status_code == 200
assert thumb_response.status_code == 200
def test_images_served_with_correct_path_structure(self, client, sample_jpeg_image):
"""Test images are served at /images/{category}/{filename} path."""
image_path = save_image(
file_bytes=sample_jpeg_image,
category="storage",
filename_base="ssd_drive",
variant="original"
)
# Path should be /images/storage/ssd_drive_original.jpg
assert image_path.startswith("/images/"), "Path should start with /images/"
assert "storage" in image_path, "Path should include category"
response = client.get(image_path)
assert response.status_code == 200