- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks the playlist before it touches an item, a move both lists in id order: remove beside reorder was a deadlock, and two adds of one video a unique-key 500. - The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls is not replaced by YouTube's order. - A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only on a pull that runs. - Deleted mirrors are committed at once, not only when a later list commits. - The claims open an unpooled connection of their own instead of a third pooled one per push.
295 lines
11 KiB
Python
295 lines
11 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
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import OperationalError
|
|
|
|
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": "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: E10 review coverage debt).
|
|
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()
|