Files
siftlode/backend/tests/test_reader_service.py
T
peter 4d1cf66d96 fix: tag counts, chip focus, upload naming, share links and unsized clips hold after round 2
- A tag counts the followed channels the Subscriptions tab shows: hidden ones are left out, and a
  subscribe, unsubscribe, hide or tag toggle refreshes the count. The Tag manager's row and its
  hover list count the same channels as the chip; /api/tags sends both counts under their names.
- A chip's count keeps the chip's colour after a dot (a dimmed count fell under 4.5:1). A pick in
  the open "+N" fold closes it; a chip that leaves the list hands the focus to the one in its place.
  The fold is a dialog of toggle buttons, not an empty menu.
- Upload: a folder name left by a press is made once the press is over, if the dialog is still
  open, instead of guessing a dismissal from Modal's markup. While the folder is made, its place
  holds the focus and a live region says so.
- Share: an expired link has no Copy and a warn badge; the link made on open goes into the cached
  list, so a reopen makes no second one; the New link form survives the link made on open.
- Watch: a repeated Enter sends one unlock while one is on its way. See all buttons are named with
  their rail.
- An unsized clip of a rotated file gets the upright size its crop cut.
2026-11-06 02:20:52 +01:00

371 lines
15 KiB
Python

"""Reader's service and queries, one use case each, below the HTTP layer.
The routes are thin callers now, and their own tests stay the contract a client sees. These hold
what each write does and leaves on its own: that it flushes and leaves the commit to its caller,
that a row of someone else's reads as a missing one, and the rule each write carries (one default
view per module, a resume point a status keeps, a move that never leaves a video in two lists, the
auto-tagger touching only its own links). DB-backed (real Postgres).
"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.discover import pins
from app.catalog.models import Channel, Subscription, Video
from app.reader import queries, service
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
def _videos(db, *ids):
if db.get(Channel, "UCr") is None:
db.add(Channel(id="UCr", title="R"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCr", published_at=now, duration_seconds=60))
db.commit()
def _uncommitted(db):
"""What the caller still has to commit: the session holds pending work."""
return bool(db.new or db.dirty or db.deleted) or db.in_transaction()
def _ids(db, pl):
return [it.video_id for it in queries.items(db, pl.id)]
# --- whose a row is ------------------------------------------------------------------------
def test_a_row_of_someone_elses_reads_as_a_missing_one(db, make_user):
owner, stranger = make_user(), make_user()
tag = service.create_tag(db, owner, "Mine", "other", None)
view = service.create_view(db, owner, "feed", "V", {})
pl = service.create_playlist(db, owner, "L")
db.commit()
assert queries.own_tag(db, owner, tag.id) is tag
assert queries.own_tag(db, stranger, tag.id) is None
assert queries.own_view(db, stranger, view.id) is None
assert queries.own_playlist(db, stranger, pl.id) is None
assert queries.own_playlist(db, owner, pl.id) is pl
assert queries.playlists_for(db, stranger) == []
def test_a_system_tag_is_nobodys_to_edit(db, make_user):
user, other = make_user(), make_user()
system = service.ensure_system_tag(db, "Music", "topic", None)
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
db.commit()
assert queries.own_tag(db, user, system.id) is None
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
# --- tags ------------------------------------------------------------------------------------
def _music_on_four(db, make_user):
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
user, other = make_user(), make_user()
tag = service.create_tag(db, user, "Music", "other", None)
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(Channel(id=cid, title=cid))
db.flush()
for cid in ("UCa", "UCb"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
for cid in ("UCa", "UCb", "UChid", "UCgone"):
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
db.commit()
return user, tag
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
the followed channels not hidden, so its count must not hold the others."""
user, tag = _music_on_four(db, make_user)
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
assert (count, subscribed) == (4, 2)
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
user, tag = _music_on_four(db, make_user)
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
user = make_user()
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
assert _uncommitted(db)
db.commit()
assert tag.category == "other"
with pytest.raises(IntegrityError):
service.create_tag(db, user, "Later", "topic", None)
db.rollback()
def test_a_tag_changes_only_what_was_sent_and_a_sent_null_clears_its_color(db, make_user):
user = make_user()
tag = service.create_tag(db, user, "A", "other", "#fff")
service.update_tag(tag, sent={"name"}, name="B", color=None)
assert (tag.name, tag.color) == ("B", "#fff")
service.update_tag(tag, sent={"color"}, name=None, color=None)
assert (tag.name, tag.color) == ("B", None)
service.delete_tag(db, tag)
db.commit()
assert db.get(Tag, tag.id) is None
def test_the_auto_tagger_moves_only_its_own_links(db, make_user):
user = make_user()
_videos(db)
gaming = service.ensure_system_tag(db, "Gaming", "topic", None)
music = service.ensure_system_tag(db, "Music", "topic", None)
assert service.ensure_system_tag(db, "Gaming", "topic", None) is gaming # made once
mine = service.create_tag(db, user, "Fav", "other", None)
db.add(ChannelTag(channel_id="UCr", tag_id=mine.id, user_id=user.id, source="user"))
db.commit() # the user's link is in the table the auto-tagger reads
service.set_auto_tags(db, "UCr", {gaming.id: 0.5})
db.commit()
service.set_auto_tags(db, "UCr", {music.id: None})
db.commit()
links = db.execute(select(ChannelTag.tag_id, ChannelTag.source)).tuples().all()
assert sorted(links) == sorted([(music.id, "auto"), (mine.id, "user")])
assert db.execute(
select(Channel.id).where(queries.has_auto_tags(Channel.id))
).scalars().all() == ["UCr"]
# --- saved views -----------------------------------------------------------------------------
def test_views_number_per_module_and_one_default_per_module(db, make_user):
user = make_user()
a = service.create_view(db, user, "feed", "A", {})
b = service.create_view(db, user, "feed", "B", {})
plex = service.create_view(db, user, "plex", "P", {})
assert (a.position, b.position, plex.position) == (0, 1, 0)
service.update_view(db, plex, sent={"is_default"}, is_default=True)
service.update_view(db, a, sent={"is_default"}, is_default=True)
service.update_view(db, b, sent={"is_default"}, is_default=True)
db.commit()
db.expire_all()
assert [(v.name, v.is_default) for v in queries.views(db, user, "feed")] == [
("A", False),
("B", True),
]
assert queries.views(db, user, "plex")[0].is_default is True # another module's default stays
def test_reordering_views_skips_ids_that_are_not_the_callers(db, make_user):
user, other = make_user(), make_user()
a = service.create_view(db, user, "feed", "A", {})
b = service.create_view(db, user, "feed", "B", {})
theirs = service.create_view(db, other, "feed", "T", {})
db.commit()
service.reorder_views(db, user, [theirs.id, b.id, a.id])
db.commit()
assert [v.name for v in queries.views(db, user, "feed")] == ["B", "A"]
assert theirs.position == 0
# --- watch state -----------------------------------------------------------------------------
def _state(db, user, vid):
db.expire_all()
return queries.video_state(db, user, vid)
def test_unmarking_keeps_a_resume_point_and_drops_a_row_with_nothing_else(db, make_user):
user = make_user()
_videos(db, "v1", "v2")
service.mark(db, user, "v1", "watched")
service.mark(db, user, "v2", "watched")
service.set_progress(db, user, "v1", 120)
db.commit()
service.unmark(db, user, "v1")
service.unmark(db, user, "v2")
db.commit()
kept = _state(db, user, "v1")
assert (kept.status, kept.watched_at, kept.position_seconds) == ("new", None, 120)
assert _state(db, user, "v2") is None
def test_hiding_a_watched_video_keeps_when_it_was_watched(db, make_user):
user = make_user()
_videos(db, "v1")
service.mark(db, user, "v1", "watched")
db.commit()
watched_at = _state(db, user, "v1").watched_at
service.mark(db, user, "v1", "hidden")
db.commit()
row = _state(db, user, "v1")
assert (row.status, row.watched_at) == ("hidden", watched_at)
assert db.execute(select(VideoState)).scalars().all() == [row] # one row per user and video
def test_a_cleared_resume_point_drops_a_bare_row_and_zeroes_one_with_a_status(db, make_user):
user = make_user()
_videos(db, "v1", "v2")
service.set_progress(db, user, "v1", 30)
service.set_progress(db, user, "v2", 30)
service.mark(db, user, "v2", "hidden")
db.commit()
service.set_progress(db, user, "v1", 0)
service.set_progress(db, user, "v2", 0)
db.commit()
assert _state(db, user, "v1") is None
assert (_state(db, user, "v2").status, _state(db, user, "v2").position_seconds) == ("hidden", 0)
# --- playlists -------------------------------------------------------------------------------
def test_watch_later_is_made_once(db, make_user):
user = make_user()
first = service.watch_later(db, user)
db.commit()
assert service.watch_later(db, user) is first
assert first.kind == "watch_later"
assert [p.id for p in queries.playlists_for(db, user)] == [first.id]
def test_an_add_is_idempotent_and_lands_where_asked(db, make_user):
user = make_user()
_videos(db, "a", "b", "c")
pl = service.create_playlist(db, user, "L")
assert service.add_item(db, pl, "a", mark_dirty=False) is True
assert service.add_item(db, pl, "a", mark_dirty=False) is False
assert service.add_item(db, pl, "b", mark_dirty=True) is True
assert service.restore_items(db, pl, ["c", "a"], [0, 2]) == 1 # "a" is there: it stays put
assert _uncommitted(db)
db.commit()
assert _ids(db, pl) == ["c", "a", "b"]
assert service.remove_item(db, pl, "a", mark_dirty=True) is True
assert service.remove_item(db, pl, "a", mark_dirty=True) is False
db.commit()
assert _ids(db, pl) == ["c", "b"]
assert queries.item_counts(db, [pl.id]) == {pl.id: 2}
assert queries.durations(db, [pl.id]) == {pl.id: 120}
assert queries.containing(db, [pl.id], "b") == {pl.id}
def test_a_move_never_leaves_a_video_in_two_lists(db, make_user):
user = make_user()
_videos(db, "a", "b")
src = service.create_playlist(db, user, "S")
dst = service.create_playlist(db, user, "D")
service.add_item(db, src, "a", mark_dirty=False)
service.add_item(db, src, "b", mark_dirty=False)
service.add_item(db, dst, "b", mark_dirty=False)
db.commit()
with pytest.raises(service.MoveRefused, match="same_playlist"):
service.move_items(db, src, src, ["a"])
assert service.move_items(db, src, dst, ["a"]) == (["a"], [])
# already in the target: dropped from the source
assert service.move_items(db, src, dst, ["b"]) == ([], ["b"])
db.commit()
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
assert service.move_items(db, src, dst, ["a"]) == ([], [])
def test_a_reorder_keeps_what_it_left_out_after_the_listed(db, make_user):
user = make_user()
_videos(db, "a", "b", "c")
pl = service.create_playlist(db, user, "L")
for v in ("a", "b", "c"):
service.add_item(db, pl, v, mark_dirty=False)
db.commit()
assert service.reorder_items(db, pl, ["c", "zzz", "b"]) == 3
db.commit()
assert _ids(db, pl) == ["c", "b", "a"]
def test_a_rename_and_a_delete(db, make_user):
user = make_user()
_videos(db, "a")
pl = service.create_playlist(db, user, "Old")
service.add_item(db, pl, "a", mark_dirty=False)
service.rename_playlist(db, pl, "New")
db.commit()
assert queries.playlist_totals(db, pl.id) == (1, 60, None)
assert pl.name == "New"
service.delete_playlist(db, pl)
db.commit()
assert db.execute(select(PlaylistItem)).first() is None
assert db.get(Playlist, pl.id) is None
# --- what the other units ask ----------------------------------------------------------------
def test_a_video_someone_holds_is_kept_and_its_playlisters_are_known(db, make_user):
user = make_user()
_videos(db, "watched", "listed", "also", "loose")
service.mark(db, user, "watched", "watched")
pl = service.create_playlist(db, user, "L")
service.add_item(db, pl, "listed", mark_dirty=False)
service.add_item(db, pl, "also", mark_dirty=False)
db.commit()
kept = db.execute(select(Video.id).where(queries.kept_by_a_reader(Video.id))).scalars()
assert sorted(kept) == ["also", "listed", "watched"]
assert queries.playlisted_by(db, ["listed", "loose"]) == [(user.id, "listed")]
# --- the comfort shelf -----------------------------------------------------------------------
def test_a_pin_is_one_per_thing_and_only_its_owners(db, make_user):
user, stranger = make_user(), make_user()
_videos(db, "a")
pin = pins.add(db, user, "video_id", "a")
db.commit()
assert pins.pin_of(db, user, "video_id", "a") is pin
assert pins.pin_of(db, stranger, "video_id", "a") is None
assert pins.own_pin(db, stranger, pin.id) is None
with pytest.raises(IntegrityError):
pins.add(db, user, "video_id", "a")
db.rollback()
pins.remove(db, pins.own_pin(db, user, pin.id))
db.commit()
assert db.execute(select(ComfortPin)).first() is None
def test_a_watch_later_save_that_lost_the_race_uses_the_winners_list(db, make_user, monkeypatch):
"""The first two saves at once both read "no Watch later"; the second must land in the list the
first made, not make one of its own."""
user = make_user()
won = service.watch_later(db, user)
db.commit()
real = queries.watch_later
stale = [True] # this request's first read happened before the winner committed
def read(d, u):
return None if stale and stale.pop() else real(d, u)
monkeypatch.setattr(queries, "watch_later", read)
assert service.watch_later(db, user).id == won.id
db.commit()
assert [p.id for p in queries.playlists_for(db, user) if p.kind == "watch_later"] == [won.id]
def test_a_first_resume_point_beside_the_watched_mark_lands_on_its_row(db, make_user, monkeypatch):
"""The first checkpoint of a video and the end-of-playback mark both read "no row"; the
checkpoint that writes second must update the mark's row, not fail on `uq_user_video`."""
user = make_user()
_videos(db, "v1")
service.mark(db, user, "v1", "watched") # the mark that won, committed
db.commit()
monkeypatch.setattr(queries, "video_state", lambda *a, **k: None) # read before it committed
service.set_progress(db, user, "v1", 90)
db.commit()
db.expire_all()
rows = db.execute(select(VideoState)).scalars().all()
assert [(r.status, r.position_seconds) for r in rows] == [("watched", 90)]