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
|
||||
Reference in New Issue
Block a user