""" Tests for the file splitting utility module. Tests cover: - PDF splitting by size - Handling of edge cases (empty PDFs, single-page PDFs, etc.) - Error handling - should_split_file function """ import os import tempfile import pytest from pypdf import PdfReader, PdfWriter # Upgraded from PyPDF2 to fix CVE-2023-36464 from app.utils.file_splitting import should_split_file, split_pdf_by_size @pytest.fixture def sample_multipage_pdf(): """Create a sample multi-page PDF for testing.""" with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() # Add 5 pages to the PDF for i in range(5): writer.add_blank_page(width=200, height=200) writer.write(f) pdf_path = f.name yield pdf_path # Cleanup if os.path.exists(pdf_path): os.remove(pdf_path) @pytest.fixture def sample_single_page_pdf(): """Create a sample single-page PDF for testing.""" with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() writer.add_blank_page(width=200, height=200) writer.write(f) pdf_path = f.name yield pdf_path # Cleanup if os.path.exists(pdf_path): os.remove(pdf_path) @pytest.mark.unit class TestSplitPdfBySize: """Tests for the split_pdf_by_size function.""" def test_split_pdf_basic(self, sample_multipage_pdf): """Test basic PDF splitting functionality.""" # Use a very small size limit to force splitting max_size = 2000 # 2KB - should split the 5-page PDF split_files = split_pdf_by_size(sample_multipage_pdf, max_size) # Verify files were created (should be at least 1 file) assert len(split_files) >= 1, "PDF should create at least one file" # Verify all split files exist for split_file in split_files: assert os.path.exists(split_file), f"Split file {split_file} should exist" assert os.path.getsize(split_file) > 0, f"Split file {split_file} should not be empty" # Verify total pages match original original_reader = PdfReader(sample_multipage_pdf) total_split_pages = sum(len(PdfReader(f).pages) for f in split_files) assert total_split_pages == len(original_reader.pages), "Total pages should match original" # If we got more than 1 file, verify each file is under the limit (with some margin for PDF overhead) if len(split_files) > 1: # PDF_OVERHEAD_MULTIPLIER: PDFs have structural overhead (headers, metadata, compression) # that can cause files to exceed the target size by ~20-50%. We allow 1.5x (50%) margin. PDF_OVERHEAD_MULTIPLIER = 1.5 for split_file in split_files: assert os.path.getsize(split_file) <= max_size * PDF_OVERHEAD_MULTIPLIER, ( f"Split file {split_file} should respect size limit (with PDF overhead allowance)" ) # Cleanup split files for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) def test_split_pdf_with_output_dir(self, sample_multipage_pdf): """Test PDF splitting with custom output directory.""" with tempfile.TemporaryDirectory() as temp_dir: max_size = 5000 split_files = split_pdf_by_size(sample_multipage_pdf, max_size, output_dir=temp_dir) # Verify files are in the specified directory for split_file in split_files: assert os.path.dirname(split_file) == temp_dir, "Split files should be in output_dir" assert os.path.exists(split_file), f"Split file {split_file} should exist" # Files will be cleaned up with temp_dir def test_split_pdf_single_page(self, sample_single_page_pdf): """Test splitting a single-page PDF.""" max_size = 1000 # Very small limit split_files = split_pdf_by_size(sample_single_page_pdf, max_size) # Should create at least one file (might be just the single page) assert len(split_files) >= 1, "Should create at least one output file" # Verify the split file exists for split_file in split_files: assert os.path.exists(split_file), f"Split file {split_file} should exist" # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) def test_split_pdf_large_limit(self, sample_multipage_pdf): """Test that PDF is not split when limit is very large.""" max_size = 10 * 1024 * 1024 # 10MB - much larger than test PDF split_files = split_pdf_by_size(sample_multipage_pdf, max_size) # Should create only one file (no splitting needed) assert len(split_files) == 1, "PDF should not be split with large limit" # Verify total pages match original_reader = PdfReader(sample_multipage_pdf) split_reader = PdfReader(split_files[0]) assert len(split_reader.pages) == len(original_reader.pages), "All pages should be in single file" # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) def test_split_pdf_file_not_found(self): """Test error handling when PDF file doesn't exist.""" with pytest.raises(FileNotFoundError): split_pdf_by_size("/nonexistent/file.pdf", 1000) def test_split_pdf_invalid_pdf(self): """Test error handling with invalid/corrupted PDF.""" # Create a file that's not a valid PDF with tempfile.NamedTemporaryFile(mode="w", suffix=".pdf", delete=False) as f: f.write("This is not a PDF file") invalid_pdf = f.name try: with pytest.raises(ValueError, match="Invalid or corrupted PDF"): split_pdf_by_size(invalid_pdf, 1000) finally: if os.path.exists(invalid_pdf): os.remove(invalid_pdf) def test_split_pdf_naming_convention(self, sample_multipage_pdf): """Test that split files follow expected naming convention.""" max_size = 5000 split_files = split_pdf_by_size(sample_multipage_pdf, max_size) # Verify naming pattern: basename_partN.pdf base_name = os.path.splitext(os.path.basename(sample_multipage_pdf))[0] for i, split_file in enumerate(split_files, start=1): filename = os.path.basename(split_file) assert filename.startswith(base_name), f"Filename should start with {base_name}" assert f"_part{i}.pdf" in filename, f"Filename should contain _part{i}.pdf" # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) def test_split_pdfs_are_valid_and_readable(self, sample_multipage_pdf): """Test that split PDFs are valid, complete PDFs that can be opened and read. This test verifies that PDF splitting is done at PAGE BOUNDARIES, not by byte position, ensuring no corrupted/broken PDFs are created. """ max_size = 5000 # Small size to force splitting split_files = split_pdf_by_size(sample_multipage_pdf, max_size) try: # Verify each split file is a valid, readable PDF for split_file in split_files: assert os.path.exists(split_file), f"Split file {split_file} should exist" # Try to open and read the PDF - this will fail if PDF is corrupted try: reader = PdfReader(split_file) # Verify it has pages (not an empty or broken PDF) assert len(reader.pages) > 0, f"Split PDF {split_file} should have pages" # Try to access first page content to ensure PDF structure is valid first_page = reader.pages[0] # If the PDF was corrupted by byte-splitting, this would raise an error _ = first_page.extract_text() # This validates PDF structure except Exception as e: pytest.fail( f"Split PDF {split_file} is corrupted or unreadable. " f"This indicates byte-level splitting instead of page-level splitting. Error: {e}" ) finally: # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) @pytest.mark.unit class TestShouldSplitFile: """Tests for the should_split_file function.""" def test_should_split_when_file_exceeds_limit(self, sample_multipage_pdf): """Test that function returns True when file exceeds limit.""" file_size = os.path.getsize(sample_multipage_pdf) max_size = file_size - 1 # Set limit just below file size result = should_split_file(sample_multipage_pdf, max_size) assert result is True, "Should return True when file exceeds limit" def test_should_not_split_when_file_under_limit(self, sample_multipage_pdf): """Test that function returns False when file is under limit.""" file_size = os.path.getsize(sample_multipage_pdf) max_size = file_size + 1000 # Set limit above file size result = should_split_file(sample_multipage_pdf, max_size) assert result is False, "Should return False when file is under limit" def test_should_not_split_when_limit_is_none(self, sample_multipage_pdf): """Test that function returns False when max_single_file_size is None.""" result = should_split_file(sample_multipage_pdf, None) assert result is False, "Should return False when limit is None (splitting disabled)" def test_should_not_split_when_file_not_exists(self): """Test that function returns False when file doesn't exist.""" result = should_split_file("/nonexistent/file.pdf", 1000) assert result is False, "Should return False when file doesn't exist" def test_should_not_split_exact_size(self, sample_multipage_pdf): """Test behavior when file size exactly matches limit.""" file_size = os.path.getsize(sample_multipage_pdf) result = should_split_file(sample_multipage_pdf, file_size) assert result is False, "Should return False when file size equals limit" @pytest.fixture def sample_empty_pdf(): """Create an empty PDF (0 pages) for testing.""" with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() # Don't add any pages - create empty PDF writer.write(f) pdf_path = f.name yield pdf_path # Cleanup if os.path.exists(pdf_path): os.remove(pdf_path) @pytest.fixture def sample_large_page_pdf(): """Create a PDF with pages that have more content to be larger.""" with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() # Add pages with larger dimensions to make them bigger for i in range(3): writer.add_blank_page(width=800, height=1200) writer.write(f) pdf_path = f.name yield pdf_path # Cleanup if os.path.exists(pdf_path): os.remove(pdf_path) @pytest.mark.unit class TestSplitPdfEdgeCases: """Additional edge case tests for split_pdf_by_size function.""" def test_split_pdf_empty_pages(self, sample_empty_pdf): """Test splitting an empty PDF with zero pages.""" max_size = 5000 split_files = split_pdf_by_size(sample_empty_pdf, max_size) # Empty PDF should return empty list assert len(split_files) == 0, "Empty PDF should return empty list" def test_split_pdf_single_page_exceeds_limit(self, sample_single_page_pdf): """Test when a single page exceeds the size limit (warning path).""" # Get actual file size and set limit below it to force single page to exceed file_size = os.path.getsize(sample_single_page_pdf) max_size = file_size - 500 # Set limit below single page size split_files = split_pdf_by_size(sample_single_page_pdf, max_size) # Should still create one file with warning assert len(split_files) >= 1, "Should create at least one file even if page exceeds limit" # Verify the file exists and has content for split_file in split_files: assert os.path.exists(split_file), f"Split file {split_file} should exist" reader = PdfReader(split_file) assert len(reader.pages) >= 1, "Split file should have pages" # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) def test_split_pdf_forces_multiple_chunks(self): """Test splitting with very small limit to force multiple chunks with page distribution. This specifically targets lines 101-117 where we save the previous chunk when adding a page would exceed the limit. """ # Create a PDF with enough pages to test the multi-chunk splitting logic with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() # Add 10 small pages - this will help us test the splitting logic for i in range(10): writer.add_blank_page(width=200, height=200) writer.write(f) pdf_path = f.name try: # Use a small size that will force multiple chunks # The key is to have a size that allows 2-3 pages per chunk max_size = 4000 # Small enough to force splitting split_files = split_pdf_by_size(pdf_path, max_size) # Should create at least one file, possibly more assert len(split_files) >= 1, "Should create at least one split file" # Verify all files exist and are valid total_pages = 0 for split_file in split_files: assert os.path.exists(split_file), f"Split file {split_file} should exist" reader = PdfReader(split_file) assert len(reader.pages) > 0, f"Split file {split_file} should have pages" total_pages += len(reader.pages) # Verify total pages match original original_reader = PdfReader(pdf_path) assert total_pages == len(original_reader.pages), "Total pages should match original" # Cleanup split files for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) finally: # Cleanup original if os.path.exists(pdf_path): os.remove(pdf_path) def test_split_pdf_previous_chunk_logic(self): """Test the specific logic for saving previous chunk when limit exceeded (lines 101-117). This test creates a scenario where: 1. We have multiple pages in the current writer 2. Adding the next page would exceed the limit 3. We need to save the previous chunk without the last page 4. Start a new chunk with the current page With blank 200x200 pages: ~431 bytes base + ~120 bytes per additional page - 1 page: ~431 bytes - 2 pages: ~551 bytes - 3 pages: ~671 bytes Setting max_size to 600 bytes should allow 2 pages (551 bytes) but not 3 pages (671 bytes). This will trigger the exceeds_limit && current_page_count > 1 path. """ # Create a multi-page PDF with small blank pages with tempfile.NamedTemporaryFile(mode="wb", suffix=".pdf", delete=False) as f: writer = PdfWriter() # Create 6 pages - enough to ensure we trigger multi-page chunk splitting for i in range(6): writer.add_blank_page(width=200, height=200) writer.write(f) pdf_path = f.name try: # Set max_size to allow 2 pages but not 3 pages # This will force the "save previous chunk" logic when the 3rd page would exceed max_size = 600 # Between 551 (2 pages) and 671 (3 pages) split_files = split_pdf_by_size(pdf_path, max_size) # Should create multiple files since 6 pages can't all fit assert len(split_files) >= 2, "Should create multiple split files" # Verify integrity - all pages accounted for original_reader = PdfReader(pdf_path) total_split_pages = sum(len(PdfReader(f).pages) for f in split_files) assert total_split_pages == len(original_reader.pages), "All pages should be preserved" # Verify each split file is valid and readable for split_file in split_files: reader = PdfReader(split_file) assert len(reader.pages) > 0, f"Split file {split_file} should have pages" # Verify we can read content from each page for page in reader.pages: _ = page.extract_text() # Should not raise # Cleanup split files for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) finally: if os.path.exists(pdf_path): os.remove(pdf_path) def test_split_pdf_final_chunk_coverage(self, sample_multipage_pdf): """Test that final chunk (lines 138-143) is properly covered.""" # Use moderate size limit to ensure we get a final chunk with remaining pages max_size = 8000 split_files = split_pdf_by_size(sample_multipage_pdf, max_size) # Should create at least one file assert len(split_files) >= 1, "Should create at least one output file" # Verify last file exists and has pages (exercises final chunk saving logic) last_file = split_files[-1] assert os.path.exists(last_file), "Last split file should exist" reader = PdfReader(last_file) assert len(reader.pages) > 0, "Last split file should have pages" # Cleanup for split_file in split_files: if os.path.exists(split_file): os.remove(split_file) @pytest.mark.unit class TestShouldSplitFileEdgeCases: """Additional edge cases for should_split_file function.""" def test_returns_false_when_max_size_is_none(self, tmp_path): """Test that splitting is disabled when max_size is None.""" from app.utils.file_splitting import should_split_file test_file = tmp_path / "test.pdf" test_file.write_bytes(b"a" * 10000) # 10KB file result = should_split_file(str(test_file), None) assert result is False def test_returns_false_for_nonexistent_file(self): """Test handling of nonexistent file.""" from app.utils.file_splitting import should_split_file result = should_split_file("/nonexistent/file.pdf", 1000) assert result is False def test_returns_true_when_file_exceeds_limit(self, tmp_path): """Test that splitting is enabled when file exceeds limit.""" from app.utils.file_splitting import should_split_file test_file = tmp_path / "large.pdf" test_file.write_bytes(b"a" * 10000) # 10KB file result = should_split_file(str(test_file), 5000) # 5KB limit assert result is True def test_returns_false_when_file_within_limit(self, tmp_path): """Test that splitting is disabled when file is within limit.""" from app.utils.file_splitting import should_split_file test_file = tmp_path / "small.pdf" test_file.write_bytes(b"a" * 1000) # 1KB file result = should_split_file(str(test_file), 5000) # 5KB limit assert result is False @pytest.mark.unit class TestSplitPdfBySizeEdgeCases: """Additional edge cases for split_pdf_by_size function.""" def test_raises_error_for_nonexistent_file(self): """Test that FileNotFoundError is raised for nonexistent file.""" from app.utils.file_splitting import split_pdf_by_size with pytest.raises(FileNotFoundError): split_pdf_by_size("/nonexistent/file.pdf", 1000000) def test_raises_error_for_invalid_pdf(self, tmp_path): """Test that ValueError is raised for invalid PDF.""" from app.utils.file_splitting import split_pdf_by_size invalid_file = tmp_path / "invalid.pdf" invalid_file.write_text("not a valid PDF") with pytest.raises(ValueError, match="Invalid or corrupted PDF"): split_pdf_by_size(str(invalid_file), 1000000) def test_returns_empty_list_for_zero_page_pdf(self, tmp_path): """Test handling of PDF with zero pages.""" from pypdf import PdfWriter from app.utils.file_splitting import split_pdf_by_size # Create a technically valid but empty PDF empty_pdf = tmp_path / "empty.pdf" writer = PdfWriter() with open(empty_pdf, "wb") as f: writer.write(f) result = split_pdf_by_size(str(empty_pdf), 1000000) assert result == [] def test_custom_output_directory(self, tmp_path, sample_pdf_path): """Test splitting with custom output directory.""" from app.utils.file_splitting import split_pdf_by_size output_dir = tmp_path / "custom_output" output_dir.mkdir() split_files = split_pdf_by_size(sample_pdf_path, 500, str(output_dir)) # All files should be in custom directory for file_path in split_files: assert str(output_dir) in file_path assert os.path.exists(file_path) # Cleanup for file_path in split_files: if os.path.exists(file_path): os.remove(file_path)