Files
tfm_ainventory/backend/tests/test_image_storage.py
Daniel Bedeleanu ea49cd6e4a 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
2026-04-20 21:57:26 +03:00

274 lines
12 KiB
Python

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