Files
siftlode/backend/tests/test_playlist_move.py
T
peter dcd8a9566a fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
  selected list: launched from Watch later (no Remove, by design), selecting another list under it
  made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
  editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
  "Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
  came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
  checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
  still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
  the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
  `removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
  whether or not the removed one left the queue; it now takes the second step that tells them apart.
2026-09-11 03:19:07 +02:00

207 lines
7.3 KiB
Python

"""E10 S2 — 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
from app.models import Channel, Playlist, PlaylistItem, Video
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": "playlist", "reason": "not_found"}
db.expire_all()
assert _ids(db, theirs.id) == ["v1"]
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
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