Files
siftlode/backend/tests/test_discover.py
T
peter 53b50231ff feat(e24): the Home surface read-model — hero, resume rail, comfort shelf
E24 S1. The anti-paralysis answer layer, served from our own Postgres with no
upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's
own comfort shelf.

Survey first, because the roadmap line was wrong about the starting point: Plex
already ships the whole trio (E5's Discover rails, including a reshuffling
Surprise me), the YouTube side ships none of it, and nothing anywhere answers
the question across both. The data already existed on both sides — VideoState
and PlexState are structurally identical, the feed has an `in_progress` filter
and a seeded shuffle, and the YT player already resumes position.

- app/discover/budget.py — the time bands. "How much time do you have"
  (10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire:
  deterministic, answerable from a column we have, and it picks the source for
  free. Matched against REMAINING time, so a film with 8 minutes left is a
  ten-minute answer. An unknown runtime fits only the unbounded band.
- app/discover/cards.py — a thin unified card that carries the NATIVE feed /
  Plex payload, so the page renders with the existing card components instead of
  a third one. `aspect` is the one new field: a rail needs to know poster from
  16:9, and that is a property of the artwork, not of the module.
- app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather
  than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a
  surface that has a sidebar. One seed drives both halves so a reroll moves
  them together.
- app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in
  the payload so the client labels it honestly instead of guessing.
- comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1
  rather than a flag or a polymorphic string ref. A flag cannot order a shelf or
  hold a channel; a string ref would let a deleted library item leave a dead tile
  on the one surface whose whole job is to be reliable.

Two honest-state rules the tests pin: the surprise draw excludes anything
already started (the resume tier may have skipped it for not fitting, and
offering it back as "Meglepetés" would contradict the question), and which
source leads the draw follows the seed, so the Plex library can actually answer
"egy este".

Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770,
pytest 987.
2026-09-06 03:31:26 +02:00

530 lines
20 KiB
Python

"""E24 S1 — the Home surface's read-model.
Covers the four things that can silently rot here: the time-budget bands (pure), the cross-source
resume rail's merge order, the hero's tier ladder, and the comfort shelf (its schema-level one-of
invariant, its cascade, and the resolved `next` that makes one click enough).
DB lane (`db` fixture) except for the budget block — skipped without Postgres.
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import delete, func, select, text
from sqlalchemy.exc import IntegrityError
from app.discover import hero, pins, queries, rails
from app.discover.budget import BANDS, fits, remaining_seconds
from app.models import (
Channel,
ComfortPin,
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
Playlist,
PlaylistItem,
Subscription,
User,
Video,
VideoState,
)
NOW = datetime(2026, 6, 1, tzinfo=timezone.utc)
# ── budget (pure) ─────────────────────────────────────────────────────────────────────────────────
def test_bands_cover_every_runtime_and_overlap_deliberately():
"""Every runtime has a home in `any`, and the bands overlap so "30 perc" is not answered with a
two-minute Short just because it fits under the cap."""
assert fits(3, "any") and fits(10_000, "any")
assert fits(2 * 60, "short") and not fits(2 * 60, "medium") # a Short is not a 30-minute answer
assert fits(30 * 60, "medium") and not fits(30 * 60, "short")
assert fits(110 * 60, "long") and not fits(110 * 60, "medium")
def test_unknown_duration_only_fits_the_unbounded_band():
"""We cannot promise an unknown-length item finishes inside ten minutes, so it is not offered as
an answer to a bounded question — the one control on the page has to mean what it says."""
for budget in BANDS:
assert fits(None, budget) is (budget == "any")
def test_budget_is_matched_against_what_is_LEFT():
"""A 90-minute film with 8 minutes to go is a ten-minute answer."""
assert remaining_seconds(90 * 60, 82 * 60) == 8 * 60
assert fits(remaining_seconds(90 * 60, 82 * 60), "short")
assert not fits(remaining_seconds(90 * 60, 0), "short")
assert remaining_seconds(None, 30) is None
assert remaining_seconds(600, 900) == 0 # a position past the end never goes negative
# ── fixtures ──────────────────────────────────────────────────────────────────────────────────────
def _seed_yt(db) -> tuple[User, Channel]:
"""A subscribed channel with three videos of distinct lengths, so a budget can pick between them."""
user = User(email="discover-e24@example.com")
ch = Channel(id="UCe24", title="Comfort Channel", handle="@comfort")
db.add_all([user, ch])
db.flush()
db.add(Subscription(user_id=user.id, channel_id=ch.id))
db.add_all(
[
Video(
id="vshort",
channel_id=ch.id,
title="Ten minute video",
duration_seconds=9 * 60,
published_at=NOW - timedelta(days=1),
),
Video(
id="vmed",
channel_id=ch.id,
title="Half hour video",
duration_seconds=30 * 60,
published_at=NOW - timedelta(days=2),
),
Video(
id="vlong",
channel_id=ch.id,
title="Feature length video",
duration_seconds=95 * 60,
published_at=NOW - timedelta(days=3),
),
]
)
db.commit()
return user, ch
def _seed_plex(db, user: User) -> tuple[PlexItem, PlexShow, PlexItem]:
lib_m = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="91", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
movie = PlexItem(
rating_key="pm1",
library_id=lib_m.id,
kind="movie",
title="A Movie",
duration_s=100 * 60,
playable="direct",
)
show = PlexShow(rating_key="ps1", library_id=lib_s.id, title="A Show", child_count=1)
db.add_all([movie, show])
db.flush()
ep1 = PlexItem(
rating_key="ps1e1",
library_id=lib_s.id,
kind="episode",
title="Ep 1",
show_id=show.id,
season_number=1,
episode_number=1,
duration_s=45 * 60,
)
ep2 = PlexItem(
rating_key="ps1e2",
library_id=lib_s.id,
kind="episode",
title="Ep 2",
show_id=show.id,
season_number=1,
episode_number=2,
duration_s=45 * 60,
)
db.add_all([ep1, ep2])
db.commit()
return movie, show, ep1
def _enable_plex(db):
from app import sysconfig
sysconfig.set_value(db, "plex_enabled", True)
db.commit()
# ── continue watching ─────────────────────────────────────────────────────────────────────────────
def test_continue_watching_merges_both_sources_by_recency(db):
"""One rail, not two lists side by side — and ordered by when the user last touched each thing,
across sources. Two side-by-side rails would just move the decision up a level."""
user, _ = _seed_yt(db)
movie, _, _ = _seed_plex(db, user)
_enable_plex(db)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW - timedelta(hours=5),
),
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW - timedelta(hours=1),
),
]
)
db.commit()
got = rails.continue_watching(db, user)
assert [(c["source"], c["id"]) for c in got] == [("plex", "pm1"), ("youtube", "vmed")]
assert got[0]["remaining_seconds"] == 100 * 60 - 600
def test_continue_watching_is_youtube_only_when_plex_is_off(db):
"""Plex is a deployment toggle; with it off the surface says nothing about Plex at all."""
user, _ = _seed_yt(db)
movie, _, _ = _seed_plex(db, user)
db.add_all(
[
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
),
PlexState(
user_id=user.id,
item_id=movie.id,
status="new",
position_seconds=600,
progress_updated_at=NOW,
),
]
)
db.commit()
assert {c["source"] for c in rails.continue_watching(db, user)} == {"youtube"}
def test_a_null_timestamp_sorts_last_instead_of_raising(db):
"""The `(ts is not None, ts)` key this rail's Plex sibling used compares `None < None` when two
rows both lack a timestamp, which raises. A missing progress timestamp must sort last, not 500
the landing page."""
user, _ = _seed_yt(db)
db.add_all(
[
VideoState(
user_id=user.id, video_id="vmed", status="new", position_seconds=1
), # no progress_updated_at
VideoState(
user_id=user.id, video_id="vlong", status="new", position_seconds=1
), # nor here
VideoState(
user_id=user.id,
video_id="vshort",
status="new",
position_seconds=1,
progress_updated_at=NOW,
),
]
)
db.commit()
got = rails.continue_watching(db, user)
assert got[0]["id"] == "vshort"
assert len(got) == 3
# ── hero ──────────────────────────────────────────────────────────────────────────────────────────
def test_hero_prefers_resume_and_labels_the_tier(db):
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "any", seed=1)
assert got["tier"] == "resume"
assert got["card"]["id"] == "vmed"
def test_hero_skips_a_resume_that_does_not_fit_the_budget(db):
""" "I have ten minutes" must not be answered with the 80 minutes left on a film, even though
that film is the most recent thing the user touched."""
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vlong",
status="new",
position_seconds=60,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "short", seed=1)
assert got["tier"] == "surprise"
assert got["card"]["duration_seconds"] <= BANDS["short"][1]
def test_hero_falls_back_to_the_shelf_before_guessing(db):
"""The user's own declared fallback beats anything we could infer."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
got = hero.pick(db, user, "medium", seed=1)
assert got["tier"] == "comfort"
assert got["card"]["id"] == "vmed"
def test_hero_resolves_a_show_to_the_episode_that_would_play(db):
"""A show has no Play button. The hero of a partially-watched show is its next episode."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
_enable_plex(db)
db.add(
PlexState(
user_id=user.id,
item_id=ep1.id,
status="watched",
watched_at=NOW,
progress_updated_at=NOW,
)
)
db.commit()
got = hero.pick(db, user, "any", seed=1)
assert got["tier"] == "resume"
assert got["card"]["kind"] == "episode"
assert got["card"]["id"] == "ps1e2" # ep1 is finished → ep2 is what plays
def test_hero_is_none_when_nothing_fits(db):
"""An empty answer is an honest answer: the page renders its own empty state rather than being
handed a card that does not answer what was asked. Here the whole library is under 40 minutes,
so "egy este" has nothing to offer."""
user, ch = _seed_yt(db)
db.execute(text("DELETE FROM videos WHERE id = 'vlong'"))
db.commit()
assert hero.pick(db, user, "long", seed=1) is None
def test_surprise_is_reproducible_for_a_seed(db):
"""The reroll button depends on this: one seed drives both sources, so the same seed must give
the same answer and a different seed must be free to give another."""
user, _ = _seed_yt(db)
a = rails.surprise(db, user, "any", seed=7, limit=3)
b = rails.surprise(db, user, "any", seed=7, limit=3)
assert [c["id"] for c in a] == [c["id"] for c in b]
seeds = {tuple(c["id"] for c in rails.surprise(db, user, "any", s, 3)) for s in range(12)}
assert len(seeds) > 1 # the seed actually reaches the ordering
def test_surprise_never_offers_something_already_started(db):
"""The resume tier may have skipped an item because its REMAINING time did not fit; handing the
same item back as "Meglepetés" — where the full runtime does fit — would contradict the question
the user just answered."""
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vlong",
status="new",
position_seconds=90 * 60,
progress_updated_at=NOW,
)
)
db.commit()
assert "vlong" not in {c["id"] for c in rails.surprise(db, user, "long", seed=3, limit=5)}
def test_the_seed_decides_which_source_leads(db):
"""With a pool of one, a fixed source order would mean the Plex library could never answer "egy
este" whenever a YouTube video also fit — the cross-source promise would quietly be a YouTube
promise."""
user, _ = _seed_yt(db)
_seed_plex(db, user)
_enable_plex(db)
leaders = {rails.surprise(db, user, "any", s, limit=1)[0]["source"] for s in range(6)}
assert leaders == {"youtube", "plex"}
# ── comfort shelf ─────────────────────────────────────────────────────────────────────────────────
def test_a_pin_must_reference_exactly_one_thing(db):
"""The union invariant lives in the schema, not in a comment at every insert site."""
user, ch = _seed_yt(db)
for kwargs in ({}, {"video_id": "vmed", "channel_id": ch.id}):
db.add(ComfortPin(user_id=user.id, position=0, **kwargs))
with pytest.raises(IntegrityError):
db.flush()
db.rollback()
def test_deleting_the_pinned_row_removes_the_pin(db):
"""A film dropped from the library must not leave a dead tile on the one surface whose whole job
is to be reliable — which is why the shelf holds foreign keys, not strings."""
user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
db.execute(text("DELETE FROM videos WHERE id = 'vmed'"))
db.commit()
assert db.execute(select(func.count()).select_from(ComfortPin)).scalar() == 0
def test_shelf_resolves_a_channel_to_its_newest_unwatched_video(db):
""" "Put that channel on" has to be one click, so the tile carries what would play."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, channel_id=ch.id, position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["kind"] == "channel"
assert card["unwatched_count"] == 3
assert card["next"]["id"] == "vshort" # newest
def test_shelf_resolves_a_playlist_to_its_first_unwatched_item(db):
user, _ = _seed_yt(db)
pl = Playlist(user_id=user.id, name="Elalvós")
db.add(pl)
db.flush()
db.add_all(
[
PlaylistItem(playlist_id=pl.id, video_id="vshort", position=0),
PlaylistItem(playlist_id=pl.id, video_id="vmed", position=1),
]
)
db.add(VideoState(user_id=user.id, video_id="vshort", status="watched", watched_at=NOW))
db.add(ComfortPin(user_id=user.id, playlist_id=pl.id, position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["kind"] == "playlist"
assert card["item_count"] == 2
assert card["next"]["id"] == "vmed"
def test_a_pinned_video_survives_leaving_the_feed_scope(db):
"""The shelf is the fallback surface. An entry silently vanishing because the user unsubscribed
from the channel is exactly the failure it must not have."""
user, ch = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
db.execute(delete(Subscription))
db.commit()
(card,) = pins.shelf(db, user)
assert card["id"] == "vmed"
assert card["next"]["id"] == "vmed"
def test_a_self_playing_pin_does_not_reference_itself(db):
"""`next` must be a distinct dict even when the pin plays itself. Aliasing the card into its own
`next` is a cycle, and the serializer turns the whole shelf into a 500 over it."""
user, _ = _seed_yt(db)
db.add(ComfortPin(user_id=user.id, video_id="vmed", position=0))
db.commit()
(card,) = pins.shelf(db, user)
assert card["next"] is not card
assert "next" not in card["next"]
def test_next_episode_prefers_the_one_in_progress(db):
"""In progress beats "next unwatched": resuming is cheaper than starting."""
user, _ = _seed_yt(db)
_, show, ep1 = _seed_plex(db, user)
ep2 = db.execute(select(PlexItem).where(PlexItem.rating_key == "ps1e2")).scalar_one()
db.add_all(
[
PlexState(user_id=user.id, item_id=ep1.id, status="watched", watched_at=NOW),
PlexState(user_id=user.id, item_id=ep2.id, status="new", position_seconds=120),
]
)
db.commit()
assert queries.next_episode(db, user.id, show.id)["id"] == "ps1e2"
# ── HTTP surface ──────────────────────────────────────────────────────────────────────────────────
def test_discover_endpoint_answers_in_one_pass(db, as_user):
user, _ = _seed_yt(db)
db.add(
VideoState(
user_id=user.id,
video_id="vmed",
status="new",
position_seconds=300,
progress_updated_at=NOW,
)
)
db.commit()
r = as_user(user).get("/api/discover?budget=medium")
assert r.status_code == 200
body = r.json()
assert body["budget"] == "medium"
assert body["hero"]["tier"] == "resume"
assert [c["id"] for c in body["continue_watching"]] == ["vmed"]
assert body["comfort"] == []
def test_an_unknown_budget_is_refused_rather_than_silently_widened(db, as_user):
user, _ = _seed_yt(db)
assert as_user(user).get("/api/discover?budget=whenever").status_code == 422
def test_pinning_twice_is_the_same_shelf(db, as_user):
"""The gesture is "make sure this is on my shelf", so a re-pin is a no-op, not a 409."""
user, _ = _seed_yt(db)
c = as_user(user)
first = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
second = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()
assert first["created"] is True and second["created"] is False
assert first["id"] == second["id"]
assert len(c.get("/api/discover/pins").json()["items"]) == 1
def test_pinning_something_that_does_not_exist_is_a_404(db, as_user):
user, _ = _seed_yt(db)
r = as_user(user).post("/api/discover/pins", json={"kind": "video", "ref": "nope"})
assert r.status_code == 404
def test_another_users_playlist_is_not_pinnable(db, as_user):
"""404, not 403 — a 403 would confirm the id exists to a stranger."""
user, _ = _seed_yt(db)
other = User(email="other-e24@example.com")
db.add(other)
db.flush()
pl = Playlist(user_id=other.id, name="Theirs")
db.add(pl)
db.commit()
r = as_user(user).post("/api/discover/pins", json={"kind": "playlist", "ref": str(pl.id)})
assert r.status_code == 404
def test_reorder_ignores_ids_that_are_not_yours(db, as_user):
"""A stale tab replaying an order that includes a since-deleted pin should reorder what still
exists rather than fail wholesale."""
user, ch = _seed_yt(db)
c = as_user(user)
a = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
b = c.post("/api/discover/pins", json={"kind": "channel", "ref": ch.id}).json()["id"]
assert c.post("/api/discover/pins/reorder", json={"ids": [b, 9999, a]}).status_code == 200
assert [p["pin_id"] for p in c.get("/api/discover/pins").json()["items"]] == [b, a]
def test_deleting_a_pin_reports_what_went(db, as_user):
user, _ = _seed_yt(db)
c = as_user(user)
pin_id = c.post("/api/discover/pins", json={"kind": "video", "ref": "vmed"}).json()["id"]
r = c.delete(f"/api/discover/pins/{pin_id}")
assert r.status_code == 200 and r.json() == {"deleted": pin_id}
assert c.get("/api/discover/pins").json()["items"] == []