"""GalleryDLService tests — subprocess is mocked so no real gallery-dl runs.""" from types import SimpleNamespace import pytest from backend.app.services.gallery_dl import ( ErrorType, GalleryDLService, SourceConfig, ) @pytest.fixture def gdl(tmp_path): return GalleryDLService( images_root=tmp_path / "images", rate_limit=3.0, validate_files=False, ) def _proc(stdout="", stderr="", returncode=0): return SimpleNamespace(stdout=stdout, stderr=stderr, returncode=returncode) @pytest.mark.asyncio async def test_download_happy_path(gdl, monkeypatch): out_path = gdl.images_root / "alice" / "patreon" / "post1" / "img.jpg" out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 32 + b"\xff\xd9") monkeypatch.setattr( "backend.app.services.gallery_dl.subprocess.run", lambda *a, **k: _proc(stdout=str(out_path) + "\n", returncode=0), ) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(), ) assert result.success is True assert result.files_downloaded == 1 assert result.error_type is None assert str(out_path) in result.written_paths @pytest.mark.asyncio async def test_download_no_new_content(gdl, monkeypatch): monkeypatch.setattr( "backend.app.services.gallery_dl.subprocess.run", lambda *a, **k: _proc(stdout="# /images/alice/patreon/old.jpg\n", returncode=0), ) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(), ) assert result.success is True assert result.files_downloaded == 0 @pytest.mark.asyncio async def test_download_auth_error_categorization(gdl, monkeypatch): monkeypatch.setattr( "backend.app.services.gallery_dl.subprocess.run", lambda *a, **k: _proc( stdout="", stderr='[patreon][error] "GET /api/foo HTTP/1.1" 401 None\n', returncode=1, ), ) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(), ) assert result.success is False assert result.error_type == ErrorType.AUTH_ERROR @pytest.mark.asyncio async def test_download_timeout(gdl, monkeypatch): import subprocess as sp def _raise(*a, **k): raise sp.TimeoutExpired(cmd="gallery-dl", timeout=10) monkeypatch.setattr("backend.app.services.gallery_dl.subprocess.run", _raise) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(timeout=10), ) assert result.success is False assert result.error_type == ErrorType.TIMEOUT # Empty-partial timeout: no preserved output and zero files. assert result.stdout == "" assert result.stderr == "" assert result.files_downloaded == 0 @pytest.mark.asyncio async def test_download_timeout_preserves_partial_output_and_classifies(gdl, monkeypatch): """When gallery-dl is killed by timeout but had emitted partial output, the DownloadResult preserves stdout/stderr, counts files written so far, and promotes to RATE_LIMITED if the partial stderr shows a rate- limit pattern (so the platform cooldown kicks in). Without this, the operator only sees 'timed out' with no clue whether it was 'lots of content', 'stuck retrying', or 'hung silent'.""" import subprocess as sp # Simulate gallery-dl writing two files, then spinning on 429s. partial_stdout = ( "/tmp/images/alice/file_001.jpg\n" "/tmp/images/alice/file_002.jpg\n" ) partial_stderr = ( "[urllib3] 429 Too Many Requests; sleeping 60s\n" "[urllib3] 429 Too Many Requests; sleeping 60s\n" ) def _raise(*a, **k): raise sp.TimeoutExpired( cmd="gallery-dl", timeout=900, output=partial_stdout, stderr=partial_stderr, ) monkeypatch.setattr("backend.app.services.gallery_dl.subprocess.run", _raise) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(timeout=900), ) assert result.success is False # Partial output preserved on the DownloadResult — _phase3_persist # writes them into download_event.metadata so the UI can render them. assert result.stdout == partial_stdout assert result.stderr == partial_stderr # Files counted from partial stdout (lines starting with '/'). assert result.files_downloaded == 2 # Rate-limit pattern in partial stderr → promoted to RATE_LIMITED so # _update_source_health stamps the platform cooldown. assert result.error_type == ErrorType.RATE_LIMITED assert "Rate-limited" in result.error_message @pytest.mark.asyncio async def test_validation_quarantines_truncated_files(tmp_path, monkeypatch): gdl = GalleryDLService( images_root=tmp_path / "images", rate_limit=3.0, validate_files=True, ) bad = gdl.images_root / "alice" / "patreon" / "post" / "bad.jpg" bad.parent.mkdir(parents=True, exist_ok=True) bad.write_bytes(b"\xff\xd8\xff" + b"\x00" * 100) # SOI but no EOI monkeypatch.setattr( "backend.app.services.gallery_dl.subprocess.run", lambda *a, **k: _proc(stdout=str(bad) + "\n", returncode=0), ) result = await gdl.download( url="https://patreon.com/alice", artist_slug="alice", platform="patreon", source_config=SourceConfig(), ) assert result.files_quarantined == 1 assert len(result.quarantined_paths) == 1 assert not bad.exists() assert result.error_type == ErrorType.VALIDATION_FAILED def test_compute_run_stats(gdl): stats = gdl._compute_run_stats( return_code=0, stdout="/path/a.jpg\n/path/b.jpg\n# /path/c.jpg\n", stderr="[patreon][warning] foo\n[patreon][download][error] failed to download /x\n", ) assert stats["downloaded_count"] == 2 assert stats["skipped_count"] == 1 assert stats["warning_count"] >= 1 assert stats["per_item_failures"] == 1 def test_extract_errors_warnings(gdl): stderr = ( "[patreon][debug] foo\n[patreon][error] bar\n" "[urllib3][debug] baz\n[patreon][warning] qux\n" ) result = gdl._extract_errors_warnings(stderr) assert "[error]" in result assert "[warning]" in result assert "[debug]" not in result def test_truncate_log_caps_large_text(gdl): huge = "line\n" * 200_000 result = gdl._truncate_log(huge, max_bytes=10_000) assert len(result.encode()) < 11_000 assert "lines elided" in result def test_truncate_log_passes_short_text(gdl): text = "small\n" assert gdl._truncate_log(text) == text