172 lines
5.5 KiB
Python
172 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"]
|