feat(downloads): auto-heal patreon campaign-ID failures via resolver retry
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""Integration tests for the Patreon campaign-ID retry hook in downloads.py.
|
||||
|
||||
These tests exercise _execute_download_with_retry — the pure async helper
|
||||
that owns the download → resolver → retry logic — without standing up a
|
||||
full Celery + DB environment.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tasks import downloads as downloads_module
|
||||
from app.tasks.downloads import _execute_download_with_retry
|
||||
|
||||
|
||||
def _make_gdl_service(results):
|
||||
"""Build a fake GalleryDLService whose .download() returns results in order."""
|
||||
service = SimpleNamespace()
|
||||
service.download = AsyncMock(side_effect=list(results))
|
||||
return service
|
||||
|
||||
|
||||
def _success(url="https://www.patreon.com/id:12345"):
|
||||
return SimpleNamespace(
|
||||
success=True, stdout="", stderr="",
|
||||
files_downloaded=3, url=url, error_type=None, error_message=None,
|
||||
)
|
||||
|
||||
|
||||
def _campaign_id_failure(url="https://www.patreon.com/mstsu"):
|
||||
return SimpleNamespace(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="[patreon][error] Failed to extract campaign ID",
|
||||
files_downloaded=0, url=url, error_type=None,
|
||||
error_message="Failed to extract campaign ID",
|
||||
)
|
||||
|
||||
|
||||
def _other_failure(url="https://www.patreon.com/mstsu"):
|
||||
return SimpleNamespace(
|
||||
success=False, stdout="", stderr="HTTP 401 Unauthorized",
|
||||
files_downloaded=0, url=url, error_type=None,
|
||||
error_message="Authentication failed",
|
||||
)
|
||||
|
||||
|
||||
async def test_happy_path_heal(monkeypatch):
|
||||
"""Patreon source fails with campaign-ID error, resolver returns ID, retry succeeds."""
|
||||
gdl = _make_gdl_service([_campaign_id_failure(), _success()])
|
||||
monkeypatch.setattr(
|
||||
downloads_module,
|
||||
"resolve_campaign_id",
|
||||
AsyncMock(return_value="12345"),
|
||||
)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/mstsu",
|
||||
source_metadata={},
|
||||
subscription_name="MSTSU",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path="/data/cookies/patreon_cookies.txt",
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result.success is True
|
||||
assert result.resolved_campaign_id == "12345"
|
||||
assert gdl.download.call_count == 2
|
||||
first_call_url = gdl.download.call_args_list[0].kwargs["url"]
|
||||
second_call_url = gdl.download.call_args_list[1].kwargs["url"]
|
||||
assert first_call_url == "https://www.patreon.com/mstsu"
|
||||
assert second_call_url == "https://www.patreon.com/id:12345"
|
||||
|
||||
|
||||
async def test_resolver_returns_none(monkeypatch):
|
||||
"""Campaign-ID failure + resolver returns None → no retry, no metadata, original failure surfaced."""
|
||||
first_failure = _campaign_id_failure()
|
||||
gdl = _make_gdl_service([first_failure])
|
||||
monkeypatch.setattr(
|
||||
downloads_module,
|
||||
"resolve_campaign_id",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/mstsu",
|
||||
source_metadata={},
|
||||
subscription_name="MSTSU",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path="/data/cookies/patreon_cookies.txt",
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result is first_failure
|
||||
assert result.resolved_campaign_id is None
|
||||
assert gdl.download.call_count == 1
|
||||
|
||||
|
||||
async def test_already_cached_campaign_id(monkeypatch):
|
||||
"""Source has cached campaign_id → first download uses id-URL, no resolver call."""
|
||||
gdl = _make_gdl_service([_success()])
|
||||
resolver_mock = AsyncMock(return_value="should-not-be-called")
|
||||
monkeypatch.setattr(downloads_module, "resolve_campaign_id", resolver_mock)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/mstsu",
|
||||
source_metadata={"patreon_campaign_id": "99999"},
|
||||
subscription_name="MSTSU",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path="/data/cookies/patreon_cookies.txt",
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result.success is True
|
||||
assert result.resolved_campaign_id is None
|
||||
assert gdl.download.call_count == 1
|
||||
assert gdl.download.call_args.kwargs["url"] == "https://www.patreon.com/id:99999"
|
||||
resolver_mock.assert_not_called()
|
||||
|
||||
|
||||
async def test_non_patreon_failure_skips_retry(monkeypatch):
|
||||
"""Non-Patreon source with similar-looking output → retry hook doesn't fire."""
|
||||
failure = SimpleNamespace(
|
||||
success=False,
|
||||
stdout="",
|
||||
stderr="some error that contains failed to extract campaign id text",
|
||||
files_downloaded=0, url="https://subscribestar.adult/foo",
|
||||
error_type=None, error_message="unknown",
|
||||
)
|
||||
gdl = _make_gdl_service([failure])
|
||||
resolver_mock = AsyncMock(return_value="should-not-be-called")
|
||||
monkeypatch.setattr(downloads_module, "resolve_campaign_id", resolver_mock)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="subscribestar",
|
||||
source_url="https://subscribestar.adult/foo",
|
||||
source_metadata={},
|
||||
subscription_name="Foo",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path=None,
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result is failure
|
||||
assert result.resolved_campaign_id is None
|
||||
resolver_mock.assert_not_called()
|
||||
assert gdl.download.call_count == 1
|
||||
|
||||
|
||||
async def test_patreon_different_failure_skips_retry(monkeypatch):
|
||||
"""Patreon source with non-campaign-ID error → no resolver, no retry."""
|
||||
failure = _other_failure()
|
||||
gdl = _make_gdl_service([failure])
|
||||
resolver_mock = AsyncMock(return_value="should-not-be-called")
|
||||
monkeypatch.setattr(downloads_module, "resolve_campaign_id", resolver_mock)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/mstsu",
|
||||
source_metadata={},
|
||||
subscription_name="MSTSU",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path="/data/cookies/patreon_cookies.txt",
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result is failure
|
||||
assert result.resolved_campaign_id is None
|
||||
resolver_mock.assert_not_called()
|
||||
assert gdl.download.call_count == 1
|
||||
|
||||
|
||||
async def test_unextractable_vanity_skips_retry(monkeypatch):
|
||||
"""Patreon source whose URL can't yield a vanity → no resolver call."""
|
||||
failure = _campaign_id_failure(url="https://www.patreon.com/")
|
||||
gdl = _make_gdl_service([failure])
|
||||
resolver_mock = AsyncMock(return_value="should-not-be-called")
|
||||
monkeypatch.setattr(downloads_module, "resolve_campaign_id", resolver_mock)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/",
|
||||
source_metadata={},
|
||||
subscription_name="?",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path=None,
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result is failure
|
||||
resolver_mock.assert_not_called()
|
||||
assert gdl.download.call_count == 1
|
||||
|
||||
|
||||
async def test_retry_itself_fails(monkeypatch):
|
||||
"""Resolver succeeds but retry fails → cache id, surface retry's failure."""
|
||||
first_failure = _campaign_id_failure()
|
||||
retry_failure = _other_failure()
|
||||
gdl = _make_gdl_service([first_failure, retry_failure])
|
||||
monkeypatch.setattr(
|
||||
downloads_module, "resolve_campaign_id", AsyncMock(return_value="12345")
|
||||
)
|
||||
|
||||
result = await _execute_download_with_retry(
|
||||
gdl_service=gdl,
|
||||
platform="patreon",
|
||||
source_url="https://www.patreon.com/mstsu",
|
||||
source_metadata={},
|
||||
subscription_name="MSTSU",
|
||||
source_config=SimpleNamespace(),
|
||||
cookies_path="/data/cookies/patreon_cookies.txt",
|
||||
auth_token=None,
|
||||
)
|
||||
|
||||
assert result.dl_result is retry_failure # retry's result wins
|
||||
assert result.resolved_campaign_id == "12345" # ID still cached
|
||||
assert gdl.download.call_count == 2
|
||||
Reference in New Issue
Block a user