from unittest.mock import MagicMock, patch import pytest from backend.app.services.patreon_resolver import ( resolve_campaign_id, resolve_campaign_id_for_source, resolve_display_name, ) def test_resolve_display_name_from_campaign(): fake = MagicMock() fake.status_code = 200 fake.json.return_value = {"data": [ {"id": "1", "type": "campaign", "attributes": {"name": "Maewix Studios"}}, ]} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake): assert resolve_display_name("maewix", None) == "Maewix Studios" def test_resolve_display_name_none_on_empty_or_error(): empty = MagicMock() empty.status_code = 200 empty.json.return_value = {"data": []} with patch("backend.app.services.patreon_resolver.requests.get", return_value=empty): assert resolve_display_name("maewix", None) is None import requests as _rq with patch( "backend.app.services.patreon_resolver.requests.get", side_effect=_rq.ConnectionError("x"), ): assert resolve_display_name("maewix", None) is None @pytest.mark.asyncio async def test_resolves_on_happy_path(): fake_response = MagicMock() fake_response.status_code = 200 fake_response.json.return_value = {"data": [{"id": "12345678", "type": "campaign"}]} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): result = await resolve_campaign_id("alice", cookies_path=None) assert result == "12345678" @pytest.mark.asyncio async def test_returns_none_on_401(): fake_response = MagicMock() fake_response.status_code = 401 fake_response.json.return_value = {} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): result = await resolve_campaign_id("alice", cookies_path=None) assert result is None @pytest.mark.asyncio async def test_returns_none_on_network_error(): import requests with patch( "backend.app.services.patreon_resolver.requests.get", side_effect=requests.ConnectionError("nope"), ): result = await resolve_campaign_id("alice", cookies_path=None) assert result is None @pytest.mark.asyncio async def test_returns_none_on_empty_data(): fake_response = MagicMock() fake_response.status_code = 200 fake_response.json.return_value = {"data": []} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): result = await resolve_campaign_id("alice", cookies_path=None) assert result is None @pytest.mark.asyncio async def test_returns_none_on_malformed_json(): fake_response = MagicMock() fake_response.status_code = 200 fake_response.json.side_effect = ValueError("not json") with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): result = await resolve_campaign_id("alice", cookies_path=None) assert result is None @pytest.mark.asyncio async def test_missing_cookies_file_does_not_raise(tmp_path): fake_response = MagicMock() fake_response.status_code = 200 fake_response.json.return_value = {"data": [{"id": "1", "type": "campaign"}]} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake_response): result = await resolve_campaign_id( "alice", cookies_path=str(tmp_path / "does-not-exist.txt") ) assert result == "1" @pytest.mark.asyncio async def test_loads_cookies_file_when_present(tmp_path): cookies = tmp_path / "cookies.txt" cookies.write_text( "# Netscape HTTP Cookie File\n" ".patreon.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc\n" ) fake_response = MagicMock() fake_response.status_code = 200 fake_response.json.return_value = {"data": [{"id": "9", "type": "campaign"}]} with patch( "backend.app.services.patreon_resolver.requests.get", return_value=fake_response, ) as mock_get: result = await resolve_campaign_id("alice", cookies_path=str(cookies)) assert result == "9" _, kwargs = mock_get.call_args assert kwargs.get("cookies") is not None @pytest.mark.asyncio async def test_falls_back_to_creator_page_when_api_empty(): """When the campaigns API returns empty data (its known failure mode), the resolver scrapes the campaign id out of the creator page (gallery-dl's method).""" api_resp = MagicMock() api_resp.status_code = 200 api_resp.json.return_value = {"data": []} page_resp = MagicMock() page_resp.status_code = 200 page_resp.text = ( '' ) def _get(url, **kwargs): return api_resp if "/api/campaigns" in url else page_resp with patch( "backend.app.services.patreon_resolver.requests.get", side_effect=_get ): result = await resolve_campaign_id("alice", cookies_path=None) assert result == "7654321" def test_scrape_campaign_id_patterns(): from backend.app.services.patreon_resolver import _scrape_campaign_id assert _scrape_campaign_id('foo /api/campaigns/424242 bar') == "424242" assert _scrape_campaign_id('"campaign_id":"99"') == "99" assert _scrape_campaign_id('{"id":"5","type":"campaign"}') == "5" assert _scrape_campaign_id("no id here") is None assert _scrape_campaign_id(None) is None # -- resolve_campaign_id_for_source (shared by download + verify) ---------- @pytest.mark.asyncio async def test_for_source_uses_cached_override_without_lookup(): with patch("backend.app.services.patreon_resolver.requests.get") as mock_get: cid, resolved = await resolve_campaign_id_for_source( "https://patreon.com/alice", None, {"patreon_campaign_id": "999"}, ) assert cid == "999" assert resolved is None # cached → no newly-resolved id to cache mock_get.assert_not_called() @pytest.mark.asyncio async def test_for_source_extracts_id_url_without_lookup(): with patch("backend.app.services.patreon_resolver.requests.get") as mock_get: cid, resolved = await resolve_campaign_id_for_source( "https://www.patreon.com/id:4242", None, {}, ) assert cid == "4242" assert resolved is None mock_get.assert_not_called() @pytest.mark.asyncio async def test_for_source_resolves_vanity_and_reports_it(): fake = MagicMock() fake.status_code = 200 fake.json.return_value = {"data": [{"id": "777", "type": "campaign"}]} with patch("backend.app.services.patreon_resolver.requests.get", return_value=fake): cid, resolved = await resolve_campaign_id_for_source( "https://patreon.com/alice", None, {}, ) assert cid == "777" assert resolved == "777" # newly resolved → caller caches it @pytest.mark.asyncio async def test_for_source_unresolvable_returns_none(): cid, resolved = await resolve_campaign_id_for_source("not-a-patreon-url", None, {}) assert cid is None assert resolved is None def test_extract_vanity_handles_all_path_prefixes(): """Patreon serves the vanity bare and under c/ and cw/ prefixes; all must yield the creator slug, not the prefix (operator-flagged 2026-06-07: a /cw/Atole source resolved vanity='cw').""" from backend.app.services.patreon_resolver import extract_vanity assert extract_vanity("https://www.patreon.com/Atole") == "Atole" assert extract_vanity("https://www.patreon.com/c/Atole") == "Atole" assert extract_vanity("https://www.patreon.com/cw/Atole") == "Atole" assert extract_vanity("https://patreon.com/cw/Atole") == "Atole" # id: URLs are NOT vanities (handled by the id path). assert extract_vanity("https://www.patreon.com/id:123") is None # /posts/ permalinks are NOT vanities (handled by the post path) — must not # masquerade as a creator slug named "posts". assert extract_vanity( "https://www.patreon.com/posts/mimic-in-dungeon-158372536" ) is None @pytest.mark.asyncio async def test_for_source_resolves_post_url_to_campaign(): """A /posts/- permalink resolves to the OWNING creator's campaign (a subscription walks the whole feed, not one post).""" post_resp = MagicMock() post_resp.status_code = 200 post_resp.json.return_value = { "data": { "id": "158372536", "type": "post", "relationships": {"campaign": {"data": {"id": "9988", "type": "campaign"}}}, } } calls = [] def _get(url, **kwargs): calls.append(url) return post_resp with patch("backend.app.services.patreon_resolver.requests.get", side_effect=_get): cid, resolved = await resolve_campaign_id_for_source( "https://www.patreon.com/posts/mimic-in-dungeon-158372536", None, {}, ) assert cid == "9988" assert resolved == "9988" # newly resolved → caller caches it assert calls and "/api/posts/158372536" in calls[0]