Add delete-downloaded-video and real YouTube unsubscribe

Delete local copy:
- MeTubeClient.delete_download() -> POST /delete {ids, where: "done"}
- Only ever acts on a job our own app tracked (metube_job_id we stored from
  its own 'completed' event), never a pre-existing MeTube file
- New "deleted" terminal status; DELETE /api/videos/{id}/download
- Frontend: delete button next to "На сервере" badge, confirm dialog

Unsubscribe (deliberate deviation from the original TZ's MVP exclusion of
subscription management, per explicit user request after being shown the
tradeoff):
- OAuth scope widened from youtube.readonly to full youtube (read/write) --
  existing stored tokens only cover the old scope, so unsubscribing needs a
  fresh reconnect; reads keep working unchanged on the old token meanwhile
- channels.youtube_subscription_id (distinct from the channel id; that's
  what subscriptions.delete actually keys on) captured during subscriptions
  sync
- YouTubeInsufficientScope raised on 401/403 "insufficient authentication
  scopes" and surfaced as a clear 403 asking the user to reconnect, rather
  than a generic API error
- POST /api/channels/{id}/unsubscribe calls subscriptions.delete and marks
  the channel unsubscribed locally on success
- Frontend: "Отписаться" button on ChannelCard with confirm dialog

10 new backend tests (73 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-09-16 19:47:35 +00:00
parent 10c16ba2cb
commit 3089202316
18 changed files with 453 additions and 15 deletions

View file

@ -1,3 +1,5 @@
import logging
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
@ -8,6 +10,11 @@ from app.db import get_db
from app.models.category import Category
from app.models.channel import Channel
from app.models.channel_category import channel_categories
from app.services import sync
from app.services.google_oauth import OAuthNotConnected
from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(require_session)])
@ -104,3 +111,32 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
db.commit()
return _serialize(channel, sorted(unique_ids))
@router.post("/channels/{channel_id}/unsubscribe")
def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict:
channel = db.get(Channel, channel_id)
if channel is None:
raise HTTPException(status_code=404, detail="Channel not found")
try:
sync.unsubscribe_channel(db, channel)
except sync.ChannelHasNoSubscriptionId as exc:
raise HTTPException(status_code=400, detail=str(exc))
except OAuthNotConnected:
raise HTTPException(status_code=400, detail="Google account is not connected")
except YouTubeInsufficientScope:
raise HTTPException(
status_code=403,
detail="Недостаточно прав для отписки — переподключи YouTube, чтобы выдать разрешение на управление подписками",
)
except YouTubeQuotaExceeded:
raise HTTPException(status_code=503, detail="YouTube API quota exhausted")
except YouTubeAPIError as exc:
raise HTTPException(status_code=502, detail=f"YouTube API error: {exc}")
except Exception:
logger.exception("Failed to unsubscribe from channel %s", channel_id)
raise HTTPException(status_code=502, detail="YouTube is unavailable")
category_map = _category_ids_by_channel(db, [channel_id])
return _serialize(channel, category_map.get(channel_id, []))

View file

@ -8,7 +8,13 @@ from app.db import get_db
from app.models.channel import Channel
from app.models.download_job import DownloadJob
from app.models.video import Video
from app.services.download_jobs import MeTubeRejected, get_latest_job, request_download
from app.services.download_jobs import (
DeleteNotAllowed,
MeTubeRejected,
delete_local_copy,
get_latest_job,
request_download,
)
from app.services.metube_client import MeTubeClient
from app.services.video_presentation import channel_categories_map, serialize_video
@ -64,6 +70,21 @@ def download_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict
return _serialize_job(job)
@router.delete("/videos/{youtube_video_id}/download")
def delete_download(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)
try:
job = delete_local_copy(db, video)
except DeleteNotAllowed as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception:
logger.exception("Failed to delete local copy for %s", youtube_video_id)
raise HTTPException(status_code=502, detail="MeTube is unavailable")
return _serialize_job(job)
@router.get("/videos/{youtube_video_id}/download-status")
def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
video = _get_video_or_404(db, youtube_video_id)

View file

@ -11,6 +11,7 @@ class Channel(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
youtube_channel_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
youtube_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True)

View file

