feat(phase1): add image storage utilities
- Create backend/services/image_storage.py with 4 core functions:
- sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
- get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
- ensure_image_directories(): create /images/ root and category subdirs on startup
- save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
273
backend/tests/test_image_storage.py
Normal file
273
backend/tests/test_image_storage.py
Normal 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
|
||||
181
backend/tests/test_schema.py
Normal file
181
backend/tests/test_schema.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import Item, AuditLog, User
|
||||
|
||||
|
||||
class TestItemPhotoFields:
|
||||
"""Tests for Item model photo fields."""
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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()
|
||||
|
||||
item = Item(
|
||||
barcode="TEST-001",
|
||||
name="Test Item",
|
||||
category="Electronics",
|
||||
quantity=5.0
|
||||
)
|
||||
test_db.add(item)
|
||||
test_db.commit()
|
||||
test_db.refresh(item)
|
||||
|
||||
# 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_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(
|
||||
barcode="TEST-002",
|
||||
name="Test Item with Photo",
|
||||
category="Electronics",
|
||||
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)
|
||||
|
||||
# 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_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(
|
||||
barcode="TEST-003",
|
||||
name="Item",
|
||||
category="Electronics",
|
||||
photo_path=path
|
||||
)
|
||||
test_db.add(item)
|
||||
test_db.commit()
|
||||
test_db.refresh(item)
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user