Deep review of the debt burn: - `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a misconfigured client — while the grant stayed and the account still read as connected, with no connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now. - `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the user's pins first; the second waits and deals from the first's result. - Adding a whitelist entry committed the invite and the activation it implies separately, and its audit row was written before a new invite had an id (target NULL). One transaction, flushed for the id. - The mutation convention test skipped async routes and dicts returned by name: request-access, purge-discovery and the subscription sync answered without `ok`. They do now, and the test covers both. The helper-commit check looked only at `_`-names; it reads every non-route function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now allowlisted with their reasons.
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""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"),
|
|
# 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="<unparseable>", 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"),
|
|
],
|
|
)
|
|
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 —
|
|
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"
|