Implement Phases 1-5: skeleton, OAuth, categories, video sync/feed, playback
- FastAPI + PostgreSQL + Alembic + React/Vite skeleton, Docker Compose, healthcheck - Google OAuth (single allowed account), encrypted refresh token storage - Subscriptions sync with pagination, uploads playlist batch fetch - Categories CRUD, many-to-many channel assignment, category filtering - Video sync (playlistItems + videos.list batching), cached feed with cursor pagination, background scheduler (APScheduler) - Video detail page with YouTube embed player - SPA fallback routing, optimistic UI updates, client-side query caching 40 backend tests covering OAuth allow-list, sync idempotency, cascade deletes, cursor pagination, and category filtering. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
0ed20bb838
90 changed files with 8545 additions and 0 deletions
39
tests/conftest.py
Normal file
39
tests/conftest.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import os
|
||||
|
||||
os.environ.setdefault("APP_BASE_URL", "http://testserver")
|
||||
os.environ.setdefault("APP_SECRET_KEY", "test-secret")
|
||||
os.environ.setdefault("TOKEN_ENCRYPTION_KEY", "y-KaYJS2O-CziGFYfjtMObHX73xMI9FwFipMnFpHKAY=")
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test")
|
||||
os.environ.setdefault("ALLOWED_GOOGLE_EMAIL", "owner@example.com")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base
|
||||
from app import models # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
engine = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_fk(dbapi_connection, _):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
session = session_factory()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
engine.dispose()
|
||||
79
tests/test_auth.py
Normal file
79
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import auth as auth_module
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.services import google_oauth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, db_session):
|
||||
def _get_db_override():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _get_db_override
|
||||
monkeypatch.setattr(auth_module, "_run_initial_sync", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
google_oauth, "build_authorization_url", lambda: ("https://accounts.google.com/fake", "fixed-state")
|
||||
)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
del app.dependency_overrides[get_db]
|
||||
|
||||
|
||||
class FakeCredentials:
|
||||
token = "fake-access-token"
|
||||
refresh_token = "fake-refresh-token"
|
||||
expiry = None
|
||||
|
||||
|
||||
def _start_oauth(client):
|
||||
response = client.get("/api/auth/google/start", follow_redirects=False)
|
||||
assert response.status_code in (302, 307)
|
||||
return "fixed-state"
|
||||
|
||||
|
||||
def test_callback_rejects_disallowed_email(client, monkeypatch):
|
||||
state = _start_oauth(client)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials())
|
||||
monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "stranger@example.com"})
|
||||
revoked = {}
|
||||
monkeypatch.setattr(google_oauth, "revoke_token", lambda token: revoked.setdefault("called", True))
|
||||
|
||||
response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 307)
|
||||
assert "account_not_allowed" in response.headers["location"]
|
||||
assert revoked.get("called") is True
|
||||
|
||||
status = client.get("/api/auth/status").json()
|
||||
assert status["authenticated"] is False
|
||||
assert status["connected"] is False
|
||||
|
||||
|
||||
def test_callback_accepts_allowed_email(client, monkeypatch):
|
||||
state = _start_oauth(client)
|
||||
|
||||
monkeypatch.setattr(google_oauth, "exchange_code", lambda code, state: FakeCredentials())
|
||||
monkeypatch.setattr(google_oauth, "fetch_userinfo", lambda token: {"email": "owner@example.com"})
|
||||
|
||||
response = client.get(f"/api/auth/google/callback?code=abc&state={state}", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 307)
|
||||
assert "auth_error" not in response.headers["location"]
|
||||
|
||||
status = client.get("/api/auth/status").json()
|
||||
assert status["authenticated"] is True
|
||||
assert status["connected"] is True
|
||||
assert status["email"] == "owner@example.com"
|
||||
|
||||
|
||||
def test_callback_rejects_invalid_state(client, monkeypatch):
|
||||
_start_oauth(client)
|
||||
|
||||
response = client.get("/api/auth/google/callback?code=abc&state=wrong-state", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 307)
|
||||
assert "invalid_state" in response.headers["location"]
|
||||
119
tests/test_categories.py
Normal file
119
tests/test_categories.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
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
|
||||
|
||||
|
||||
@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
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
del app.dependency_overrides[get_db]
|
||||
del app.dependency_overrides[require_session]
|
||||
|
||||
|
||||
def _create_channel(db_session, youtube_channel_id="chanA", title="Channel A"):
|
||||
channel = Channel(youtube_channel_id=youtube_channel_id, title=title, subscribed=True)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
db_session.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
def test_category_crud(client):
|
||||
created = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
assert created["name"] == "Linux"
|
||||
assert created["slug"] == "linux"
|
||||
assert created["channel_count"] == 0
|
||||
|
||||
listed = client.get("/api/categories").json()
|
||||
assert len(listed) == 1
|
||||
|
||||
updated = client.patch(f"/api/categories/{created['id']}", json={"name": "Linux/BSD"}).json()
|
||||
assert updated["name"] == "Linux/BSD"
|
||||
|
||||
resp = client.delete(f"/api/categories/{created['id']}")
|
||||
assert resp.status_code == 204
|
||||
assert client.get("/api/categories").json() == []
|
||||
|
||||
|
||||
def test_category_name_must_be_unique(client):
|
||||
client.post("/api/categories", json={"name": "Шахматы"})
|
||||
resp = client.post("/api/categories", json={"name": "шахматы"})
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_cyrillic_slug_transliterated(client):
|
||||
created = client.post("/api/categories", json={"name": "Научпоп"}).json()
|
||||
assert created["slug"] == "nauchpop"
|
||||
|
||||
|
||||
def test_delete_category_does_not_delete_channel(client, db_session):
|
||||
channel = _create_channel(db_session)
|
||||
category = client.post("/api/categories", json={"name": "IT"}).json()
|
||||
|
||||
client.put(f"/api/channels/{channel.id}/categories", json={"category_ids": [category["id"]]})
|
||||
client.delete(f"/api/categories/{category['id']}")
|
||||
|
||||
resp = client.get(f"/api/channels/{channel.id}").json()
|
||||
assert resp["id"] == channel.id
|
||||
assert resp["category_ids"] == []
|
||||
|
||||
|
||||
def test_channel_can_belong_to_multiple_categories(client, db_session):
|
||||
channel = _create_channel(db_session)
|
||||
cat1 = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
cat2 = client.post("/api/categories", json={"name": "IT"}).json()
|
||||
|
||||
resp = client.put(
|
||||
f"/api/channels/{channel.id}/categories",
|
||||
json={"category_ids": [cat1["id"], cat2["id"]]},
|
||||
).json()
|
||||
|
||||
assert sorted(resp["category_ids"]) == sorted([cat1["id"], cat2["id"]])
|
||||
|
||||
|
||||
def test_assign_unknown_category_rejected(client, db_session):
|
||||
channel = _create_channel(db_session)
|
||||
resp = client.put(f"/api/channels/{channel.id}/categories", json={"category_ids": [9999]})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_channels_filter_by_category_and_uncategorized(client, db_session):
|
||||
channel_a = _create_channel(db_session, "chanA", "Channel A")
|
||||
channel_b = _create_channel(db_session, "chanB", "Channel B")
|
||||
category = client.post("/api/categories", json={"name": "Linux"}).json()
|
||||
client.put(f"/api/channels/{channel_a.id}/categories", json={"category_ids": [category["id"]]})
|
||||
|
||||
in_category = client.get(f"/api/channels?category_id={category['id']}").json()
|
||||
assert [c["id"] for c in in_category] == [channel_a.id]
|
||||
|
||||
uncategorized = client.get("/api/channels?uncategorized=true").json()
|
||||
assert [c["id"] for c in uncategorized] == [channel_b.id]
|
||||
|
||||
|
||||
def test_reorder_categories(client):
|
||||
cat1 = client.post("/api/categories", json={"name": "A"}).json()
|
||||
cat2 = client.post("/api/categories", json={"name": "B"}).json()
|
||||
cat3 = client.post("/api/categories", json={"name": "C"}).json()
|
||||
|
||||
reordered = client.post(
|
||||
"/api/categories/reorder",
|
||||
json={"category_ids": [cat3["id"], cat1["id"], cat2["id"]]},
|
||||
).json()
|
||||
|
||||
assert [c["id"] for c in reordered] == [cat3["id"], cat1["id"], cat2["id"]]
|
||||
|
||||
|
||||
def test_reorder_rejects_mismatched_ids(client):
|
||||
client.post("/api/categories", json={"name": "A"})
|
||||
resp = client.post("/api/categories/reorder", json={"category_ids": [9999]})
|
||||
assert resp.status_code == 400
|
||||
20
tests/test_duration.py
Normal file
20
tests/test_duration.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import pytest
|
||||
|
||||
from app.core.duration import parse_iso8601_duration
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
("PT18M42S", 18 * 60 + 42),
|
||||
("PT1H2M3S", 3600 + 120 + 3),
|
||||
("PT45S", 45),
|
||||
("PT5M", 300),
|
||||
("P1DT2H", 86400 + 7200),
|
||||
(None, None),
|
||||
("", None),
|
||||
("garbage", None),
|
||||
],
|
||||
)
|
||||
def test_parse_iso8601_duration(value, expected):
|
||||
assert parse_iso8601_duration(value) == expected
|
||||
125
tests/test_feed.py
Normal file
125
tests/test_feed.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.video import Video
|
||||
|
||||
|
||||
@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
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
del app.dependency_overrides[get_db]
|
||||
del app.dependency_overrides[require_session]
|
||||
|
||||
|
||||
def _seed(db_session):
|
||||
channel_a = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
|
||||
channel_b = Channel(youtube_channel_id="chanB", title="Channel B", subscribed=True)
|
||||
db_session.add_all([channel_a, channel_b])
|
||||
db_session.commit()
|
||||
|
||||
category = Category(name="Linux", slug="linux", sort_order=0)
|
||||
db_session.add(category)
|
||||
db_session.commit()
|
||||
|
||||
db_session.execute(channel_categories.insert().values(channel_id=channel_a.id, category_id=category.id))
|
||||
db_session.commit()
|
||||
|
||||
base = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
||||
videos = []
|
||||
for i in range(5):
|
||||
video = Video(
|
||||
youtube_video_id=f"vid{i}",
|
||||
channel_id=channel_a.id if i % 2 == 0 else channel_b.id,
|
||||
title=f"Video {i}",
|
||||
published_at=base + timedelta(hours=i),
|
||||
youtube_url=f"https://www.youtube.com/watch?v=vid{i}",
|
||||
)
|
||||
videos.append(video)
|
||||
db_session.add_all(videos)
|
||||
db_session.commit()
|
||||
|
||||
return channel_a, channel_b, category, videos
|
||||
|
||||
|
||||
def test_feed_orders_by_published_at_desc(client, db_session):
|
||||
_seed(db_session)
|
||||
|
||||
resp = client.get("/api/feed").json()
|
||||
|
||||
ids = [item["youtube_video_id"] for item in resp["items"]]
|
||||
assert ids == ["vid4", "vid3", "vid2", "vid1", "vid0"]
|
||||
assert resp["next_cursor"] is None
|
||||
|
||||
|
||||
def test_feed_pagination_cursor(client, db_session):
|
||||
_seed(db_session)
|
||||
|
||||
page1 = client.get("/api/feed?limit=2").json()
|
||||
assert [i["youtube_video_id"] for i in page1["items"]] == ["vid4", "vid3"]
|
||||
assert page1["next_cursor"] is not None
|
||||
|
||||
page2 = client.get(f"/api/feed?limit=2&cursor={page1['next_cursor']}").json()
|
||||
assert [i["youtube_video_id"] for i in page2["items"]] == ["vid2", "vid1"]
|
||||
assert page2["next_cursor"] is not None
|
||||
|
||||
page3 = client.get(f"/api/feed?limit=2&cursor={page2['next_cursor']}").json()
|
||||
assert [i["youtube_video_id"] for i in page3["items"]] == ["vid0"]
|
||||
assert page3["next_cursor"] is None
|
||||
|
||||
|
||||
def test_feed_filters_by_category(client, db_session):
|
||||
_, _, category, _ = _seed(db_session)
|
||||
|
||||
resp = client.get(f"/api/feed?category_id={category.id}").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vid0", "vid2", "vid4"}
|
||||
|
||||
|
||||
def test_feed_filters_by_channel(client, db_session):
|
||||
channel_a, channel_b, _, _ = _seed(db_session)
|
||||
|
||||
resp = client.get(f"/api/feed?channel_id={channel_b.id}").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vid1", "vid3"}
|
||||
|
||||
|
||||
def test_feed_filters_uncategorized(client, db_session):
|
||||
_seed(db_session)
|
||||
|
||||
resp = client.get("/api/feed?uncategorized=true").json()
|
||||
|
||||
ids = {i["youtube_video_id"] for i in resp["items"]}
|
||||
assert ids == {"vid1", "vid3"}
|
||||
|
||||
|
||||
def test_feed_item_shape(client, db_session):
|
||||
channel_a, _, category, _ = _seed(db_session)
|
||||
|
||||
resp = client.get("/api/feed?limit=1").json()
|
||||
item = resp["items"][0]
|
||||
|
||||
assert item["channel"]["id"] == item["channel"]["id"]
|
||||
assert set(item["channel"].keys()) == {"id", "youtube_channel_id", "title", "thumbnail_url"}
|
||||
assert item["local"] == {
|
||||
"available": False,
|
||||
"status": "not_downloaded",
|
||||
"progress_percent": None,
|
||||
"media_url": None,
|
||||
}
|
||||
assert isinstance(item["categories"], list)
|
||||
43
tests/test_health.py
Normal file
43
tests/test_health.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
def _fake_db():
|
||||
session = MagicMock()
|
||||
session.execute.return_value = None
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def override_get_db():
|
||||
app.dependency_overrides[get_db] = _fake_db
|
||||
yield
|
||||
del app.dependency_overrides[get_db]
|
||||
|
||||
|
||||
def test_health_ok():
|
||||
with patch("app.api.health.httpx.get") as mock_get:
|
||||
mock_get.return_value = MagicMock(status_code=200)
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["database"] == "ok"
|
||||
assert body["metube"] == "ok"
|
||||
|
||||
|
||||
def test_health_metube_unreachable():
|
||||
with patch("app.api.health.httpx.get", side_effect=ConnectionError):
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["metube"] == "error"
|
||||
167
tests/test_sync.py
Normal file
167
tests/test_sync.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from app.models.channel import Channel
|
||||
from app.models.video import Video
|
||||
from app.services import sync
|
||||
|
||||
|
||||
def _fake_credentials():
|
||||
return object()
|
||||
|
||||
|
||||
def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session):
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: _fake_credentials())
|
||||
monkeypatch.setattr(
|
||||
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"},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_uploads_playlists",
|
||||
lambda creds, ids: {cid: f"UU{cid}" for cid in ids},
|
||||
)
|
||||
|
||||
result = sync.sync_subscriptions(db_session)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["channels_added"] == 2
|
||||
assert result["channels_unsubscribed"] == 0
|
||||
|
||||
channels = db_session.query(Channel).order_by(Channel.youtube_channel_id).all()
|
||||
assert [c.youtube_channel_id for c in channels] == ["chanA", "chanB"]
|
||||
assert all(c.subscribed for c in channels)
|
||||
assert channels[0].uploads_playlist_id == "UUchanA"
|
||||
|
||||
# Second sync: chanA disappears from subscriptions, chanC appears.
|
||||
monkeypatch.setattr(
|
||||
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"},
|
||||
],
|
||||
)
|
||||
|
||||
result2 = sync.sync_subscriptions(db_session)
|
||||
|
||||
assert result2["channels_added"] == 1
|
||||
assert result2["channels_updated"] == 1
|
||||
assert result2["channels_unsubscribed"] == 1
|
||||
|
||||
channels = {c.youtube_channel_id: c for c in db_session.query(Channel).all()}
|
||||
assert len(channels) == 3
|
||||
assert channels["chanA"].subscribed is False
|
||||
assert channels["chanB"].subscribed is True
|
||||
assert channels["chanC"].subscribed is True
|
||||
|
||||
|
||||
def test_sync_in_progress_raises(monkeypatch, db_session):
|
||||
sync._subscriptions_lock.acquire()
|
||||
try:
|
||||
try:
|
||||
sync.sync_subscriptions(db_session)
|
||||
assert False, "expected SyncInProgress"
|
||||
except sync.SyncInProgress:
|
||||
pass
|
||||
finally:
|
||||
sync._subscriptions_lock.release()
|
||||
|
||||
|
||||
def _seed_channel(db_session, youtube_channel_id="chanA", uploads_playlist_id="UUchanA"):
|
||||
channel = Channel(
|
||||
youtube_channel_id=youtube_channel_id,
|
||||
title="Channel",
|
||||
subscribed=True,
|
||||
uploads_playlist_id=uploads_playlist_id,
|
||||
)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
db_session.refresh(channel)
|
||||
return channel
|
||||
|
||||
|
||||
def test_sync_videos_adds_and_updates(monkeypatch, db_session):
|
||||
channel = _seed_channel(db_session)
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: ["vid1"]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: [
|
||||
{
|
||||
"youtube_video_id": "vid1",
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": "Video One",
|
||||
"description": "d",
|
||||
"thumbnail_url": "t",
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": "PT5M",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["videos_added"] == 1
|
||||
|
||||
video = db_session.query(Video).filter_by(youtube_video_id="vid1").one()
|
||||
assert video.title == "Video One"
|
||||
assert video.duration_seconds == 300
|
||||
assert video.youtube_url == "https://www.youtube.com/watch?v=vid1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: [
|
||||
{
|
||||
"youtube_video_id": "vid1",
|
||||
"youtube_channel_id": channel.youtube_channel_id,
|
||||
"title": "Video One Updated",
|
||||
"description": "d2",
|
||||
"thumbnail_url": "t2",
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": "PT6M",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result2 = sync.sync_videos(db_session)
|
||||
assert result2["videos_added"] == 0
|
||||
assert result2["videos_updated"] == 1
|
||||
|
||||
videos = db_session.query(Video).all()
|
||||
assert len(videos) == 1
|
||||
assert videos[0].title == "Video One Updated"
|
||||
|
||||
|
||||
def test_sync_videos_skips_unknown_channel(monkeypatch, db_session):
|
||||
_seed_channel(db_session, "chanA")
|
||||
|
||||
monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object())
|
||||
monkeypatch.setattr(sync.youtube_client, "fetch_playlist_video_ids", lambda creds, playlist_id, max_results: [])
|
||||
monkeypatch.setattr(
|
||||
sync.youtube_client,
|
||||
"fetch_videos_details",
|
||||
lambda creds, ids: [
|
||||
{
|
||||
"youtube_video_id": "vidX",
|
||||
"youtube_channel_id": "unknown-channel",
|
||||
"title": "Orphan",
|
||||
"description": "",
|
||||
"thumbnail_url": None,
|
||||
"published_at": "2026-09-10T12:00:00Z",
|
||||
"duration_iso8601": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = sync.sync_videos(db_session)
|
||||
|
||||
assert result["videos_skipped"] == 1
|
||||
assert db_session.query(Video).count() == 0
|
||||
62
tests/test_videos.py
Normal file
62
tests/test_videos.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
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.category import Category
|
||||
from app.models.channel import Channel
|
||||
from app.models.channel_category import channel_categories
|
||||
from app.models.video import Video
|
||||
|
||||
|
||||
@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
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
del app.dependency_overrides[get_db]
|
||||
del app.dependency_overrides[require_session]
|
||||
|
||||
|
||||
def test_get_video_by_youtube_id(client, db_session):
|
||||
channel = Channel(youtube_channel_id="chanA", title="Channel A", subscribed=True)
|
||||
db_session.add(channel)
|
||||
db_session.commit()
|
||||
|
||||
category = Category(name="Linux", slug="linux", sort_order=0)
|
||||
db_session.add(category)
|
||||
db_session.commit()
|
||||
db_session.execute(channel_categories.insert().values(channel_id=channel.id, category_id=category.id))
|
||||
db_session.commit()
|
||||
|
||||
video = Video(
|
||||
youtube_video_id="vid1",
|
||||
channel_id=channel.id,
|
||||
title="Video One",
|
||||
description="desc",
|
||||
published_at=datetime(2026, 9, 10, tzinfo=timezone.utc),
|
||||
duration_seconds=125,
|
||||
youtube_url="https://www.youtube.com/watch?v=vid1",
|
||||
)
|
||||
db_session.add(video)
|
||||
db_session.commit()
|
||||
|
||||
resp = client.get("/api/videos/vid1")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["youtube_video_id"] == "vid1"
|
||||
assert body["channel"]["id"] == channel.id
|
||||
assert body["categories"] == [{"id": category.id, "name": "Linux"}]
|
||||
assert body["local"]["available"] is False
|
||||
|
||||
|
||||
def test_get_video_not_found(client):
|
||||
resp = client.get("/api/videos/missing")
|
||||
assert resp.status_code == 404
|
||||
160
tests/test_youtube_client.py
Normal file
160
tests/test_youtube_client.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import pytest
|
||||
|
||||
from app.services import youtube_client
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = str(payload)
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, params=None, headers=None):
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def _credentials():
|
||||
class Cred:
|
||||
token = "fake-token"
|
||||
|
||||
return Cred()
|
||||
|
||||
|
||||
def test_fetch_subscriptions_paginates(monkeypatch):
|
||||
page1 = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"snippet": {
|
||||
"title": "Channel A",
|
||||
"description": "desc a",
|
||||
"resourceId": {"channelId": "chanA"},
|
||||
"thumbnails": {"high": {"url": "http://thumb/a.jpg"}},
|
||||
}
|
||||
}
|
||||
],
|
||||
"nextPageToken": "page2",
|
||||
},
|
||||
)
|
||||
page2 = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"snippet": {
|
||||
"title": "Channel B",
|
||||
"description": "desc b",
|
||||
"resourceId": {"channelId": "chanB"},
|
||||
"thumbnails": {"default": {"url": "http://thumb/b.jpg"}},
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([page1, page2]))
|
||||
|
||||
result = youtube_client.fetch_subscriptions(_credentials())
|
||||
|
||||
assert [r["youtube_channel_id"] for r in result] == ["chanA", "chanB"]
|
||||
assert result[0]["thumbnail_url"] == "http://thumb/a.jpg"
|
||||
assert result[1]["thumbnail_url"] == "http://thumb/b.jpg"
|
||||
|
||||
|
||||
def test_fetch_subscriptions_raises_on_quota_exceeded(monkeypatch):
|
||||
response = FakeResponse(
|
||||
403,
|
||||
{"error": {"message": "Quota exceeded", "errors": [{"reason": "quotaExceeded"}]}},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
with pytest.raises(youtube_client.YouTubeQuotaExceeded):
|
||||
youtube_client.fetch_subscriptions(_credentials())
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids(monkeypatch):
|
||||
response = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{"contentDetails": {"videoId": "vid1"}},
|
||||
{"contentDetails": {"videoId": "vid2"}},
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids(_credentials(), "UUplaylist", 10)
|
||||
|
||||
assert result == ["vid1", "vid2"]
|
||||
|
||||
|
||||
def test_fetch_playlist_video_ids_returns_empty_on_404(monkeypatch):
|
||||
response = FakeResponse(404, {"error": {"message": "playlist not found"}})
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_playlist_video_ids(_credentials(), "UUplaylist", 10)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_fetch_videos_details(monkeypatch):
|
||||
response = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "vid1",
|
||||
"snippet": {
|
||||
"channelId": "chanA",
|
||||
"title": "Title 1",
|
||||
"description": "desc",
|
||||
"publishedAt": "2026-09-10T12:00:00Z",
|
||||
"thumbnails": {"high": {"url": "http://thumb/1.jpg"}},
|
||||
},
|
||||
"contentDetails": {"duration": "PT10M5S"},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_videos_details(_credentials(), ["vid1"])
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["youtube_video_id"] == "vid1"
|
||||
assert result[0]["youtube_channel_id"] == "chanA"
|
||||
assert result[0]["duration_iso8601"] == "PT10M5S"
|
||||
assert result[0]["thumbnail_url"] == "http://thumb/1.jpg"
|
||||
|
||||
|
||||
def test_fetch_uploads_playlists_batches(monkeypatch):
|
||||
response = FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{"id": "chanA", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanA"}}},
|
||||
{"id": "chanB", "contentDetails": {"relatedPlaylists": {"uploads": "UUchanB"}}},
|
||||
]
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeClient([response]))
|
||||
|
||||
result = youtube_client.fetch_uploads_playlists(_credentials(), ["chanA", "chanB"])
|
||||
|
||||
assert result == {"chanA": "UUchanA", "chanB": "UUchanB"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue