"""Tests for abacus_file_upload.utils.s3 module.""" from datetime import datetime, timezone import pytest from abacus_file_upload.constants import ( S3_MAX_CHUNK_SIZE_BYTES, S3_MAX_PARTS, S3_MIN_CHUNK_SIZE_BYTES, ) from abacus_file_upload.utils.s3 import ( calculate_optimal_chunk_size, convert_md5_hex_to_base64, format_bytes, generate_key, get_file_extension, get_file_parts, validate_file_type, ) class TestFormatBytes: """Tests for format_bytes function.""" def test_zero_bytes(self): """Test formatting zero bytes.""" assert format_bytes(0) == '0 B' def test_bytes(self): """Test formatting bytes (< 1KB).""" assert format_bytes(512) == '512.00 B' assert format_bytes(1023) == '1023.00 B' def test_kilobytes(self): """Test formatting kilobytes.""" assert format_bytes(1024) == '1.00 KB' assert format_bytes(1536) == '1.50 KB' assert format_bytes(2048) == '2.00 KB' def test_megabytes(self): """Test formatting megabytes.""" assert format_bytes(1048576) == '1.00 MB' assert format_bytes(5242880) == '5.00 MB' assert format_bytes(10485760) == '10.00 MB' def test_gigabytes(self): """Test formatting gigabytes.""" assert format_bytes(1073741824) == '1.00 GB' assert format_bytes(5368709120) == '5.00 GB' def test_terabytes(self): """Test formatting terabytes.""" assert format_bytes(1099511627776) == '1.00 TB' assert format_bytes(5497558138880) == '5.00 TB' def test_none(self): """Test formatting None returns 'unlimited'.""" assert format_bytes(None) == 'unlimited' def test_rounding(self): """Test formatting rounds to 2 decimal places.""" assert format_bytes(1536) == '1.50 KB' assert format_bytes(1049) == '1.02 KB' class TestGetFileParts: """Tests for get_file_parts function.""" def test_simple_extension(self): """Test file with simple extension.""" filename, ext = get_file_parts('report.csv') assert filename == 'report' assert ext == 'csv' def test_compound_extension(self): """Test file with compound extension.""" filename, ext = get_file_parts('archive.tar.gz') assert filename == 'archive.tar' assert ext == 'gz' def test_uppercase_extension(self): """Test file with uppercase extension is lowercased.""" filename, ext = get_file_parts('report.CSV') assert filename == 'report' assert ext == 'csv' def test_no_extension(self): """Test file without extension.""" filename, ext = get_file_parts('README') assert filename == 'README' assert ext is None def test_hidden_file(self): """Test hidden file (starts with dot).""" filename, ext = get_file_parts('.gitignore') assert filename == '.gitignore' assert ext is None def test_hidden_file_with_extension(self): """Test hidden file with extension.""" filename, ext = get_file_parts('.config.json') assert filename == '.config' assert ext == 'json' class TestGetFileExtension: """Tests for get_file_extension function.""" def test_csv_extension(self): """Test extracting CSV extension.""" assert get_file_extension('data.csv') == 'csv' def test_xlsx_extension(self): """Test extracting XLSX extension.""" assert get_file_extension('spreadsheet.xlsx') == 'xlsx' def test_no_extension(self): """Test file without extension.""" assert get_file_extension('README') is None def test_uppercase_extension(self): """Test uppercase extension is lowercased.""" assert get_file_extension('DATA.CSV') == 'csv' class TestConvertMd5HexToBase64: """Tests for convert_md5_hex_to_base64 function.""" def test_valid_md5_conversion(self): """Test converting valid MD5 hex to base64.""" hex_md5 = '5d41402abc4b2a76b9719d911017c592' base64_md5 = convert_md5_hex_to_base64(hex_md5) # Verify it's a valid base64 string assert isinstance(base64_md5, str) assert len(base64_md5) == 24 # MD5 base64 is always 24 chars assert ( base64_md5.endswith('==') or base64_md5.endswith('=') or '=' not in base64_md5 ) def test_lowercase_hex(self): """Test conversion with lowercase hex.""" hex_md5 = 'abcdef1234567890abcdef1234567890' base64_md5 = convert_md5_hex_to_base64(hex_md5) assert len(base64_md5) == 24 def test_uppercase_hex(self): """Test conversion with uppercase hex.""" hex_md5 = 'ABCDEF1234567890ABCDEF1234567890' base64_md5 = convert_md5_hex_to_base64(hex_md5) assert len(base64_md5) == 24 class TestCalculateOptimalChunkSize: """Tests for calculate_optimal_chunk_size function.""" def test_small_file(self): """Test chunk size for small file.""" file_size = 10 * 1024 * 1024 # 10MB min_chunk = 5 * 1024 * 1024 # 5MB chunk_size = calculate_optimal_chunk_size(file_size, min_chunk) assert chunk_size >= min_chunk assert chunk_size >= S3_MIN_CHUNK_SIZE_BYTES assert chunk_size <= S3_MAX_CHUNK_SIZE_BYTES def test_medium_file(self): """Test chunk size for medium file.""" file_size = 1 * 1024 * 1024 * 1024 # 1GB min_chunk = 10 * 1024 * 1024 # 10MB chunk_size = calculate_optimal_chunk_size(file_size, min_chunk) assert chunk_size >= min_chunk assert chunk_size >= S3_MIN_CHUNK_SIZE_BYTES assert chunk_size <= S3_MAX_CHUNK_SIZE_BYTES def test_large_file(self): """Test chunk size for large file.""" file_size = 5 * 1024 * 1024 * 1024 * 1024 # 5TB (S3 max) min_chunk = 10 * 1024 * 1024 # 10MB chunk_size = calculate_optimal_chunk_size(file_size, min_chunk) assert chunk_size >= min_chunk assert chunk_size >= S3_MIN_CHUNK_SIZE_BYTES assert chunk_size <= S3_MAX_CHUNK_SIZE_BYTES def test_respects_min_chunk_size(self): """Test that chunk size respects minimum.""" file_size = 100 * 1024 * 1024 # 100MB min_chunk = 50 * 1024 * 1024 # 50MB chunk_size = calculate_optimal_chunk_size(file_size, min_chunk) assert chunk_size >= min_chunk assert chunk_size >= S3_MIN_CHUNK_SIZE_BYTES assert chunk_size <= S3_MAX_CHUNK_SIZE_BYTES class TestValidateFileType: """Tests for validate_file_type function.""" def test_allowed_type(self): """Test validating allowed file type.""" assert validate_file_type('csv', ['csv', 'xlsx']) is True assert validate_file_type('xlsx', ['csv', 'xlsx']) is True def test_disallowed_type(self): """Test validating disallowed file type.""" assert validate_file_type('pdf', ['csv', 'xlsx']) is False assert validate_file_type('exe', ['csv', 'xlsx']) is False def test_no_restrictions(self): """Test validation with no restrictions (None or empty list).""" assert validate_file_type('pdf', None) is True assert validate_file_type('exe', []) is True def test_none_file_type_with_restrictions(self): """Test None file type with restrictions.""" assert validate_file_type(None, ['csv', 'xlsx']) is False def test_case_sensitive(self): """Test that validation is case-sensitive.""" # File extension should already be lowercase from get_file_extension assert validate_file_type('csv', ['csv', 'xlsx']) is True assert ( validate_file_type('CSV', ['csv', 'xlsx']) is False ) # Should be lowercase class TestGenerateKey: """Tests for generate_key function.""" def test_basic_template(self): """Test basic key generation with year/month/file_key.""" template = '{year}/{month}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' upload_time = datetime(2025, 1, 15, tzinfo=timezone.utc) key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, upload_time=upload_time, ) assert key == '2025/01/abc-123.csv' def test_template_with_entity_id(self): """Test key generation with entity_id in metadata.""" template = '{entity_id}/{year}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' metadata = {'entity_id': 456} upload_time = datetime(2025, 1, 15, tzinfo=timezone.utc) key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, metadata=metadata, upload_time=upload_time, ) assert key == '456/2025/abc-123.csv' def test_template_without_entity_id_in_metadata(self): """Test key generation when entity_id in template but not in metadata.""" template = '{entity_id}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' metadata = {} # No entity_id key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, metadata=metadata, ) # entity_id segment should be removed assert key == 'abc-123.csv' def test_template_with_upload_type(self): """Test key generation with upload_type.""" template = '{upload_type}/{year}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'flowthrough' file_name = 'data.xlsx' upload_time = datetime(2025, 3, 10, tzinfo=timezone.utc) key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, upload_time=upload_time, ) assert key == 'flowthrough/2025/abc-123.xlsx' def test_template_with_filename(self): """Test key generation with filename (without extension).""" template = '{year}/{filename}_{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'monthly_report.csv' upload_time = datetime(2025, 1, 15, tzinfo=timezone.utc) key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, upload_time=upload_time, ) assert key == '2025/monthly_report_abc-123.csv' def test_template_with_all_date_parts(self): """Test key generation with year, month, and day.""" template = '{year}/{month}/{day}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' upload_time = datetime(2025, 1, 5, tzinfo=timezone.utc) key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, upload_time=upload_time, ) assert key == '2025/01/05/abc-123.csv' def test_default_upload_time(self): """Test key generation with default upload time (current time).""" template = '{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' key = generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, ) assert key == 'abc-123.csv' def test_invalid_template_variable(self): """Test key generation with invalid template variable.""" template = '{invalid_var}/{file_key}.{ext}' file_key = 'abc-123' upload_type = 'adjustments' file_name = 'report.csv' with pytest.raises(ValueError) as exc_info: generate_key( template=template, file_key=file_key, upload_type=upload_type, file_name=file_name, ) assert 'Invalid template variable' in str(exc_info.value)