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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue