fix(playlists): a racing move is not a 500; ownership through owned_or_404 everywhere
move_item checked "already in the target?" and then re-parented without a lock, so a second tab's add or the scheduler's YouTube merge committing the same video in between hit uq_playlist_video and answered a raw 500. The re-parent now runs in a savepoint and a conflict falls back to the already-present outcome. The target is touched only when the row landed in it (an untouched list was promoted in the rail's "recently changed" sort), and _touch stamps the database clock like the mixin's own onupdate. The canonical-patterns table names owned_or_404 against ad-hoc ownership helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view, _own_tag, notifications._owned, downloads._own_job), two of them hand-rolled with a bare English 404. All call sites now use owned_or_404, and the download/link/notification 404s are structured like the rest. The contract test reads every "what is gone" the routes name, and the YouTube refusal reasons, and requires both languages to name each.
This commit is contained in:
@@ -242,3 +242,49 @@ def test_the_list_endpoint_publishes_updated_at(db, make_user, as_user):
|
||||
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_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
|
||||
YouTube merge) can commit the same video to the target in between, and the re-parent then
|
||||
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
|
||||
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", [])
|
||||
real = plroutes._next_item_position
|
||||
|
||||
def add_first(session, playlist_id):
|
||||
with SessionLocal() as other: # the other tab, committing between check and re-parent
|
||||
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
||||
other.commit()
|
||||
return real(session, playlist_id)
|
||||
|
||||
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user