"""An action YouTube refuses answers a 422 the UI can translate — never a raw 500 — and says what the user can do about it (`routes/_common.youtube_refusal`). The two manual syncs had no handler at all, so a quota or transport failure opened the blocking "Server error" dialog.""" from types import SimpleNamespace import httpx import pytest from app.auth import has_read_scope from app.models import OAuthToken from app.catalog.models import Channel from app.sync.explore import explore_ingest_page from app.sync.videos import backfill_channel_deep, backfill_channel_recent from app.youtube import client as yt from app.youtube.client import YouTubeError, refusal_reason @pytest.mark.parametrize( ("exc", "reason"), [ (YouTubeError("m", reason="quotaExceeded", path="playlists"), "quota"), (YouTubeError("m", reason="backendError,dailyLimitExceeded", path="videos"), "quota"), (YouTubeError("m", reason="invalid_grant", path="oauth2/token"), "reconnect"), # The token endpoint failing any other way leaves the grant alone — and a reconnect would # not help: Google's bad minute, a rate limit, a misconfigured client. (YouTubeError("m", reason="backend_error", path="oauth2/token"), "unavailable"), (YouTubeError("m", reason="", path="oauth2/token"), "unavailable"), (YouTubeError("m", reason="invalid_client", path="oauth2/token"), "unavailable"), (YouTubeError("No refresh token; user must re-authenticate"), "reconnect"), (YouTubeError("m", reason="transport"), "unavailable"), (YouTubeError("m", reason="backendError", path="subscriptions"), "unavailable"), # A missing playlist the user never named (a channel's uploads): nothing they can act on. (YouTubeError("m", reason="playlistNotFound", path="playlistItems"), "unavailable"), (YouTubeError("m", reason="emptyAnswer", path="playlistItems"), "unavailable"), ], ) def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason): assert refusal_reason(exc) == reason def test_a_missing_playlist_is_gone_only_on_a_route_about_that_playlist(): # A playlist deleted on YouTube: neither a retry nor a reconnect helps (it said "reconnect"). # Said only where the user picked that one list — anywhere else "this playlist no longer # exists" names nothing on their screen. exc = YouTubeError("m", reason="playlistNotFound", path="playlists") assert refusal_reason(exc, playlist=True) == "gone" assert refusal_reason(exc) == "unavailable" assert refusal_reason(YouTubeError("m", reason="quotaExceeded"), playlist=True) == "quota" def test_a_channel_without_uploads_explores_as_an_empty_page(): # YouTube answers a channel's uploads playlist as not found when the channel has no uploads. class _Yt: def get_playlist_items(self, *_): raise YouTubeError("m", reason="playlistNotFound", path="playlistItems") channel = SimpleNamespace(id="UCempty", uploads_playlist_id="UUempty") assert explore_ingest_page(None, _Yt(), channel) == {"ingested": 0, "next_page_token": None} class _NoUploads: """A client whose every uploads read answers "playlist not found" (a channel with no uploads).""" def __init__(self): self.calls = 0 def get_playlist_items(self, *_): self.calls += 1 raise YouTubeError("m", reason="playlistNotFound", path="playlistItems") def test_a_channel_without_uploads_backfills_as_done_and_is_not_charged_again(db): # The recent pass stamped nothing on this answer, so the channel was picked — and charged a # quota unit, with an ERROR traceback — on every backfill run, forever. ch = Channel(id="UCempty", title="Empty", uploads_playlist_id="UUempty") db.add(ch) db.commit() yt = _NoUploads() assert backfill_channel_recent(db, yt, ch) == 0 db.refresh(ch) assert ch.recent_synced_at is not None and ch.backfill_done and ch.backfill_cursor is None assert backfill_channel_deep(db, yt, ch) == 0 assert yt.calls == 1 # the deep pass has nothing left to ask def test_a_deep_backfill_whose_uploads_vanish_ends_instead_of_failing(db): ch = Channel(id="UCgone", title="Gone", uploads_playlist_id="UUgone", backfill_cursor="page2") db.add(ch) db.commit() assert backfill_channel_deep(db, _NoUploads(), ch) == 0 db.refresh(ch) assert ch.backfill_done and ch.backfill_cursor is None def test_any_other_backfill_failure_still_raises(db): class _Yt: def get_playlist_items(self, *_): raise YouTubeError("m", reason="quotaExceeded", path="playlistItems") ch = Channel(id="UCq", title="Q", uploads_playlist_id="UUq") db.add(ch) db.commit() with pytest.raises(YouTubeError): backfill_channel_recent(db, _Yt(), ch) def test_any_other_explore_failure_still_raises(): class _Yt: def get_playlist_items(self, *_): raise YouTubeError("m", reason="quotaExceeded", path="playlistItems") channel = SimpleNamespace(id="UCx", uploads_playlist_id="UUx") with pytest.raises(YouTubeError): explore_ingest_page(None, _Yt(), channel) @pytest.mark.parametrize( ("route", "target"), [ ("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"), ("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"), ], ) def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500( db, as_user, make_user, monkeypatch, route, target ): user = make_user() db.commit() module, name = target.rsplit(".", 1) def refused(*a, **k): raise YouTubeError("GET subscriptions -> 403: quotaExceeded", reason="quotaExceeded") monkeypatch.setattr(f"{module}.has_read_scope", lambda u: True) monkeypatch.setattr(f"{module}.{name}", refused) r = as_user(user).post(route) assert r.status_code == 422 assert r.json()["detail"] == {"code": "youtube", "reason": "quota"} def test_a_revoked_grant_is_forgotten_so_the_account_can_reconnect(db, make_user, monkeypatch): """Google's `invalid_grant` is final. Kept, the grant read as live in Settings — with no connect button — while every call failed; cleared, the connect button comes back.""" user = make_user() db.add( OAuthToken( user_id=user.id, refresh_token_enc="x", scopes="openid https://www.googleapis.com/auth/youtube.readonly", ) ) db.commit() db.refresh(user) assert has_read_scope(user) resp = httpx.Response( 400, json={"error": "invalid_grant", "error_description": "Token has been expired or revoked."}, request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL), ) c = yt.YouTubeClient.__new__(yt.YouTubeClient) # no HTTP client: `_send` is the stub c.db, c.user = db, user monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token") monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "") monkeypatch.setattr(c, "_send", lambda *a, **k: resp) with pytest.raises(YouTubeError) as caught: c._access_token() assert refusal_reason(caught.value) == "reconnect" db.expire_all() assert not has_read_scope(db.get(type(user), user.id)) def test_any_other_token_refusal_keeps_the_grant(monkeypatch): """Only `invalid_grant` is final: a 5xx from the token endpoint is Google having a bad minute — and the user is told to try again, not to reconnect an account that still reads as connected.""" resp = httpx.Response( 503, json={"error": "backend_error"}, request=httpx.Request("POST", yt.GOOGLE_TOKEN_URL) ) tok = SimpleNamespace(access_token=None, expiry=None, refresh_token_enc="x", scopes="s") c = yt.YouTubeClient.__new__(yt.YouTubeClient) c.db = SimpleNamespace(add=lambda o: None, commit=lambda: None) c.user = SimpleNamespace(id=1, token=tok) monkeypatch.setattr(yt, "decrypt_optional", lambda value: None) monkeypatch.setattr(yt, "decrypt", lambda value: "a-refresh-token") monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "") monkeypatch.setattr(c, "_send", lambda *a, **k: resp) with pytest.raises(YouTubeError) as caught: c._access_token() assert tok.scopes == "s" assert refusal_reason(caught.value) == "unavailable" @pytest.mark.parametrize( ("failure", "sent"), [ (httpx.ConnectError("refused"), False), (httpx.ConnectTimeout("slow connect"), False), (httpx.ProxyError("proxy down"), False), (httpx.ReadTimeout("no answer"), True), (httpx.RemoteProtocolError("dropped"), True), ], ) def test_a_transport_failure_says_whether_the_request_went_out(failure, sent): """A search that never reached YouTube spent nothing and gets its slot back; one whose answer never came may have been counted, and keeps it (`routes/search`).""" def request(*_a, **_k): raise failure c = yt.YouTubeClient.__new__(yt.YouTubeClient) c._http = SimpleNamespace(request=request) with pytest.raises(YouTubeError) as caught: c._send("GET", "https://example.invalid") assert caught.value.reason == "transport" assert caught.value.sent is sent def test_a_call_youtube_answered_is_spent_a_refused_one_too(monkeypatch): resp = httpx.Response( 400, json={"error": {"errors": [{"reason": "invalidPageToken"}]}}, request=httpx.Request("GET", f"{yt.API_BASE}/search"), ) recorded: list[int] = [] c = yt.YouTubeClient.__new__(yt.YouTubeClient) c.db, c.spent = None, 0 monkeypatch.setattr(yt.sysconfig, "get_str", lambda d, key: "a-key") monkeypatch.setattr(yt.quota, "record_usage", recorded.append) monkeypatch.setattr(c, "_send", lambda *a, **k: resp) with pytest.raises(YouTubeError): c._get("search", {}, cost=100) assert (recorded, c.spent) == ([100], 100)