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