Files
siftlode/backend/tests/test_feed_views.py
T
peter e4b19b5320 fix(feed): round-2 deep-review remediations (incl. a real coerce-on-seed bug)
The re-review of the fix round surfaced 4 findings; fixing the coverage one
exposed that the C1 migration was ineffective:
- FeedFiltersProvider.loadAccountFilters: run coerceTagModes on the RAW stored
  blob, not the object merged over DEFAULT_FILTERS. The merge injects
  DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy
  scalar `tagMode` — so the migration never fired. Now a persisted legacy
  tagMode:'and' actually migrates into tagModes.topic (proven by a new test).
- test_feed_views: the tag_mode_other=and case was vacuous (only chC carried
  either "other" tag, so AND == OR). Give chA an "other" tag too so OR-other
  {chA,chC} differs from AND-other {chC} — the conjunctive path now narrows.
- DateFilterSelect: reset the custom reveal in useLayoutEffect (not useEffect)
  so the correction commits before paint — no one-frame "Custom range" flicker
  on the trigger after an external clear.
- SavedViewsProvider: extract the sticky-view tie-break to a pure, tested
  lib/savedViews.ts (preferSticky).

New tests: savedViews (preferSticky), FeedFiltersProvider.dom (legacy tagMode
migration on the localStorage/default-view seed).
2026-08-22 06:55:16 +02:00

168 lines
7.9 KiB
Python

