fix(playlists): a push stops at a list deleted mid-way, and a rename made during it stays unsynced
A delete landing while a push read YouTube left a plan over no items, so the push emptied the YouTube playlist a local-only delete keeps: the push now stops at a vanished list (PlaylistGone, 404), and a delete is refused while a sync of the list runs. The closing baseline fingerprints the title YouTube got, not the one its lock re-reads, so a rename made during the writes stays dirty. The other-tab test helper bounds its lock wait, so a lock-order regression fails instead of hanging.
This commit is contained in:
@@ -67,7 +67,12 @@ def test_push_and_revert_refuse_while_the_list_is_claimed(
|
||||
r = c.post(f"/api/playlists/{pl.id}/{path}")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
|
||||
r = c.delete(f"/api/playlists/{pl.id}")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||
assert ran == []
|
||||
assert db.get(Playlist, pl.id) is not None
|
||||
|
||||
# Released on the way out: the same list syncs again, and a claim is per list.
|
||||
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
|
||||
|
||||
@@ -10,11 +10,12 @@ during its YouTube writes. The "other tab" here is a second session that commits
|
||||
talking to YouTube — when the push holds no lock, as a real one does not.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import delete, event, text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
@@ -151,9 +152,16 @@ def _playlist(db, user, local, *, source="local", baseline=None):
|
||||
return pl
|
||||
|
||||
|
||||
def _other_tab(pl_id, *, add=None, remove=None):
|
||||
"""A second session's edit, committed at once — what another tab's request does."""
|
||||
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
|
||||
"""A second session's edit, committed at once — what another tab's request does. It runs on
|
||||
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
|
||||
wait is bounded and a regression fails instead of hanging the gate."""
|
||||
with SessionLocal() as s:
|
||||
s.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||
if drop:
|
||||
s.execute(delete(Playlist).where(Playlist.id == pl_id))
|
||||
if rename:
|
||||
s.get(Playlist, pl_id).name = rename
|
||||
if add:
|
||||
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
|
||||
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
|
||||
@@ -227,6 +235,48 @@ def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_
|
||||
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
|
||||
|
||||
|
||||
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
|
||||
matched the rename, and the list read synced with YouTube still on the old title."""
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
|
||||
if not linked:
|
||||
pl.yt_playlist_id = None
|
||||
db.commit()
|
||||
fake = fake_yt(
|
||||
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
|
||||
)
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
db.refresh(pl)
|
||||
assert (pl.name, fake.title) == ("Mix 2", "Mix")
|
||||
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
|
||||
|
||||
|
||||
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
|
||||
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
|
||||
emptied the YouTube playlist the delete meant to keep."""
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"])
|
||||
# a video YouTube would not return last time, retried now — the read the delete lands in
|
||||
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
|
||||
db.commit()
|
||||
fake = fake_yt(
|
||||
FakeYT(
|
||||
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
|
||||
)
|
||||
)
|
||||
raised = None
|
||||
try:
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
|
||||
raised = exc
|
||||
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
|
||||
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||
user = make_user()
|
||||
|
||||
Reference in New Issue
Block a user