fix(youtube): a refused YouTube action answers a 422 that says what to do
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".
One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.
"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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`). E9 F1: 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.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"),
|
||||
(YouTubeError("No refresh token; user must re-authenticate"), "reconnect"),
|
||||
(YouTubeError("m", reason="transport"), "unavailable"),
|
||||
(YouTubeError("m", reason="backendError", path="subscriptions"), "unavailable"),
|
||||
],
|
||||
)
|
||||
def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
|
||||
assert refusal_reason(exc) == reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("route", "target"),
|
||||
[
|
||||
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
||||
("/api/playlists/sync-youtube", "app.routes.playlists.sync_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."""
|
||||
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):
|
||||
c._access_token()
|
||||
assert tok.scopes == "s"
|
||||
Reference in New Issue
Block a user