3fd354ecf7
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
420 lines
16 KiB
Python
420 lines
16 KiB
Python
"""
|
|
Tests for app/utils/filename_utils.py
|
|
|
|
Tests filename sanitization and manipulation functions.
|
|
"""
|
|
|
|
import os
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFilenameSanitization:
|
|
"""Test filename sanitization functions"""
|
|
|
|
def test_sanitize_filename_basic(self):
|
|
"""Test basic filename sanitization"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
# Basic valid filename
|
|
result = sanitize_filename("document.pdf")
|
|
assert result == "document.pdf"
|
|
|
|
def test_sanitize_filename_with_spaces(self):
|
|
"""Test sanitization of filenames with spaces"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("my document file.pdf")
|
|
# Spaces should be preserved
|
|
assert "my" in result
|
|
assert "document" in result
|
|
assert "file.pdf" in result
|
|
|
|
def test_sanitize_filename_with_special_characters(self):
|
|
"""Test sanitization removes or replaces special characters"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("file:with*special?chars.pdf")
|
|
# Special characters should be replaced with underscores
|
|
assert ":" not in result
|
|
assert "*" not in result
|
|
assert "?" not in result
|
|
assert "_" in result
|
|
|
|
def test_sanitize_filename_with_path_separators(self):
|
|
"""Test that path separators are handled"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("../../../etc/passwd")
|
|
# Path traversal characters should be replaced
|
|
assert ".." not in result or result.count("..") < 3
|
|
|
|
def test_sanitize_filename_empty_string(self):
|
|
"""Test sanitization of empty string"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("")
|
|
# Should return a valid string (default name with timestamp)
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
assert "document" in result
|
|
|
|
def test_sanitize_filename_only_periods(self):
|
|
"""Test sanitization of only periods"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("...")
|
|
# Should return a default name
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
assert "document" in result
|
|
|
|
def test_sanitize_filename_leading_trailing_spaces(self):
|
|
"""Test sanitization trims leading/trailing spaces"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename(" filename.pdf ")
|
|
assert result == "filename.pdf"
|
|
|
|
def test_sanitize_filename_multiple_underscores(self):
|
|
"""Test sanitization collapses multiple underscores"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("file____name.pdf")
|
|
assert "____" not in result
|
|
assert result == "file_name.pdf"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUniqueFilenameGeneration:
|
|
"""Test unique filename generation"""
|
|
|
|
def test_get_unique_filename_no_collision(self):
|
|
"""Test that original filename is returned when no collision"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Mock check_exists_func to return False (file doesn't exist)
|
|
check_func = Mock(return_value=False)
|
|
|
|
result = get_unique_filename("/tmp/document.pdf", check_exists_func=check_func)
|
|
assert result == "/tmp/document.pdf"
|
|
check_func.assert_called_once_with("/tmp/document.pdf")
|
|
|
|
def test_get_unique_filename_with_collision(self):
|
|
"""Test that unique filename is generated on collision"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Mock check_exists_func to return True for original, False for timestamped
|
|
def check_func(path):
|
|
return path == "/tmp/document.pdf"
|
|
|
|
result = get_unique_filename("/tmp/document.pdf", check_exists_func=check_func)
|
|
assert result != "/tmp/document.pdf"
|
|
assert "document" in result
|
|
assert ".pdf" in result
|
|
|
|
def test_get_unique_filename_uses_timestamp(self):
|
|
"""Test that timestamp is added on collision"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# First file exists
|
|
check_func = Mock(side_effect=[True, False])
|
|
|
|
result = get_unique_filename("/tmp/test.pdf", check_exists_func=check_func)
|
|
assert result != "/tmp/test.pdf"
|
|
assert "test_" in result
|
|
assert ".pdf" in result
|
|
|
|
def test_get_unique_filename_falls_back_to_uuid(self):
|
|
"""Test UUID fallback when timestamp collision occurs"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Original and timestamp both exist
|
|
check_func = Mock(side_effect=[True, True, False])
|
|
|
|
result = get_unique_filename("/tmp/test.pdf", check_exists_func=check_func)
|
|
assert result != "/tmp/test.pdf"
|
|
assert "test_" in result
|
|
assert ".pdf" in result
|
|
|
|
def test_get_unique_filename_default_check_function(self):
|
|
"""Test that os.path.exists is used by default"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Use actual filesystem check
|
|
result = get_unique_filename("/tmp/nonexistent_file_12345.pdf")
|
|
# Should return original since file doesn't exist
|
|
assert result == "/tmp/nonexistent_file_12345.pdf"
|
|
|
|
def test_get_unique_filename_counter_fallback(self):
|
|
"""Test counter fallback when both timestamp and UUID already exist"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Original, timestamp, and first UUID all exist, but counter is free
|
|
call_count = [0]
|
|
|
|
def check_func(path):
|
|
call_count[0] += 1
|
|
# First 3 calls return True (original, timestamp, UUID exist)
|
|
# Fourth call returns False (counter-based name is free)
|
|
return call_count[0] <= 3
|
|
|
|
result = get_unique_filename("/tmp/test.pdf", check_exists_func=check_func)
|
|
assert result != "/tmp/test.pdf"
|
|
assert "test_" in result
|
|
assert ".pdf" in result
|
|
# Should end with _1.pdf since that's the first counter
|
|
assert result.endswith("_1.pdf")
|
|
|
|
def test_get_unique_filename_full_uuid_fallback(self):
|
|
"""Test full UUID fallback when 1000+ counters exist"""
|
|
from app.utils.filename_utils import get_unique_filename
|
|
|
|
# Make it return True for the first 1003 calls (original, timestamp, UUID, and 1000 counters)
|
|
call_count = [0]
|
|
|
|
def check_func(path):
|
|
call_count[0] += 1
|
|
# Return True for first 1003 calls to simulate all variations existing
|
|
return call_count[0] <= 1003
|
|
|
|
result = get_unique_filename("/tmp/test.pdf", check_exists_func=check_func)
|
|
assert result != "/tmp/test.pdf"
|
|
assert "test_" in result
|
|
assert ".pdf" in result
|
|
# Should contain a full UUID (36 characters with dashes)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractRemotePath:
|
|
"""Test remote path extraction"""
|
|
|
|
def test_extract_remote_path_basic(self):
|
|
"""Test basic remote path extraction"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/home/user/docs/file.pdf"
|
|
base_dir = "/home/user"
|
|
remote_base = "Documents"
|
|
|
|
result = extract_remote_path(file_path, base_dir, remote_base)
|
|
assert result == "Documents/docs/file.pdf"
|
|
|
|
def test_extract_remote_path_without_remote_base(self):
|
|
"""Test remote path extraction without remote base"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/home/user/docs/file.pdf"
|
|
base_dir = "/home/user"
|
|
|
|
result = extract_remote_path(file_path, base_dir, "")
|
|
assert result == "docs/file.pdf"
|
|
|
|
def test_extract_remote_path_skips_processed_dir(self):
|
|
"""Test that 'processed' directory is skipped"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/home/user/processed/docs/file.pdf"
|
|
base_dir = "/home/user"
|
|
|
|
result = extract_remote_path(file_path, base_dir, "")
|
|
assert "processed" not in result
|
|
assert result == "docs/file.pdf"
|
|
|
|
def test_extract_remote_path_with_absolute_remote_base(self):
|
|
"""Test remote path extraction with absolute remote base"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/home/user/docs/file.pdf"
|
|
base_dir = "/home/user"
|
|
remote_base = "/Documents"
|
|
|
|
result = extract_remote_path(file_path, base_dir, remote_base)
|
|
# Leading slash should be stripped
|
|
assert result == "Documents/docs/file.pdf"
|
|
|
|
def test_extract_remote_path_file_outside_base(self):
|
|
"""Test handling of file outside base directory"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/other/path/file.pdf"
|
|
base_dir = "/home/user"
|
|
|
|
result = extract_remote_path(file_path, base_dir, "")
|
|
# Should just use filename
|
|
assert result == "file.pdf"
|
|
|
|
def test_extract_remote_path_uses_forward_slashes(self):
|
|
"""Test that result uses forward slashes"""
|
|
from app.utils.filename_utils import extract_remote_path
|
|
|
|
file_path = "/home/user/docs/subfolder/file.pdf"
|
|
base_dir = "/home/user"
|
|
|
|
result = extract_remote_path(file_path, base_dir, "")
|
|
# Should use forward slashes for cloud service compatibility
|
|
assert "/" in result
|
|
assert "\\" not in result
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestFilenameUtilsEdgeCases:
|
|
"""Test edge cases in filename utilities"""
|
|
|
|
def test_very_long_filename(self):
|
|
"""Test handling of very long filenames"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
long_name = "a" * 300 + ".pdf"
|
|
result = sanitize_filename(long_name)
|
|
# Should handle long filenames
|
|
assert isinstance(result, str)
|
|
assert len(result) > 0
|
|
|
|
def test_filename_with_multiple_dots(self):
|
|
"""Test filename with multiple dots"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename("my.document.file.name.pdf")
|
|
assert isinstance(result, str)
|
|
assert ".pdf" in result
|
|
assert result == "my.document.file.name.pdf"
|
|
|
|
def test_sanitize_filename_windows_reserved_chars(self):
|
|
"""Test sanitization of Windows reserved characters"""
|
|
from app.utils.filename_utils import sanitize_filename
|
|
|
|
result = sanitize_filename('file<>:"|?*.pdf')
|
|
# All reserved chars should be replaced
|
|
assert "<" not in result
|
|
assert ">" not in result
|
|
assert ":" not in result
|
|
assert '"' not in result
|
|
assert "|" not in result
|
|
assert "?" not in result
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUniqueFilepathWithCounter:
|
|
"""Test unique filepath generation with numeric counter suffix"""
|
|
|
|
def test_get_unique_filepath_with_counter_no_collision(self, tmp_path):
|
|
"""Test that original filename is returned when no collision exists"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "document")
|
|
assert result == str(tmp_path / "document.pdf")
|
|
|
|
def test_get_unique_filepath_with_counter_single_collision(self, tmp_path):
|
|
"""Test that -0001 suffix is added on first collision"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
# Create the base file
|
|
(tmp_path / "document.pdf").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "document")
|
|
assert result == str(tmp_path / "document-0001.pdf")
|
|
|
|
def test_get_unique_filepath_with_counter_multiple_collisions(self, tmp_path):
|
|
"""Test that counter increments correctly for multiple collisions"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
# Create files with base name and first two counter suffixes
|
|
(tmp_path / "document.pdf").touch()
|
|
(tmp_path / "document-0001.pdf").touch()
|
|
(tmp_path / "document-0002.pdf").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "document")
|
|
assert result == str(tmp_path / "document-0003.pdf")
|
|
|
|
def test_get_unique_filepath_with_counter_custom_extension(self, tmp_path):
|
|
"""Test with custom file extension"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
(tmp_path / "data.json").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "data", extension=".json")
|
|
assert result == str(tmp_path / "data-0001.json")
|
|
|
|
def test_get_unique_filepath_with_counter_zero_padded(self, tmp_path):
|
|
"""Test that counter uses zero-padded 4-digit format"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
(tmp_path / "invoice.pdf").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "invoice")
|
|
# Should be -0001, not -1
|
|
assert result == str(tmp_path / "invoice-0001.pdf")
|
|
assert "-1.pdf" not in result
|
|
|
|
def test_get_unique_filepath_with_counter_preserves_filename(self, tmp_path):
|
|
"""Test that complex filenames are preserved"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
filename = "2024-01-01_Invoice_Company-Name"
|
|
(tmp_path / f"{filename}.pdf").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), filename)
|
|
assert filename in result
|
|
assert result == str(tmp_path / f"{filename}-0001.pdf")
|
|
|
|
def test_get_unique_filepath_with_counter_high_count(self, tmp_path):
|
|
"""Test that function handles high counter values"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
# Create files up to -0099
|
|
(tmp_path / "test.pdf").touch()
|
|
for i in range(1, 100):
|
|
(tmp_path / f"test-{i:04d}.pdf").touch()
|
|
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "test")
|
|
assert result == str(tmp_path / "test-0100.pdf")
|
|
|
|
def test_get_unique_filepath_with_counter_directory_creation(self, tmp_path):
|
|
"""Test with directory that already exists"""
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
# Directory already exists (tmp_path)
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "newfile")
|
|
assert result == str(tmp_path / "newfile.pdf")
|
|
# File shouldn't be created, just path returned
|
|
assert not os.path.exists(result)
|
|
|
|
def test_get_unique_filepath_with_counter_extreme_collision(self, tmp_path):
|
|
"""Test extreme edge case when more than 9999 collisions occur"""
|
|
from unittest.mock import patch
|
|
|
|
from app.utils.filename_utils import get_unique_filepath_with_counter
|
|
|
|
# Create base file to trigger counter logic
|
|
(tmp_path / "test.pdf").touch()
|
|
|
|
# Mock os.path.exists to simulate 10000+ collisions
|
|
original_exists = os.path.exists
|
|
call_count = [0]
|
|
|
|
def mock_exists(path):
|
|
# Use actual filesystem for the tmp_path directory check
|
|
if path == str(tmp_path):
|
|
return original_exists(path)
|
|
# Check if it's our base file
|
|
if path == str(tmp_path / "test.pdf"):
|
|
return True
|
|
# Simulate all counter-based files existing up to counter 10000
|
|
call_count[0] += 1
|
|
# First 10000 calls for counters return True (files exist)
|
|
if call_count[0] <= 10000:
|
|
return True
|
|
# After that, allow the timestamp+UUID version to not exist
|
|
return False
|
|
|
|
with patch("os.path.exists", side_effect=mock_exists):
|
|
result = get_unique_filepath_with_counter(str(tmp_path), "test")
|
|
# Should have timestamp and UUID in the name
|
|
assert "test-" in result
|
|
assert ".pdf" in result
|
|
# Should not be a simple counter-based name
|
|
assert not any(f"test-{i:04d}.pdf" in result for i in range(1, 100))
|