Files
siftlode/backend/tests/test_playlist_move.py
T
peter e82f87acb0 refactor(reader): the unit's tables move into reader/models
VideoState, Playlist, PlaylistItem, SavedView, ComfortPin, Tag and ChannelTag leave app/models.py
for app/reader/models.py; app.tables registers them. No schema change.
2026-10-19 00:53:44 +02:00

296 lines
11 KiB
Python

"""The one-step "move this video to another list" endpoint, and the `updated_at` touch
the rail's "recently changed" sort reads.
Two properties are load-bearing and neither is visible from the happy path alone:
1. **The move is one transaction.** The client-side shape it replaces (add to the target, then
remove from the source) leaves the video in BOTH lists when the second call fails. So the
tests assert the *pair* of lists after every call, never just the destination.
2. **`updated_at` tracks item edits.** `UpdatedAtMixin.onupdate` only fires on an UPDATE of the
playlist row, and add/remove/reorder/move write `playlist_items` — so the timestamp is bumped
explicitly, and a regression there would silently degrade the sort to "renames only".
"""
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy.exc import OperationalError
from app.models import Channel, Video
from app.reader.models import Playlist, PlaylistItem
def _seed_videos(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, name, video_ids, **kw):
pl = Playlist(user_id=user.id, name=name, **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(video_ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=i))
db.commit()
return pl
def _ids(db, pl_id):
return [
it.video_id
for it in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _move(client, src, video_id, dst):
return client.post(
f"/api/playlists/{src}/items/{video_id}/move", json={"target_playlist_id": dst}
)
def test_move_leaves_the_video_in_exactly_one_list(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 200
assert r.json() == {
"ok": True,
"from_playlist_id": src.id,
"to_playlist_id": dst.id,
"video_id": "v1",
}
db.expire_all()
assert _ids(db, src.id) == ["v2"]
assert _ids(db, dst.id) == ["v1"]
def test_move_appends_to_the_end_of_the_target(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1", "v2", "v3"])
src = _playlist(db, user, "Source", ["v3"])
dst = _playlist(db, user, "Target", ["v1", "v2"])
assert _move(as_user(user), src.id, "v3", dst.id).status_code == 200
db.expire_all()
assert _ids(db, dst.id) == ["v1", "v2", "v3"]
def test_move_into_a_list_that_already_has_it_is_a_removal_not_a_duplicate(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", ["v1"])
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"] # exactly one row, not two
def test_move_to_the_same_list_is_refused(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
r = _move(as_user(user), src.id, "v1", src.id)
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist_move", "reason": "same_playlist"}
db.expire_all()
assert _ids(db, src.id) == ["v1"]
def test_move_of_a_video_that_left_the_source_reports_it(db, make_user, as_user):
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", [])
dst = _playlist(db, user, "Target", [])
r = _move(as_user(user), src.id, "v1", dst.id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "playlist_move", "reason": "not_in_playlist"}
def test_move_into_someone_elses_list_404s_and_changes_nothing(db, make_user, as_user):
user, other = make_user(), make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Mine", ["v1"])
theirs = _playlist(db, other, "Theirs", [])
assert _move(as_user(user), src.id, "v1", theirs.id).status_code == 404
db.expire_all()
assert _ids(db, src.id) == ["v1"]
assert _ids(db, theirs.id) == []
def test_a_list_that_is_not_yours_answers_with_a_reason_the_ui_can_translate(
db, make_user, as_user
):
# The one 404 a user meets in practice (the list went away in another tab). It used to be a bare
# English "Unknown playlist", which the Hungarian UI then showed verbatim.
user, other = make_user(), make_user()
_seed_videos(db, ["v1"])
theirs = _playlist(db, other, "Theirs", ["v1"])
r = as_user(user).delete(f"/api/playlists/{theirs.id}/items/v1")
assert r.status_code == 404
assert r.json()["detail"] == {"code": "not_found", "reason": "playlist"}
db.expire_all()
assert _ids(db, theirs.id) == ["v1"]
def test_a_move_into_a_list_that_is_gone_names_the_target_not_the_list_on_screen(
db, make_user, as_user
):
# Both lists 404'd with the same reason, which the UI reads as "THIS playlist doesn't exist any
# more" — said about the source the reader is looking at, which is still there.
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Mine", ["v1"])
gone = _playlist(db, user, "Gone", [])
gone_id = gone.id
db.delete(gone)
db.commit()
r = _move(as_user(user), src.id, "v1", gone_id)
assert r.status_code == 404
assert r.json()["detail"] == {"code": "not_found", "reason": "move_target"}
db.expire_all()
assert _ids(db, src.id) == ["v1"]
def test_a_refused_playlist_action_answers_with_a_reason_the_ui_can_translate(
db, make_user, as_user
):
# The rest of the router's refusals were bare English too ("The Watch later list can't be
# deleted.", "YouTube delete failed.", ...), shown verbatim under the Hungarian UI.
user = make_user()
client = as_user(user)
wl = client.post("/api/playlists/watch-later", json={"video_id": "nope"})
assert wl.status_code == 404
assert wl.json()["detail"] == {"code": "not_found", "reason": "video"}
_seed_videos(db, ["v1"])
saved = client.post("/api/playlists/watch-later", json={"video_id": "v1"})
wl_id = saved.json()["playlist_id"]
r = client.delete(f"/api/playlists/{wl_id}")
assert r.status_code == 400
assert r.json()["detail"] == {"code": "playlist", "reason": "watch_later_undeletable"}
def test_move_marks_both_linked_sides_dirty(db, make_user, as_user):
"""A move edits two YouTube mirrors at once — the source lost an item, the target gained one.
Marking only the source is the asymmetric fix this repo keeps re-learning."""
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Src", ["v1"], yt_playlist_id="PLsrc", synced_video_ids='["v1"]')
dst = _playlist(db, user, "Dst", [], yt_playlist_id="PLdst", synced_video_ids="[]")
# Baseline: both are in sync with YouTube before the move.
src.dirty = False
dst.dirty = False
db.commit()
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).dirty is True
assert db.get(Playlist, dst.id).dirty is True
def test_item_edits_bump_updated_at(db, make_user, as_user):
"""The rail's "recently changed first" sort reads this. Adding, moving and reordering all
write `playlist_items` only, so nothing here is covered by the ORM's `onupdate`."""
user = make_user()
_seed_videos(db, ["v1", "v2"])
src = _playlist(db, user, "Source", ["v1", "v2"])
dst = _playlist(db, user, "Target", [])
old = datetime.now(timezone.utc) - timedelta(days=3)
src.updated_at = old
dst.updated_at = old
db.commit()
client = as_user(user)
assert client.post(f"/api/playlists/{dst.id}/items", json={"video_id": "v1"}).status_code == 200
db.expire_all()
assert db.get(Playlist, dst.id).updated_at > old
dst.updated_at = old
db.commit()
assert (
client.put(f"/api/playlists/{src.id}/order", json={"video_ids": ["v2", "v1"]}).status_code
== 200
)
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
src.updated_at = old
dst.updated_at = old
db.commit()
assert _move(client, src.id, "v2", dst.id).status_code == 200
db.expire_all()
# BOTH sides of a move are "recently changed" — the target is where the reader will look.
assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at > old
# …and removing one, the fourth item-only write (it had no test).
src.updated_at = old
db.commit()
assert client.delete(f"/api/playlists/{src.id}/items/v1").status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
user = make_user()
_playlist(db, user, "Solo", [])
rows = as_user(user).get("/api/playlists").json()
assert len(rows) == 1
assert rows[0]["updated_at"] # the rail cannot sort by a field the API never sends
def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_user):
"""Moving into a list that already holds the video only removes it from the source: the
target did not change, and bumping it promoted an untouched list in the rail's sort."""
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", ["v1"])
old = datetime.now(timezone.utc) - timedelta(days=3)
src.updated_at = old
dst.updated_at = old
db.commit()
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert db.get(Playlist, src.id).updated_at > old
assert db.get(Playlist, dst.id).updated_at == old
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
"""The already-in-target check is a read; it holds because the move locks both lists first and
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
from sqlalchemy import text
from app.db import SessionLocal
from app.routes import playlists as plroutes
user = make_user()
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", [])
plroutes.lock_playlists(db, src.id, dst.id) # what the move holds until its commit
with SessionLocal() as other:
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
with pytest.raises(OperationalError, match="lock timeout"):
other.flush()
db.rollback()