@ -5,11 +5,11 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
# queued -> downloading -> postprocessing -> completed
# queued -> downloading -> postprocessing -> completed -> deleted (by the user)
# -> failed
# unknown: state could not be reconciled after a restart
ACTIVE_STATUSES = ("queued", "downloading", "postprocessing")
TERMINAL_STATUSES = ("completed", "failed")
TERMINAL_STATUSES = ("completed", "failed", "deleted")
class DownloadJob(Base):

View file

@ -15,6 +15,10 @@ class MeTubeRejected(Exception):
pass
class DeleteNotAllowed(Exception):
pass
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
return (
db.query(DownloadJob)
@ -55,6 +59,24 @@ def request_download(db: Session, video: Video) -> DownloadJob:
return job
def delete_local_copy(db: Session, video: Video) -> DownloadJob:
"""Deletes our own previously-downloaded copy from MeTube. Only ever acts
on a job this app itself created and tracked (never a file MeTube already
had before we existed) -- see AGENTS.md constraint 10."""
job = get_latest_job(db, video.id)
if job is None or job.status != "completed":
raise DeleteNotAllowed("No completed local copy to delete")
if not job.metube_job_id:
raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion")
MeTubeClient().delete_download(job.metube_job_id)
job.status = "deleted"
job.media_url = None
db.commit()
return job
def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
metube_id = payload.get("id")
if metube_id:

View file

@ -14,7 +14,13 @@ from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
logger = logging.getLogger(__name__)
SCOPES = [
"https://www.googleapis.com/auth/youtube.readonly",
# Full read/write scope, not just youtube.readonly: unsubscribing from a
# channel (subscriptions.delete) requires write access. Deliberate
# deviation from the original "minimal scope" TZ recommendation, per
# explicit user request. Existing stored refresh tokens were granted
# under the old readonly-only scope and won't cover this -- users must
# reconnect once for this to take effect.
"https://www.googleapis.com/auth/youtube",
"openid",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",

View file

@ -77,6 +77,21 @@ class MeTubeClient:
response.raise_for_status()
return response.json()
def delete_download(self, metube_job_id: str) -> dict:
"""Asks MeTube to remove a finished download from its 'done' list and
delete the underlying file (actual file deletion additionally depends
on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't
control). Only ever called with a metube_job_id our own app tracked
from a download it started -- never touches files MeTube already had
before we existed."""
response = httpx.post(
f"{self.api_base_url}/delete",
json={"ids": [metube_job_id], "where": "done"},
timeout=self.timeout,
)
response.raise_for_status()
return response.json()
def build_media_url(self, filename: str) -> str | None:
"""Safely turn a MeTube-reported filename into a public /download/... URL.

View file

@ -78,6 +78,7 @@ def sync_subscriptions(db: Session) -> dict:
if channel is None:
channel = Channel(
youtube_channel_id=sub["youtube_channel_id"],
youtube_subscription_id=sub["youtube_subscription_id"],
title=sub["title"],
description=sub["description"],
thumbnail_url=sub["thumbnail_url"],
@ -88,6 +89,7 @@ def sync_subscriptions(db: Session) -> dict:
existing[sub["youtube_channel_id"]] = channel
added += 1
else:
channel.youtube_subscription_id = sub["youtube_subscription_id"]
channel.title = sub["title"]
channel.description = sub["description"]
channel.thumbnail_url = sub["thumbnail_url"]
@ -249,3 +251,21 @@ def sync_videos(db: Session) -> dict:
raise
finally:
_videos_lock.release()
class ChannelHasNoSubscriptionId(Exception):
pass
def unsubscribe_channel(db: Session, channel: Channel) -> None:
"""Actually unsubscribes on YouTube (subscriptions.delete) -- deliberately
out of the original MVP scope, added on explicit user request. Requires
the write-capable OAuth scope; see google_oauth.SCOPES."""
if not channel.youtube_subscription_id:
raise ChannelHasNoSubscriptionId(
"This channel was synced before subscription ids were tracked; run a subscriptions sync first"
)
credentials = google_oauth.get_credentials(db)
youtube_client.unsubscribe(credentials, channel.youtube_subscription_id)
channel.subscribed = False
db.commit()

View file

@ -19,12 +19,20 @@ class YouTubeAPIError(Exception):
pass
class YouTubeInsufficientScope(Exception):
"""Raised when Google rejects a call because the stored token was granted
under an older, narrower scope (e.g. readonly tokens issued before the
unsubscribe feature needed write access) -- the fix is reconnecting."""
pass
def _headers(credentials: Credentials) -> dict:
return {"Authorization": f"Bearer {credentials.token}"}
def _raise_for_status(response: httpx.Response) -> None:
if response.status_code == 200:
if response.status_code in (200, 204):
return
try:
payload = response.json()
@ -37,6 +45,11 @@ def _raise_for_status(response: httpx.Response) -> None:
if response.status_code == 403 and reason in ("quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded"):
raise YouTubeQuotaExceeded(message)
if response.status_code in (401, 403) and (
reason == "insufficientPermissions" or "insufficient authentication scopes" in message.lower()
):
raise YouTubeInsufficientScope(message)
logger.error("YouTube API error %s: %s", response.status_code, message)
raise YouTubeAPIError(f"{response.status_code}: {message}")
@ -72,6 +85,10 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
subscriptions.append(
{
"youtube_channel_id": channel_id,
# The subscription resource's own id -- distinct from
# the channel id, required to later call
# subscriptions.delete (unsubscribe).
"youtube_subscription_id": item.get("id"),
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"thumbnail_url": thumbnail,
@ -142,6 +159,19 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
return results
def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None:
with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client:
response = client.delete(
f"{API_BASE}/subscriptions",
params={"id": youtube_subscription_id},
headers=_headers(credentials),
)
if response.status_code == 404:
# Already gone (unsubscribed elsewhere, or stale id) -- treat as success.
return
_raise_for_status(response)
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]:
result: dict[str, str] = {}

View file

@ -127,6 +127,21 @@
color: #999;
}
.unsubscribe-button {
font-size: 12px;
padding: 2px 8px;
margin-left: 8px;
border: 1px solid #ccc;
border-radius: 10px;
background: transparent;
cursor: pointer;
}
.unsubscribe-error {
font-size: 12px;
margin-top: 4px;
}
.channel-categories {
display: flex;
flex-wrap: wrap;
@ -388,7 +403,8 @@
color: #a3221f;
}
.download-error {
.download-error,
.download-inline-actions {
display: inline-flex;
align-items: center;
gap: 6px;

View file

@ -81,6 +81,10 @@ export function setChannelCategories(channelId: number, categoryIds: number[]) {
})
}
export function unsubscribeChannel(channelId: number) {
return request<ChannelDto>(`/api/channels/${channelId}/unsubscribe`, { method: 'POST' })
}
export interface CategoryDto {
id: number
name: string
@ -182,6 +186,10 @@ export function downloadVideo(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' })
}
export function deleteDownload(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'DELETE' })
}
export function getDownloadStatus(youtubeVideoId: string) {
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download-status`)
}

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import type { CategoryDto, ChannelDto } from '../api/client'
import { setChannelCategories } from '../api/client'
import { setChannelCategories, unsubscribeChannel } from '../api/client'
interface Props {
channel: ChannelDto
@ -44,6 +44,13 @@ function ChannelCard({ channel, categories }: Props) {
},
})
const unsubscribeMutation = useMutation({
mutationFn: () => unsubscribeChannel(channel.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
})
function toggleCategory(categoryId: number) {
const current = new Set(channel.category_ids)
if (current.has(categoryId)) {
@ -64,6 +71,22 @@ function ChannelCard({ channel, categories }: Props) {
{channel.title}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>}
{channel.subscribed && (
<button
className="unsubscribe-button"
onClick={() => {
if (confirm(`Отписаться от канала "${channel.title}" на YouTube?`)) {
unsubscribeMutation.mutate()
}
}}
disabled={unsubscribeMutation.isPending}
>
Отписаться
</button>
)}
{unsubscribeMutation.isError && (
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
)}
<div className="channel-categories">
{assignedNames.map((name) => (

View file

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { FeedVideoDto } from '../api/client'
import { downloadVideo, getDownloadStatus } from '../api/client'
import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client'
const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing']
@ -40,6 +40,13 @@ function DownloadButton({ video }: Props) {
},
})
const deleteMutation = useMutation({
mutationFn: () => deleteDownload(video.youtube_video_id),
onSuccess: (data) => {
queryClient.setQueryData(['download-status', video.youtube_video_id], data)
},
})
const status = statusQuery.data?.status ?? video.local.status
const percent = statusQuery.data?.progress_percent ?? video.local.progress_percent
@ -47,7 +54,7 @@ function DownloadButton({ video }: Props) {
useEffect(() => {
if (prevStatus.current !== status) {
prevStatus.current = status
if (status === 'completed' || status === 'failed') {
if (status === 'completed' || status === 'failed' || status === 'deleted') {
queryClient.invalidateQueries({ queryKey: ['feed'] })
queryClient.invalidateQueries({ queryKey: ['video', video.youtube_video_id] })
}
@ -55,7 +62,21 @@ function DownloadButton({ video }: Props) {
}, [status, queryClient, video.youtube_video_id])
if (status === 'completed') {
return <span className="download-badge completed">✓ На сервере</span>
return (
<span className="download-inline-actions">
<span className="download-badge completed">✓ На сервере</span>
<button
onClick={() => {
if (confirm('Удалить скачанную копию с сервера?')) {
deleteMutation.mutate()
}
}}
disabled={deleteMutation.isPending}
>
Удалить
</button>
</span>
)
}
if (status === 'downloading') {

View file

@ -0,0 +1,24 @@
"""channels.youtube_subscription_id
Revision ID: 0006_channel_sub_id
Revises: 0005_download_jobs
Create Date: 2026-09-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0006_channel_sub_id"
down_revision: Union[str, None] = "0005_download_jobs"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("channels", sa.Column("youtube_subscription_id", sa.String(length=64), nullable=True))
def downgrade() -> None:
op.drop_column("channels", "youtube_subscription_id")

88
tests/test_channels.py Normal file
View file

@ -0,0 +1,88 @@
import pytest
from fastapi.testclient import TestClient
from app.core.auth_dependency import require_session
from app.db import get_db
from app.main import app
from app.models.channel import Channel
from app.services import sync
from app.services.youtube_client import YouTubeInsufficientScope
@pytest.fixture
def client(db_session):
def _get_db_override():
yield db_session
app.dependency_overrides[get_db] = _get_db_override
app.dependency_overrides[require_session] = lambda: None
# Deliberately not using `with TestClient(app)`: that runs the app's
# lifespan, which would try to reach the real MeTube instance and DB.
yield TestClient(app)
del app.dependency_overrides[get_db]
del app.dependency_overrides[require_session]
def _seed_channel(db_session, youtube_subscription_id="sub1"):
channel = Channel(
youtube_channel_id="chanA",
youtube_subscription_id=youtube_subscription_id,
title="Channel A",
subscribed=True,
)
db_session.add(channel)
db_session.commit()
db_session.refresh(channel)
return channel
def test_unsubscribe_success(client, db_session, monkeypatch):
channel = _seed_channel(db_session)
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
calls = {}
monkeypatch.setattr(
sync.youtube_client,
"unsubscribe",
lambda creds, sub_id: calls.setdefault("sub_id", sub_id),
)
resp = client.post(f"/api/channels/{channel.id}/unsubscribe")
assert resp.status_code == 200
assert resp.json()["subscribed"] is False
assert calls["sub_id"] == "sub1"
db_session.refresh(channel)
assert channel.subscribed is False
def test_unsubscribe_without_subscription_id_returns_400(client, db_session):
channel = _seed_channel(db_session, youtube_subscription_id=None)
resp = client.post(f"/api/channels/{channel.id}/unsubscribe")
assert resp.status_code == 400
def test_unsubscribe_insufficient_scope_returns_403(client, db_session, monkeypatch):
channel = _seed_channel(db_session)
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
def raise_insufficient(creds, sub_id):
raise YouTubeInsufficientScope("Request had insufficient authentication scopes.")
monkeypatch.setattr(sync.youtube_client, "unsubscribe", raise_insufficient)
resp = client.post(f"/api/channels/{channel.id}/unsubscribe")
assert resp.status_code == 403
db_session.refresh(channel)
assert channel.subscribed is True
def test_unsubscribe_channel_not_found(client):
resp = client.post("/api/channels/9999/unsubscribe")
assert resp.status_code == 404

View file

@ -13,8 +13,20 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
sync.youtube_client,
"fetch_subscriptions",
lambda creds: [
{"youtube_channel_id": "chanA", "title": "Channel A", "description": "d", "thumbnail_url": "t"},
{"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"},
{
"youtube_channel_id": "chanA",
"youtube_subscription_id": "subA",
"title": "Channel A",
"description": "d",
"thumbnail_url": "t",
},
{
"youtube_channel_id": "chanB",
"youtube_subscription_id": "subB",
"title": "Channel B",
"description": "d",
"thumbnail_url": "t",
},
],
)
monkeypatch.setattr(
@ -39,8 +51,20 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
sync.youtube_client,
"fetch_subscriptions",
lambda creds: [
{"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"},
{"youtube_channel_id": "chanC", "title": "Channel C", "description": "d", "thumbnail_url": "t"},
{
"youtube_channel_id": "chanB",
"youtube_subscription_id": "subB",
"title": "Channel B",
"description": "d",
"thumbnail_url": "t",
},
{
"youtube_channel_id": "chanC",
"youtube_subscription_id": "subC",
"title": "Channel C",
"description": "d",
"thumbnail_url": "t",
},
],
)

View file

@ -158,3 +158,40 @@ def test_recheck_local_keeps_completed_when_media_reachable(client, db_session,
assert resp.status_code == 200
assert resp.json()["status"] == "completed"
def test_delete_download_requires_completed_job(client, db_session):
video = _seed_single_video(db_session)
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download")
assert resp.status_code == 400
def test_delete_download_success(client, db_session, monkeypatch):
from app.models.download_job import DownloadJob
video = _seed_single_video(db_session)
job = DownloadJob(
video_id=video.id,
status="completed",
metube_job_id="vid1.vid1",
media_url="http://metube.local/download/f.mp4",
)
db_session.add(job)
db_session.commit()
calls = {}
monkeypatch.setattr(
"app.services.metube_client.MeTubeClient.delete_download",
lambda self, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"},
)
resp = client.delete(f"/api/videos/{video.youtube_video_id}/download")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
assert resp.json()["media_url"] is None
assert calls["id"] == "vid1.vid1"
db_session.refresh(job)
assert job.status == "deleted"
assert job.media_url is None

View file

@ -40,12 +40,13 @@ def test_fetch_subscriptions_paginates(monkeypatch):
{
"items": [
{
"id": "subA",
"snippet": {
"title": "Channel A",
"description": "desc a",
"resourceId": {"channelId": "chanA"},
"thumbnails": {"high": {"url": "http://thumb/a.jpg"}},
}
},
}
],
"nextPageToken": "page2",
@ -72,6 +73,7 @@ def test_fetch_subscriptions_paginates(monkeypatch):
result = youtube_client.fetch_subscriptions(_credentials())
assert [r["youtube_channel_id"] for r in result] == ["chanA", "chanB"]
assert result[0]["youtube_subscription_id"] == "subA"
assert result[0]["thumbnail_url"] == "http://thumb/a.jpg"
assert result[1]["thumbnail_url"] == "http://thumb/b.jpg"
@ -143,6 +145,50 @@ def test_fetch_videos_details(monkeypatch):
assert result[0]["thumbnail_url"] == "http://thumb/1.jpg"
def test_unsubscribe_success(monkeypatch):
response = FakeResponse(204, {})
seen = {}
class FakeDeleteClient(FakeClient):
def delete(self, url, params=None, headers=None):
seen["params"] = params
return self._responses.pop(0)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response]))
youtube_client.unsubscribe(_credentials(), "sub123")
assert seen["params"] == {"id": "sub123"}
def test_unsubscribe_already_gone_is_not_an_error(monkeypatch):
response = FakeResponse(404, {"error": {"message": "not found"}})
class FakeDeleteClient(FakeClient):
def delete(self, url, params=None, headers=None):
return self._responses.pop(0)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response]))
youtube_client.unsubscribe(_credentials(), "sub123") # must not raise
def test_unsubscribe_insufficient_scope(monkeypatch):
response = FakeResponse(
403,
{"error": {"message": "Request had insufficient authentication scopes.", "errors": [{"reason": "forbidden"}]}},
)
class FakeDeleteClient(FakeClient):
def delete(self, url, params=None, headers=None):
return self._responses.pop(0)
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response]))
with pytest.raises(youtube_client.YouTubeInsufficientScope):
youtube_client.unsubscribe(_credentials(), "sub123")
def test_fetch_uploads_playlists_batches(monkeypatch):
response = FakeResponse(
200,