"""Unit tests for GalleryDLService._categorize_error. Focus: making sure errors gallery-dl logs but recovers from (e.g., Patreon's "Failed to extract campaign ID" that falls back to /cw/) don't get misclassified as real failures when the overall run was just archive skips. """ from unittest.mock import patch from app.services.gallery_dl import ErrorType, GalleryDLService def _make_service(): """Build a service without touching the filesystem.""" with patch.object(GalleryDLService, "_load_base_config", return_value={}): return GalleryDLService(rate_limit=1.0) def test_patreon_campaign_id_error_with_skips_is_no_new_content(): """gallery-dl logged the extractor error but recovered + skipped all → no_new_content.""" service = _make_service() stderr = ( "[patreon][error] Failed to extract campaign ID\n" "[patreon][info] campaign_id: 548223\n" "[patreon][api] HEAD https://c10.patreonusercontent.com/4/.../img.jpg\n" ) stdout = ( "# /data/downloads/ElArteDeVero/patreon/2024-01-01_1_title/01_img.jpg\n" "# /data/downloads/ElArteDeVero/patreon/2024-01-02_2_title/01_img.jpg\n" ) error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.NO_NEW_CONTENT def test_patreon_campaign_id_error_without_skips_still_fails(): """Same error line but no skip activity → genuine failure, not masked.""" service = _make_service() stderr = "[patreon][error] Failed to extract campaign ID\n" stdout = "" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type != ErrorType.NO_NEW_CONTENT def test_patreon_campaign_id_error_alongside_real_error_still_fails(): """Campaign-ID error + a genuine auth error → auth error wins, not masked.""" service = _make_service() stderr = ( "[patreon][error] Failed to extract campaign ID\n" '[patreon][error] "GET /api/posts HTTP/1.1" 401 None\n' ) stdout = "# /data/downloads/Foo/patreon/post1/img.jpg\n" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.AUTH_ERROR def test_patreon_per_item_download_404_with_skips_is_no_new_content(): """Per-item 'Failed to download' + urllib3 404 HEAD + skips → no_new_content. Real-world trigger: a Patreon post's media URL (e.g., /media-u/v3/) expired or was deleted. gallery-dl logs [download][error] and moves on; other items skip cleanly. We shouldn't classify the whole source NOT_FOUND just because one attachment 404'd. """ service = _make_service() stderr = ( "[download][error] Failed to download 02_46004845.part\n" '[urllib3.connectionpool][debug] "HEAD /media-u/v3/46004845 HTTP/1.1" 404 0\n' "[downloader.http][warning] '404 OK' for 'https://www.patreon.com/media-u/v3/46004845'\n" "[download][error] Failed to download 03_46004845.part\n" "[patreon][debug] skipping https://c10.patreonusercontent.com/4/patreon-media/p/post/30953341/.../1.png (abc image_large)\n" ) stdout = "" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.NO_NEW_CONTENT def test_patreon_per_item_download_failure_without_skips_still_fails(): """Same 'Failed to download' error but no skip activity → real failure.""" service = _make_service() stderr = ( "[download][error] Failed to download 02_46004845.part\n" '[urllib3.connectionpool][debug] "HEAD /media-u/v3/46004845 HTTP/1.1" 404 0\n' ) stdout = "" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type != ErrorType.NO_NEW_CONTENT def test_patreon_tier_gated_warnings_classify_as_tier_limited(): """Many 'Not allowed to view post N' warnings + exit 4 + no downloads → TIER_LIMITED. Reflects a Patreon tier downgrade where older posts are now paywalled but we don't want to call this a failure.""" service = _make_service() stderr = "\n".join( f"[patreon][warning] Not allowed to view post {100000 + i}" for i in range(15) ) stdout = "" error_type, message = service._categorize_error(4, stdout, stderr) assert error_type == ErrorType.TIER_LIMITED assert "15" in message def test_tier_limited_with_exit_code_1_also_classifies(): """Mixed run: exit 1 with tier-gated warnings and no real errors.""" service = _make_service() stderr = "[patreon][warning] Not allowed to view post 999\n" stdout = "" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.TIER_LIMITED def test_tier_limited_not_applied_when_real_error_present(): """Tier warnings + a real auth error → auth error wins (TIER_LIMITED is only for 'everything else was fine' runs).""" service = _make_service() stderr = ( "[patreon][warning] Not allowed to view post 999\n" '[patreon][error] "GET /api/posts HTTP/1.1" 401 None\n' ) stdout = "" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.AUTH_ERROR def test_tier_limited_does_not_fire_on_exit_0(): """Successful run with some paywalled posts as warnings isn't an error at all — don't reach the TIER_LIMITED branch (exit 0 → success path earlier).""" service = _make_service() stderr = "[patreon][warning] Not allowed to view post 999\n" stdout = "/data/downloads/Artist/patreon/post1/img.jpg\n" error_type, _message = service._categorize_error(0, stdout, stderr) # Exit 0 + stdout activity should not hit the TIER_LIMITED branch assert error_type != ErrorType.TIER_LIMITED def test_skip_activity_takes_precedence_over_tier_limited(): """If there's also skip activity, NO_NEW_CONTENT wins — precedence matters because the tier_limited branch is the last informational fallback.""" service = _make_service() stderr = "[patreon][warning] Not allowed to view post 999\n" stdout = "# /data/downloads/Artist/patreon/post1/img.jpg\n" error_type, _message = service._categorize_error(1, stdout, stderr) assert error_type == ErrorType.NO_NEW_CONTENT def test_ytdl_missing_with_skips_classifies_as_no_new_content(): """Real-world scenario from a Patreon source: container is missing yt-dlp so every video logs '[downloader.ytdl][error] Cannot import yt-dlp' plus one [download][error] per video. With overwhelming skip activity, this should classify as NO_NEW_CONTENT (the setup gap is per-item — it shouldn't mask the dominant signal that the archive is up-to-date). Regression: previously the yt-dlp import error didn't match any per-item pattern, so has_actual_error stayed True and the run was misclassified as UNKNOWN_ERROR even though 1300+ files were skipped.""" service = _make_service() stderr = ( "[downloader.ytdl][error] Cannot import yt-dlp or youtube-dl\n" "[download][error] Failed to download 02_video.mp4\n" "[download][error] Failed to download 02_video.mp4\n" ) stdout = "# /data/downloads/Artist/patreon/post/01_img.jpg\n" * 100 error_type, _message = service._categorize_error(4, stdout, stderr) assert error_type == ErrorType.NO_NEW_CONTENT