- Detect shorts by duration (SHORTS_MAX_DURATION_SECONDS, default 180)
and expose is_short in feed, video and channel-activity DTOs.
- Feed gains type=all|long|short and anchor; lists show a two-mode
'Обычные | Shorts' filter (no 'Все') persisted in the URL.
- Clicking a short opens /shorts/🆔 a vertical scroll-snap feed with
autoplay for the active slide, context-aware endpoints and infinite
loading.
- Keep the sound choice across swipes, syncing with the player's own
mute control and guarding against the widget's stale isMuted() reads.
171 lines
5.5 KiB
Python
171 lines
5.5 KiB
Python
"""Tests for the Shorts/long split on /api/feed: the `type` filter, the
|
|
`is_short` flag and the one-off `anchor` starting point (see
|
|
analytics/2026-09-27-shorts-split-and-feed.md)."""
|
|
|
|
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.channel import Channel
|
|
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
|
|
# 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]
|
|
|
|
|
|
BASE = datetime(2026, 9, 10, tzinfo=timezone.utc)
|
|
|
|
# Insertion order gives increasing ids. published_at also increases with i,
|
|
# so the descending feed order is v4, v3, v2, v1, v0.
|
|
DURATIONS = {
|
|
"v0": None, # unknown -> long
|
|
"v1": 179, # short
|
|
"v2": 180, # short (boundary, inclusive)
|
|
"v3": 181, # long (just above the threshold)
|
|
"v4": 100, # short
|
|
}
|
|
|
|
|
|
def _seed(db_session):
|
|
channel = Channel(youtube_channel_id="chanShorts", title="Channel Shorts", subscribed=True)
|
|
db_session.add(channel)
|
|
db_session.commit()
|
|
|
|
videos = []
|
|
for i, (youtube_video_id, duration) in enumerate(DURATIONS.items()):
|
|
video = Video(
|
|
youtube_video_id=youtube_video_id,
|
|
channel_id=channel.id,
|
|
title=f"Video {youtube_video_id}",
|
|
published_at=BASE + timedelta(hours=i),
|
|
duration_seconds=duration,
|
|
youtube_url=f"https://www.youtube.com/watch?v={youtube_video_id}",
|
|
)
|
|
videos.append(video)
|
|
db_session.add_all(videos)
|
|
db_session.commit()
|
|
return channel, videos
|
|
|
|
|
|
def _ids(resp):
|
|
return [item["youtube_video_id"] for item in resp["items"]]
|
|
|
|
|
|
def test_feed_type_short_only_includes_at_or_below_threshold(client, db_session):
|
|
_seed(db_session)
|
|
|
|
assert _ids(client.get("/api/feed?type=short").json()) == ["v4", "v2", "v1"]
|
|
|
|
|
|
def test_feed_type_long_includes_null_and_above_threshold(client, db_session):
|
|
_seed(db_session)
|
|
|
|
assert _ids(client.get("/api/feed?type=long").json()) == ["v3", "v0"]
|
|
|
|
|
|
def test_feed_type_all_matches_default(client, db_session):
|
|
_seed(db_session)
|
|
|
|
explicit = client.get("/api/feed?type=all").json()
|
|
default = client.get("/api/feed").json()
|
|
assert _ids(explicit) == ["v4", "v3", "v2", "v1", "v0"]
|
|
assert explicit["items"] == default["items"]
|
|
|
|
|
|
def test_feed_type_invalid_returns_422(client, db_session):
|
|
_seed(db_session)
|
|
|
|
assert client.get("/api/feed?type=weird").status_code == 422
|
|
|
|
|
|
def test_feed_type_threshold_is_configurable(client, db_session, monkeypatch):
|
|
_seed(db_session)
|
|
|
|
monkeypatch.setattr("app.config.settings.shorts_max_duration_seconds", 181)
|
|
|
|
assert set(_ids(client.get("/api/feed?type=short").json())) == {"v4", "v3", "v2", "v1"}
|
|
assert _ids(client.get("/api/feed?type=long").json()) == ["v0"]
|
|
|
|
|
|
def test_feed_item_is_short_flag(client, db_session):
|
|
_seed(db_session)
|
|
|
|
items = {item["youtube_video_id"]: item for item in client.get("/api/feed").json()["items"]}
|
|
|
|
assert items["v1"]["is_short"] is True
|
|
assert items["v2"]["is_short"] is True
|
|
assert items["v4"]["is_short"] is True
|
|
assert items["v3"]["is_short"] is False
|
|
assert items["v0"]["is_short"] is False
|
|
|
|
|
|
def test_feed_type_short_pagination_in_filtered_set(client, db_session):
|
|
_seed(db_session)
|
|
|
|
page1 = client.get("/api/feed?type=short&limit=2").json()
|
|
assert _ids(page1) == ["v4", "v2"]
|
|
assert page1["next_cursor"] is not None
|
|
|
|
page2 = client.get(f"/api/feed?type=short&limit=2&cursor={page1['next_cursor']}").json()
|
|
assert _ids(page2) == ["v1"]
|
|
assert page2["next_cursor"] is None
|
|
|
|
|
|
def test_feed_type_long_pagination_in_filtered_set(client, db_session):
|
|
_seed(db_session)
|
|
|
|
page1 = client.get("/api/feed?type=long&limit=1").json()
|
|
assert _ids(page1) == ["v3"]
|
|
assert page1["next_cursor"] is not None
|
|
|
|
page2 = client.get(f"/api/feed?type=long&limit=1&cursor={page1['next_cursor']}").json()
|
|
assert _ids(page2) == ["v0"]
|
|
assert page2["next_cursor"] is None
|
|
|
|
|
|
def test_feed_anchor_starts_at_video_and_goes_older(client, db_session):
|
|
_seed(db_session)
|
|
|
|
assert _ids(client.get("/api/feed?anchor=v2").json()) == ["v2", "v1", "v0"]
|
|
|
|
|
|
def test_feed_anchor_unknown_falls_back_to_first_page(client, db_session):
|
|
_seed(db_session)
|
|
|
|
resp = client.get("/api/feed?anchor=does-not-exist").json()
|
|
assert _ids(resp) == ["v4", "v3", "v2", "v1", "v0"]
|
|
|
|
|
|
def test_feed_anchor_ignored_when_cursor_present(client, db_session):
|
|
_seed(db_session)
|
|
|
|
page1 = client.get("/api/feed?anchor=v2&limit=2").json()
|
|
assert _ids(page1) == ["v2", "v1"]
|
|
assert page1["next_cursor"] is not None
|
|
|
|
# A (different) anchor on a cursor page must not shift the selection:
|
|
# only the first page uses the anchor.
|
|
page2 = client.get(f"/api/feed?anchor=v4&cursor={page1['next_cursor']}").json()
|
|
assert _ids(page2) == ["v0"]
|
|
|
|
|
|
def test_feed_anchor_combines_with_type_filter(client, db_session):
|
|
_seed(db_session)
|
|
|
|
assert client.get("/api/feed?type=short&anchor=v0").json()["items"] == []
|
|
assert _ids(client.get("/api/feed?type=short&anchor=v3").json()) == ["v2", "v1"]
|