""" Tests for Stegasoo batch processing module (v4.0.0). Updated for v4.0.0: - Uses 'passphrase' instead of 'phrase' in credentials dict - No date_str parameter - BatchCredentials.passphrase is a single string """ import shutil import tempfile from pathlib import Path from unittest.mock import Mock import pytest from stegasoo.batch import ( BatchCredentials, BatchItem, BatchProcessor, BatchResult, BatchStatus, batch_capacity_check, print_batch_result, ) @pytest.fixture def temp_dir(): """Create a temporary directory for tests.""" path = Path(tempfile.mkdtemp()) yield path shutil.rmtree(path) @pytest.fixture def sample_images(temp_dir): """Create sample PNG images for testing.""" from PIL import Image images = [] for i in range(3): img_path = temp_dir / f"test_image_{i}.png" img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50)) img.save(img_path, "PNG") images.append(img_path) return images @pytest.fixture def sample_reference_photo(): """Create a sample reference photo as bytes.""" from io import BytesIO from PIL import Image img = Image.new("RGB", (100, 100), color=(128, 128, 128)) buf = BytesIO() img.save(buf, "PNG") return buf.getvalue() @pytest.fixture def sample_credentials(sample_reference_photo): """Create sample v3.2.0 credentials dict.""" return { "reference_photo": sample_reference_photo, "passphrase": "test phrase four words", # v3.2.0: single passphrase "pin": "123456", } class TestBatchItem: """Tests for BatchItem dataclass.""" def test_duration_calculation(self): """Duration should be calculated from start/end times.""" item = BatchItem(input_path=Path("test.png")) item.start_time = 100.0 item.end_time = 105.5 assert item.duration == 5.5 def test_duration_none_without_times(self): """Duration should be None if times not set.""" item = BatchItem(input_path=Path("test.png")) assert item.duration is None def test_to_dict(self): """to_dict should serialize all fields.""" item = BatchItem( input_path=Path("input.png"), output_path=Path("output.png"), status=BatchStatus.SUCCESS, message="Done", ) result = item.to_dict() assert result["input_path"] == "input.png" assert result["output_path"] == "output.png" assert result["status"] == "success" class TestBatchResult: """Tests for BatchResult dataclass.""" def test_to_json(self): """Should serialize to valid JSON.""" import json result = BatchResult(operation="encode", total=5, succeeded=4, failed=1) json_str = result.to_json() parsed = json.loads(json_str) assert parsed["operation"] == "encode" assert parsed["summary"]["total"] == 5 def test_duration_with_end_time(self): """Duration should work when end_time is set.""" result = BatchResult(operation="test") result.start_time = 100.0 result.end_time = 110.0 assert result.duration == 10.0 class TestBatchCredentials: """Tests for BatchCredentials dataclass (v3.2.0).""" def test_from_dict_new_format(self, sample_reference_photo): """Should parse v3.2.0 format with 'passphrase' key.""" data = { "reference_photo": sample_reference_photo, "passphrase": "test phrase four words", "pin": "123456", } creds = BatchCredentials.from_dict(data) assert creds.passphrase == "test phrase four words" assert creds.pin == "123456" def test_from_dict_legacy_format(self, sample_reference_photo): """Should parse legacy format with 'day_phrase' key for migration.""" data = { "reference_photo": sample_reference_photo, "day_phrase": "legacy phrase here", # Old key name "pin": "123456", } creds = BatchCredentials.from_dict(data) # Should accept old key and map to passphrase assert creds.passphrase == "legacy phrase here" assert creds.pin == "123456" def test_to_dict(self, sample_reference_photo): """Should serialize to v3.2.0 format.""" creds = BatchCredentials( reference_photo=sample_reference_photo, passphrase="test phrase four words", pin="123456", ) result = creds.to_dict() assert result["passphrase"] == "test phrase four words" assert result["pin"] == "123456" assert "day_phrase" not in result # Old key should not be present def test_passphrase_is_string(self, sample_reference_photo): """Passphrase should be a string, not a dict.""" creds = BatchCredentials( reference_photo=sample_reference_photo, passphrase="test phrase four words", pin="123456", ) assert isinstance(creds.passphrase, str) class TestBatchProcessor: """Tests for BatchProcessor class.""" def test_init_default_workers(self): """Should default to 4 workers.""" processor = BatchProcessor() assert processor.max_workers == 4 def test_init_custom_workers(self): """Should accept custom worker count.""" processor = BatchProcessor(max_workers=8) assert processor.max_workers == 8 def test_is_valid_image_png(self, temp_dir): """Should recognize PNG as valid.""" processor = BatchProcessor() png_path = temp_dir / "test.png" png_path.touch() assert processor._is_valid_image(png_path) def test_is_valid_image_txt(self, temp_dir): """Should reject non-image files.""" processor = BatchProcessor() txt_path = temp_dir / "test.txt" txt_path.touch() assert not processor._is_valid_image(txt_path) def test_find_images_file(self, sample_images): """Should find single image file.""" processor = BatchProcessor() results = list(processor.find_images([sample_images[0]])) assert len(results) == 1 assert results[0] == sample_images[0] def test_find_images_directory(self, sample_images, temp_dir): """Should find images in directory.""" processor = BatchProcessor() results = list(processor.find_images([temp_dir])) assert len(results) == 3 def test_find_images_recursive(self, temp_dir): """Should find images recursively.""" from PIL import Image # Create nested directory nested = temp_dir / "nested" nested.mkdir() img_path = nested / "nested.png" img = Image.new("RGB", (50, 50)) img.save(img_path) processor = BatchProcessor() results = list(processor.find_images([temp_dir], recursive=True)) assert any(p.name == "nested.png" for p in results) def test_batch_encode_requires_message_or_file(self, sample_images, sample_credentials): """Should raise if neither message nor file provided.""" processor = BatchProcessor() with pytest.raises(ValueError, match="message or file_payload"): processor.batch_encode( images=sample_images, credentials=sample_credentials, ) def test_batch_encode_requires_credentials(self, sample_images): """Should raise if credentials not provided.""" processor = BatchProcessor() with pytest.raises(ValueError, match="Credentials"): processor.batch_encode( images=sample_images, message="test", ) def test_batch_encode_accepts_passphrase_credentials( self, sample_images, temp_dir, sample_credentials ): """Should accept v3.2.0 format credentials with passphrase.""" processor = BatchProcessor() result = processor.batch_encode( images=sample_images, message="Test message", output_dir=temp_dir / "output", credentials=sample_credentials, # Uses 'passphrase' key ) assert isinstance(result, BatchResult) assert result.operation == "encode" assert result.total == 3 def test_batch_encode_creates_result(self, sample_images, temp_dir, sample_credentials): """Should return BatchResult with correct structure.""" processor = BatchProcessor() result = processor.batch_encode( images=sample_images, message="Test message", output_dir=temp_dir / "output", credentials=sample_credentials, ) assert isinstance(result, BatchResult) assert result.operation == "encode" assert result.total == 3 assert len(result.items) == 3 def test_batch_decode_requires_credentials(self, sample_images): """Should raise if credentials not provided.""" processor = BatchProcessor() with pytest.raises(ValueError, match="Credentials"): processor.batch_decode(images=sample_images) def test_batch_decode_accepts_passphrase_credentials(self, sample_images, sample_credentials): """Should accept v3.2.0 format credentials with passphrase.""" processor = BatchProcessor() result = processor.batch_decode( images=sample_images, credentials=sample_credentials, # Uses 'passphrase' key ) assert isinstance(result, BatchResult) assert result.operation == "decode" assert result.total == 3 def test_batch_decode_creates_result(self, sample_images, sample_credentials): """Should return BatchResult with correct structure.""" processor = BatchProcessor() result = processor.batch_decode( images=sample_images, credentials=sample_credentials, ) assert isinstance(result, BatchResult) assert result.operation == "decode" assert result.total == 3 def test_progress_callback_called(self, sample_images, sample_credentials): """Progress callback should be called for each item.""" processor = BatchProcessor() callback = Mock() processor.batch_encode( images=sample_images, message="Test", credentials=sample_credentials, progress_callback=callback, ) assert callback.call_count == 3 def test_custom_encode_func(self, sample_images, temp_dir, sample_credentials): """Should use custom encode function if provided.""" processor = BatchProcessor() encode_mock = Mock() processor.batch_encode( images=sample_images, message="Test", output_dir=temp_dir / "output", credentials=sample_credentials, encode_func=encode_mock, ) assert encode_mock.call_count == 3 class TestBatchCapacityCheck: """Tests for batch_capacity_check function.""" def test_returns_list(self, sample_images): """Should return list of results.""" results = batch_capacity_check(sample_images) assert isinstance(results, list) assert len(results) == 3 def test_includes_capacity(self, sample_images): """Results should include capacity info.""" results = batch_capacity_check(sample_images) for item in results: assert "capacity_bytes" in item assert "dimensions" in item assert "valid" in item def test_handles_invalid_files(self, temp_dir): """Should handle non-image files gracefully.""" bad_file = temp_dir / "not_an_image.png" bad_file.write_bytes(b"not a png") results = batch_capacity_check([bad_file]) assert len(results) == 1 assert "error" in results[0] class TestPrintBatchResult: """Tests for print_batch_result function.""" def test_prints_summary(self, capsys, sample_images): """Should print summary without errors.""" result = BatchResult( operation="encode", total=3, succeeded=2, failed=1, ) result.end_time = result.start_time + 5.0 print_batch_result(result) captured = capsys.readouterr() assert "ENCODE" in captured.out assert "3" in captured.out # total assert "2" in captured.out # succeeded def test_verbose_shows_items(self, capsys): """Verbose mode should show individual items.""" result = BatchResult(operation="decode", total=1, succeeded=1) result.items = [ BatchItem( input_path=Path("test.png"), status=BatchStatus.SUCCESS, message="Decoded successfully", ) ] result.end_time = result.start_time + 1.0 print_batch_result(result, verbose=True) captured = capsys.readouterr() assert "test.png" in captured.out class TestCredentialsMigration: """Tests for v3.1.x to v3.2.0 credentials migration.""" def test_old_phrase_key_accepted(self, sample_reference_photo): """Old 'phrase' key should be accepted for migration.""" old_format = { "reference_photo": sample_reference_photo, "phrase": "old style phrase", "pin": "123456", } # Should not raise creds = BatchCredentials.from_dict(old_format) assert creds.passphrase == "old style phrase" def test_old_day_phrase_key_accepted(self, sample_reference_photo): """Old 'day_phrase' key should be accepted for migration.""" old_format = { "reference_photo": sample_reference_photo, "day_phrase": "old day phrase", "pin": "123456", } creds = BatchCredentials.from_dict(old_format) assert creds.passphrase == "old day phrase" def test_new_passphrase_key_preferred(self, sample_reference_photo): """New 'passphrase' key should take precedence if both present.""" mixed_format = { "reference_photo": sample_reference_photo, "passphrase": "new style passphrase", "day_phrase": "old day phrase", "pin": "123456", } creds = BatchCredentials.from_dict(mixed_format) assert creds.passphrase == "new style passphrase"