"""Tests for Sonarr webhook integration""" import pytest import json from unittest.mock import Mock, AsyncMock, patch from datetime import datetime from app.models.sonarr import ( SonarrWebhookPayload, SonarrEventType, SonarrSeries, SonarrEpisode, SonarrConfig, SonarrMapping, SonarrDownloadRequest ) from app.sonarr_handler import SonarrHandler, get_sonarr_handler # ==================== FIXTURES ==================== @pytest.fixture def sample_sonarr_series(): """Sample Sonarr series data""" return { "tvdbId": 12345, "title": "Naruto Shippuden", "sortTitle": "naruto shippuden", "status": "continuing", "ended": False, "overview": "Test overview", "network": "TV Tokyo", "airTime": "19:00", "images": [], "seasons": [1, 2, 3], "year": 2007, "path": "/anime/naruto", "qualityProfileId": 1, "languageProfileId": 1, "seasonFolder": True, "monitored": True, "useSceneNumbering": False, "runtime": 24, "tvRageId": 123, "tvMazeId": 456, "firstAired": "2007-02-15T00:00:00Z", "seriesType": "standard", "cleanTitle": "narutoshippuden", "imdbId": "tt0988824", "titleSlug": "naruto-shippuden", "certification": "TV-14", "genres": ["Action", "Adventure"], "tags": [], "added": "2023-01-01T00:00:00Z", "ratings": {"votes": 100, "value": 8.5}, "id": 1 } @pytest.fixture def sample_sonarr_episode(): """Sample Sonarr episode data""" return { "seriesId": 12345, "episodeFileId": 1, "seasonNumber": 1, "episodeNumber": 1, "title": "Homecoming", "airDate": "2007-02-15", "airDateUtc": "2007-02-15T14:00:00Z", "overview": "Episode overview", "hasFile": True, "monitored": True, "absoluteEpisodeNumber": 1, "unverifiedSceneNumbering": False, "id": 1 } @pytest.fixture def sample_grab_payload(sample_sonarr_series, sample_sonarr_episode): """Sample Grab event payload""" return { "eventType": "Grab", "instanceName": "Sonarr", "applicationUrl": "http://localhost:8989", "series": sample_sonarr_series, "episodes": [sample_sonarr_episode], "release": { "indexer": "test-indexer", "releaseTitle": "Naruto Shippuden S01E01 test", "quality": { "quality": {"name": "1080p", "id": 7}, "revision": {"version": 1, "real": 0} } } } @pytest.fixture def sample_sonarr_config(): """Sample Sonarr configuration""" return SonarrConfig( webhook_enabled=True, webhook_secret="test-secret", auto_download_enabled=True, default_language="vostfr", default_quality="1080p", default_provider="anime-sama", verify_hmac=True, log_webhooks=True ) @pytest.fixture def temp_sonarr_handler(temp_dir): """Create SonarrHandler with temporary storage""" config_path = temp_dir / "sonarr_config.json" mappings_path = temp_dir / "sonarr_mappings.json" return SonarrHandler(str(config_path), str(mappings_path)) @pytest.fixture def sample_mapping(): """Sample Sonarr to anime mapping""" return SonarrMapping( sonarr_series_id=12345, sonarr_title="Naruto Shippuden", anime_provider="anime-sama", anime_url="https://anime-sama.si/catalogue/naruto-shippuden/saison1/vostfr/", anime_title="Naruto Shippuden", lang="vostfr", quality_preference="1080p", auto_download=True ) # ==================== MODEL TESTS ==================== class TestSonarrModels: """Test Sonarr Pydantic models""" def test_sonarr_config_validation(self): """Test SonarrConfig model validation""" config = SonarrConfig( webhook_enabled=True, webhook_secret="secret123", auto_download_enabled=True ) assert config.webhook_enabled is True assert config.webhook_secret == "secret123" assert config.auto_download_enabled is True def test_sonarr_mapping_validation(self): """Test SonarrMapping model validation""" mapping = SonarrMapping( sonarr_series_id=123, sonarr_title="Test Anime", anime_provider="anime-sama", anime_url="https://test.com/anime/", anime_title="Test Anime", lang="vostfr" ) assert mapping.sonarr_series_id == 123 assert mapping.anime_provider == "anime-sama" assert mapping.auto_download is True # Default value def test_sonarr_download_request_validation(self): """Test SonarrDownloadRequest model validation""" request = SonarrDownloadRequest( sonarr_series_id=123, sonarr_title="Test Anime", season_number=1, episode_number=5, quality="1080p", lang="vostfr", provider="anime-sama" ) assert request.season_number == 1 assert request.episode_number == 5 assert request.quality == "1080p" def test_grab_payload_validation(self, sample_grab_payload): """Test SonarrWebhookPayload validation for Grab event""" payload = SonarrWebhookPayload(**sample_grab_payload) assert payload.eventType == SonarrEventType.GRAB assert payload.series is not None assert payload.episodes is not None assert len(payload.episodes) == 1 assert payload.series.tvdbId == 12345 def test_test_payload_validation(self): """Test SonarrWebhookPayload validation for Test event""" payload_data = { "eventType": "Test", "instanceName": "Sonarr", "applicationUrl": "http://localhost:8989" } payload = SonarrWebhookPayload(**payload_data) assert payload.eventType == SonarrEventType.TEST # ==================== HANDLER TESTS ==================== class TestSonarrHandler: """Test SonarrHandler functionality""" def test_handler_initialization(self, temp_sonarr_handler): """Test SonarrHandler initialization""" assert temp_sonarr_handler.config is not None assert isinstance(temp_sonarr_handler.mappings, list) assert len(temp_sonarr_handler.mappings) == 0 def test_config_persistence(self, temp_sonarr_handler, sample_sonarr_config): """Test configuration save/load""" # Update config temp_sonarr_handler.update_config(sample_sonarr_config) # Create new handler instance to test persistence config_path = temp_sonarr_handler.config_path mappings_path = temp_sonarr_handler.mappings_path new_handler = SonarrHandler(str(config_path), str(mappings_path)) assert new_handler.config.webhook_enabled == sample_sonarr_config.webhook_enabled assert new_handler.config.webhook_secret == sample_sonarr_config.webhook_secret def test_add_mapping(self, temp_sonarr_handler, sample_mapping): """Test adding a new mapping""" result = temp_sonarr_handler.add_mapping(sample_mapping) assert len(temp_sonarr_handler.mappings) == 1 assert result.sonarr_series_id == sample_mapping.sonarr_series_id assert result.anime_title == sample_mapping.anime_title def test_get_mapping(self, temp_sonarr_handler, sample_mapping): """Test retrieving a specific mapping""" temp_sonarr_handler.add_mapping(sample_mapping) result = temp_sonarr_handler.get_mapping(12345) assert result is not None assert result.anime_title == "Naruto Shippuden" def test_get_nonexistent_mapping(self, temp_sonarr_handler): """Test retrieving a non-existent mapping""" result = temp_sonarr_handler.get_mapping(99999) assert result is None def test_delete_mapping(self, temp_sonarr_handler, sample_mapping): """Test deleting a mapping""" temp_sonarr_handler.add_mapping(sample_mapping) assert len(temp_sonarr_handler.mappings) == 1 success = temp_sonarr_handler.delete_mapping(12345) assert success is True assert len(temp_sonarr_handler.mappings) == 0 def test_delete_nonexistent_mapping(self, temp_sonarr_handler): """Test deleting a non-existent mapping""" success = temp_sonarr_handler.delete_mapping(99999) assert success is False def test_update_mapping(self, temp_sonarr_handler, sample_mapping): """Test updating an existing mapping""" temp_sonarr_handler.add_mapping(sample_mapping) # Update with same series ID updated_mapping = SonarrMapping( sonarr_series_id=12345, sonarr_title="Naruto Shippuden", anime_provider="neko-sama", anime_url="https://neko-sama.fr/anime/naruto-shippuden", anime_title="Naruto Shippuden (Updated)", lang="vf" ) result = temp_sonarr_handler.add_mapping(updated_mapping) assert len(temp_sonarr_handler.mappings) == 1 # Still only one assert result.anime_provider == "neko-sama" assert result.anime_title == "Naruto Shippuden (Updated)" def test_hmac_verification_valid(self, temp_sonarr_handler, sample_sonarr_config): """Test HMAC verification with valid signature""" import hmac import hashlib temp_sonarr_handler.update_config(sample_sonarr_config) # Create valid signature payload = b'{"test": "data"}' signature = hmac.new( sample_sonarr_config.webhook_secret.encode(), payload, hashlib.sha256 ).hexdigest() result = temp_sonarr_handler.verify_hmac(payload, f"sha256={signature}") assert result is True def test_hmac_verification_invalid(self, temp_sonarr_handler, sample_sonarr_config): """Test HMAC verification with invalid signature""" temp_sonarr_handler.update_config(sample_sonarr_config) payload = b'{"test": "data"}' result = temp_sonarr_handler.verify_hmac(payload, "sha256=invalid") assert result is False def test_hmac_verification_disabled(self, temp_sonarr_handler): """Test HMAC verification when disabled""" temp_sonarr_handler.config.verify_hmac = False payload = b'{"test": "data"}' result = temp_sonarr_handler.verify_hmac(payload, "invalid") assert result is True # Should pass when verification disabled def test_match_score_calculation(self, temp_sonarr_handler): """Test match score calculation""" # Exact match score1 = temp_sonarr_handler._calculate_match_score("Naruto", "Naruto") assert score1 == 1.0 # Partial match score2 = temp_sonarr_handler._calculate_match_score("Naruto Shippuden", "Naruto Shippuden") assert score2 == 1.0 # Contains score3 = temp_sonarr_handler._calculate_match_score("Naruto", "Naruto Shippuden") assert score3 > 0.5 # No match score4 = temp_sonarr_handler._calculate_match_score("One Piece", "Naruto") assert score4 == 0.0 # ==================== WEBHOOK PROCESSING TESTS ==================== class TestWebhookProcessing: """Test webhook processing""" @pytest.mark.asyncio async def test_process_grab_event_with_mapping( self, temp_sonarr_handler, sample_grab_payload, sample_mapping ): """Test processing Grab event with valid mapping""" temp_sonarr_handler.add_mapping(sample_mapping) temp_sonarr_handler.config.auto_download_enabled = True temp_sonarr_handler.config.webhook_enabled = True payload = SonarrWebhookPayload(**sample_grab_payload) result = await temp_sonarr_handler.process_webhook(payload) assert result["status"] == "processing" assert "mapping" in result assert result["mapping"] == "Naruto Shippuden" assert result["downloads_queued"] == 1 @pytest.mark.asyncio async def test_process_grab_event_without_mapping( self, temp_sonarr_handler, sample_grab_payload ): """Test processing Grab event without mapping""" temp_sonarr_handler.config.auto_download_enabled = True temp_sonarr_handler.config.webhook_enabled = True payload = SonarrWebhookPayload(**sample_grab_payload) result = await temp_sonarr_handler.process_webhook(payload) assert result["status"] == "no_mapping" assert "series" in result @pytest.mark.asyncio async def test_process_grab_event_auto_disabled( self, temp_sonarr_handler, sample_grab_payload, sample_mapping ): """Test processing Grab event when auto-download is disabled""" temp_sonarr_handler.add_mapping(sample_mapping) temp_sonarr_handler.config.auto_download_enabled = False temp_sonarr_handler.config.webhook_enabled = True payload = SonarrWebhookPayload(**sample_grab_payload) result = await temp_sonarr_handler.process_webhook(payload) assert result["status"] == "ignored" assert result["reason"] == "Auto-download disabled" @pytest.mark.asyncio async def test_process_grab_event_webhook_disabled( self, temp_sonarr_handler, sample_grab_payload ): """Test processing Grab event when webhook is disabled""" temp_sonarr_handler.config.webhook_enabled = False payload = SonarrWebhookPayload(**sample_grab_payload) result = await temp_sonarr_handler.process_webhook(payload) assert result["status"] == "ignored" assert result["reason"] == "Webhook not enabled" @pytest.mark.asyncio async def test_process_test_event(self, temp_sonarr_handler): """Test processing Test event""" temp_sonarr_handler.config.webhook_enabled = True payload_data = { "eventType": "Test", "instanceName": "Sonarr", "applicationUrl": "http://localhost:8989" } payload = SonarrWebhookPayload(**payload_data) result = await temp_sonarr_handler.process_webhook(payload) assert result["status"] == "ok" assert result["message"] == "Test webhook received" # ==================== UNIT TESTS ==================== @pytest.mark.unit class TestSonarrUtilities: """Test Sonarr utility functions""" def test_get_sonarr_handler_singleton(self): """Test that get_sonarr_handler returns singleton instance""" handler1 = get_sonarr_handler() handler2 = get_sonarr_handler() assert handler1 is handler2 # ==================== SECURITY UTILITIES TESTS ==================== @pytest.mark.unit class TestSecurityUtilities: """Test security utility functions""" def test_sanitize_filename_prevents_path_traversal(self): """Test that sanitize_filename prevents path traversal attacks""" from app.utils import sanitize_filename # Double dot attack - path separators replaced with underscores safe = sanitize_filename("../../../etc/passwd") assert "/" not in safe assert "\\" not in safe assert not safe.startswith("..") # No leading double dots # Absolute path attack - slashes replaced safe = sanitize_filename("/etc/passwd") assert "/" not in safe assert safe == "_etc_passwd" # Windows path attack safe = sanitize_filename("C:\\Windows\\System32\\config") assert "C:" not in safe assert "\\" not in safe assert "Windows" in safe def test_sanitize_filename_removes_dangerous_characters(self): """Test that dangerous characters are removed""" from app.utils import sanitize_filename dangerous = "video<>:file?|.mp4" safe = sanitize_filename(dangerous) assert "<" not in safe assert ">" not in safe assert ":" not in safe assert "?" not in safe assert "|" not in safe assert "." in safe # dots are ok in filename body assert "_" in safe # underscores used as replacement def test_sanitize_filename_limits_length(self): """Test that filename length is limited""" from app.utils import sanitize_filename # Create very long filename long_name = "a" * 300 safe = sanitize_filename(long_name) assert len(safe) <= 255 def test_sanitize_filename_handles_empty_string(self): """Test that empty filename becomes 'download'""" from app.utils import sanitize_filename assert sanitize_filename("") == "download" assert sanitize_filename(None) == "download" def test_is_safe_filename_rejects_traversal(self): """Test that is_safe_filename rejects path traversal attempts""" from app.utils import is_safe_filename assert is_safe_filename("../../../etc/passwd") is False assert is_safe_filename("../test") is False assert is_safe_filename("./test") is False def test_is_safe_filename_rejects_absolute_paths(self): """Test that is_safe_filename rejects absolute paths""" from app.utils import is_safe_filename assert is_safe_filename("/etc/passwd") is False assert is_safe_filename("\\etc\\passwd") is False assert is_safe_filename("C:\\Windows\\System32\\config") is False def test_is_safe_filename_accepts_valid_names(self): """Test that is_safe_filename accepts valid filenames""" from app.utils import is_safe_filename assert is_safe_filename("video.mp4") is True assert is_safe_filename("test_file.mkv") is True assert is_safe_filename("anime_episode_01.mp4") is True def test_sanitize_filename_preserves_extension(self): """Test that file extension is preserved""" from app.utils import sanitize_filename assert sanitize_filename("video.mp4").endswith(".mp4") assert sanitize_filename("test.mkv").endswith(".mkv") assert sanitize_filename("anime.avi").endswith(".avi")