"""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, 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