A reorder's left-out item, a playlisted video beside one not asked about, another user's tag, and the user's own channel link committed before the auto-tagger reads the table.
311 lines
12 KiB
Python
311 lines
12 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.models import Channel, 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 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)
|
|
service.clear_state(db, user, "v2")
|
|
db.commit()
|
|
assert _state(db, user, "v2") is None
|
|
|
|
|
|
# --- 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
|
|
service.add_to_playlist(db, pl, "b", None)
|
|
service.add_to_playlist(db, pl, "c", 0)
|
|
service.add_to_playlist(db, pl, "a", 2) # already there: 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_item(db, src, src, "a")
|
|
service.move_item(db, src, dst, "a")
|
|
service.move_item(db, src, dst, "b") # already in the target: dropped from the source
|
|
db.commit()
|
|
assert (_ids(db, src), _ids(db, dst)) == ([], ["b", "a"])
|
|
with pytest.raises(service.MoveRefused, match="not_in_playlist"):
|
|
service.move_item(db, src, dst, "a")
|
|
db.rollback()
|
|
|
|
|
|
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
|