"""Feed search + watch-state views (E2 S3), DB-lane.
Regression nets for two query rewrites that must stay behaviour-equivalent:
- PERF-12: the search channel-name branch moved from an ORed `unaccent(channels.title) ILIKE` (which
defeated the search_vector GIN) to a pre-resolved `channel_id IN (...)`. A search must STILL return
both videos whose text matches AND videos whose CHANNEL name matches.
- PERF-16: the watched/hidden/in_progress views moved from an outer join + `coalesce(state.status,'new')`
to an inner join on video_states + the raw column. Each view must return exactly the same set.
"""
from datetime import datetime, timezone
import pytest
from app.models import Channel, ChannelTag, Subscription, Tag, Video, VideoState
def _dt(day: int) -> datetime:
return datetime(2024, 1, day, tzinfo=timezone.utc)
def _video(vid: str, ch: str, title: str) -> Video:
return Video(
id=vid, channel_id=ch, published_at=_dt(1), title=title,
is_short=False, live_status="none", via_search=False, via_explore=False,
)
def _feed_ids(client, query: str) -> set[str]:
resp = client.get(f"/api/feed?{query}&limit=50")
assert resp.status_code == 200, resp.text
return {it["id"] for it in resp.json()["items"]}
def test_search_matches_both_video_text_and_channel_name(db, as_user, make_user):
# Two subscribed channels: one whose NAME matches the query (its video's text does not), and one
# whose video TEXT matches (its name does not). The pre-resolved channel_id branch must still OR in
# the name match, so both videos come back — and an unrelated video on a matching-name-less channel
# stays out.
user = make_user(email="search@example.com")
db.add_all([
Channel(id="UCkube", title="Kubernetes Weekly", subscriber_count=10),
Channel(id="UCother", title="Some Other Channel", subscriber_count=10),
])
db.add_all([
Subscription(user_id=user.id, channel_id="UCkube", priority=0, hidden=False),
Subscription(user_id=user.id, channel_id="UCother", priority=0, hidden=False),
])
db.add_all([
_video("vidChan", "UCkube", "Totally unrelated content"), # channel NAME matches
_video("vidText", "UCother", "kubernetes deep dive"), # video TEXT matches
_video("vidNone", "UCother", "cooking pasta at home"), # neither matches
])
db.commit()
ids = _feed_ids(as_user(user), "q=kubernetes&show=all")
assert ids == {"vidChan", "vidText"}
def test_watch_state_views_return_the_right_sets(db, as_user, make_user):
user = make_user(email="views@example.com")
db.add(Channel(id="UCv", title="Channel V", subscriber_count=10))
db.add(Subscription(user_id=user.id, channel_id="UCv", priority=0, hidden=False))
db.add_all([
_video("vWatch", "UCv", "watched one"),
_video("vHide", "UCv", "hidden one"),
_video("vProg", "UCv", "in progress one"),
_video("vNew", "UCv", "brand new one"),
])
db.flush()
db.add_all([
VideoState(user_id=user.id, video_id="vWatch", status="watched"),
VideoState(user_id=user.id, video_id="vHide", status="hidden"),
# in progress = a stored resume position while still "new" (not watched/hidden)
VideoState(user_id=user.id, video_id="vProg", status="new", position_seconds=30),
# vNew has NO state row -> "new"/unwatched
])
db.commit()
client = as_user(user)
assert _feed_ids(client, "show=watched") == {"vWatch"}
assert _feed_ids(client, "show=hidden") == {"vHide"}
assert _feed_ids(client, "show=in_progress") == {"vProg"}
# unwatched excludes watched + hidden, keeps new AND in-progress
assert _feed_ids(client, "show=unwatched") == {"vProg", "vNew"}
# "all" shows everything except hidden
assert _feed_ids(client, "show=all") == {"vWatch", "vProg", "vNew"}
def _seed_tagged(db, make_user):
"""A user subscribed to 4 channels with a spread of language/topic/other tags and differing video
counts, so the count sum and the per-category facet drill-down are all non-trivial."""
user = make_user(email="summary@example.com")
tags = {
"L1": Tag(user_id=None, name="Hungarian", category="language"),
"L2": Tag(user_id=None, name="English", category="language"),
"T1": Tag(user_id=None, name="Tech", category="topic"),
"T2": Tag(user_id=None, name="Gaming", category="topic"),
"O1": Tag(user_id=None, name="Favourite", category="other"),
"O2": Tag(user_id=None, name="Docs", category="other"),
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
}
db.add_all(tags.values())
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
db.add_all(chans.values())
db.flush()
# channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
# AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
# language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
# {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (chA/chB share topic tags T1/T2 for the topic case.)
links = {
"chA": ["L1", "L2", "T1", "O1"],
"chB": ["L1", "T2"],
"chC": ["L2", "T1", "O1", "O2"],
"chD": [],
}
for cid, tkeys in links.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for tk in tkeys:
db.add(ChannelTag(channel_id=cid, tag_id=tags[tk].id, user_id=None))
# The user's own private link (visible only to them): chB carries U1 for this user.
db.add(ChannelTag(channel_id="chB", tag_id=tags["U1"].id, user_id=user.id))
# differing video counts per channel (chA:3, chB:2, chC:1, chD:2)
counts = {"chA": 3, "chB": 2, "chC": 1, "chD": 2}
for cid, n in counts.items():
db.add_all([_video(f"{cid}v{i}", cid, f"{cid} clip {i}") for i in range(n)])
db.commit()
return user, {k: v.id for k, v in tags.items()}
# Query-param combos (as a template with {L1}/{T1}/… placeholders) that stress every facet path:
# no tags, single-category, cross-category, disjunctive + conjunctive topic modes, and a search filter.
_SUMMARY_CASES = [
"show=all",
"show=all&tags={L1}",
"show=all&tags={T1}",
"show=all&tags={L1}&tags={T1}",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
# Per-category any/all is NOT topic-only — language and other must drill down identically too.
"show=all&tags={L1}&tags={L2}&tag_mode_language=or",
"show=all&tags={L1}&tags={L2}&tag_mode_language=and",
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
"show=all&q=clip",
"show=unwatched",
]
@pytest.mark.parametrize("case", _SUMMARY_CASES)
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
# across the full matrix of tag filters — it reimplements the tag drill-down in Python, so this is
# the equivalence net.
user, tag_ids = _seed_tagged(db, make_user)
client = as_user(user)
qs = case.format(**tag_ids)
summary = client.get(f"/api/feed/summary?{qs}")
count = client.get(f"/api/feed/count?{qs}")
facets = client.get(f"/api/facets?{qs}")
assert summary.status_code == count.status_code == facets.status_code == 200
assert summary.json()["count"] == count.json()["count"], qs
assert summary.json()["facets"] == facets.json()["counts"], qs