"""Adding a video AT a position: the list page's "Undo" of a removal. The row has to come back where it was in ONE request. The two-call shape (append, then send the whole order) leaves it at the end of the list whenever the second call fails, and the reorder would also overwrite any change another tab made in between. Positions can have gaps after removals, so the position is an index over the ORDERED rows, and the list is renumbered around it. """ from datetime import datetime, timezone from app.models import Channel, Playlist, PlaylistItem, Video def _seed(db, user, ids, *, positions=None, **kw): db.add(Channel(id="UCtest", title="Test")) now = datetime.now(timezone.utc) for v in [*ids, "vX"]: db.add(Video(id=v, channel_id="UCtest", published_at=now)) pl = Playlist(user_id=user.id, name="L", **kw) db.add(pl) db.flush() for i, vid in enumerate(ids): db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i])) db.commit() return pl def _ids(db, pl_id): db.expire_all() return [ r.video_id for r in db.execute( PlaylistItem.__table__.select() .where(PlaylistItem.playlist_id == pl_id) .order_by(PlaylistItem.position) ).all() ] def _add(client, pl_id, body): return client.post(f"/api/playlists/{pl_id}/items", json=body) def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user): user = make_user() pl = _seed(db, user, ["a", "b", "c"]) r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1}) assert r.status_code == 200 assert _ids(db, pl.id) == ["a", "vX", "b", "c"] def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user): # A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2". user = make_user() pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9]) _add(as_user(user), pl.id, {"video_id": "vX", "position": 2}) assert _ids(db, pl.id) == ["a", "b", "vX", "c"] def test_a_position_past_the_end_appends(db, make_user, as_user): user = make_user() pl = _seed(db, user, ["a", "b"]) _add(as_user(user), pl.id, {"video_id": "vX", "position": 40}) assert _ids(db, pl.id) == ["a", "b", "vX"] def test_no_position_still_appends(db, make_user, as_user): user = make_user() pl = _seed(db, user, ["a", "b"]) _add(as_user(user), pl.id, {"video_id": "vX"}) assert _ids(db, pl.id) == ["a", "b", "vX"] def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user): # The add is idempotent; a position must not turn it into a reorder. user = make_user() pl = _seed(db, user, ["a", "b", "c"]) _add(as_user(user), pl.id, {"video_id": "c", "position": 0}) assert _ids(db, pl.id) == ["a", "b", "c"] def test_a_negative_position_is_refused(db, make_user, as_user): user = make_user() pl = _seed(db, user, ["a"]) r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1}) assert r.status_code == 422 assert _ids(db, pl.id) == ["a"] def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user): # Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it. user = make_user() pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx") client = as_user(user) from app.sync.playlists import current_fingerprint pl.synced_fingerprint = current_fingerprint(db, pl) pl.dirty = False db.commit() assert client.delete(f"/api/playlists/{pl.id}/items/b").status_code == 200 db.expire_all() assert db.get(Playlist, pl.id).dirty is True _add(client, pl.id, {"video_id": "b", "position": 1}) db.expire_all() assert db.get(Playlist, pl.id).dirty is False