"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it. A push reads YouTube and then writes both sides for seconds, committing step by step, so the client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock on a connection of its own; the test holds it from outside, the way a second request would. """ from sqlalchemy import text from app.auth import require_write_scope from app.db import SessionLocal from app.models import Playlist from app.routes import playlists as plroutes from app.sync import playlists as plsync def _linked(db, user, *, source="local"): pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123") db.add(pl) db.commit() return pl class FakeYT: """YouTube as far as these routes read it: two playlists, none with items.""" def __init__(self, *_): pass def __enter__(self): return self def __exit__(self, *_): return False def iter_my_playlists(self): return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}] def iter_my_playlist_video_ids(self, _ytid): return [] def iter_playlist_items_with_ids(self, _ytid): return [] def get_playlist_snippet(self, _ytid): return {} def test_push_and_revert_refuse_while_the_list_is_claimed( db, as_user, make_user, override_dep, monkeypatch ): user = make_user() pl = _linked(db, user) override_dep(require_write_scope, lambda: user) monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True) ran: list[str] = [] monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT) monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0}) monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {}) monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {}) c = as_user(user) with plsync.sync_claim(db, pl.id) as free: assert free for path in ("push", "revert-youtube"): 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 assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200 assert ran == ["push", "revert"] def test_a_claim_is_released_when_its_block_raises(db, make_user): pl = _linked(db, make_user()) try: with plsync.sync_claim(db, pl.id) as free: assert free raise RuntimeError("boom") except RuntimeError: pass # Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted # again to that same connection (advisory locks are re-entrant per session). held = db.scalar( text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"), {"id": pl.id}, ) assert held == 0 def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user): """A claim is held for a whole push beside the request's pooled session; drawn from the pool too, a burst of pushes ran it dry and stalled every other request for pool_timeout.""" from app.db import engine pl = _linked(db, make_user()) before = engine.pool.checkedout() with plsync.sync_claim(db, pl.id) as free: assert free assert engine.pool.checkedout() == before def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch): user = make_user() busy = _linked(db, user, source="youtube") replaced: list[int] = [] monkeypatch.setattr(plsync, "has_read_scope", lambda u: True) monkeypatch.setattr(plsync, "YouTubeClient", FakeYT) monkeypatch.setattr( plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id) ) with plsync.sync_claim(db, busy.id): out = plsync.sync_user_playlists(db, user) assert out == {"synced": 1} assert busy.id not in replaced and len(replaced) == 1 db.refresh(busy) assert busy.name == "L" def test_refused_reverts_do_not_spend_the_pull_cooldown( db, as_user, make_user, override_dep, monkeypatch ): """Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking Revert while a push runs is refused as busy without using up a pull.""" user = make_user() pl = _linked(db, user) monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True) monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {}) c = as_user(user) with plsync.sync_claim(db, pl.id): for _ in range(4): assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409 assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200 def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch): """Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror.""" user = make_user() monkeypatch.setattr(plsync, "has_read_scope", lambda u: True) monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True) monkeypatch.setattr(plsync, "YouTubeClient", FakeYT) monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, p, ids: None) c = as_user(user) with plsync.user_sync_claim(db, user.id): assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"} for _ in range(4): r = c.post("/api/playlists/sync-youtube") assert r.status_code == 409 assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"} assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2} def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch): """Every remaining mirror has unpushed edits, so nothing below the deletion commits.""" user = make_user() gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone") kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True) other = Playlist( user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True ) db.add_all([gone, kept, other]) db.commit() gone_id = gone.id monkeypatch.setattr(plsync, "has_read_scope", lambda u: True) monkeypatch.setattr(plsync, "YouTubeClient", FakeYT) assert plsync.sync_user_playlists(db, user) == {"synced": 0} db.rollback() # what the request's session close does to an uncommitted deletion assert db.get(Playlist, gone_id) is None def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch): """The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another request meanwhile must win over YouTube's order.""" user = make_user() pl = _linked(db, user, source="youtube") replaced: list[int] = [] class EditedMeanwhile(FakeYT): def iter_my_playlists(self): return [{"id": "PL123", "title": "Renamed"}] def iter_my_playlist_video_ids(self, _ytid): with SessionLocal() as other: other.get(Playlist, pl.id).dirty = True other.commit() return [] monkeypatch.setattr(plsync, "has_read_scope", lambda u: True) monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile) monkeypatch.setattr( plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id) ) assert plsync.sync_user_playlists(db, user) == {"synced": 0} assert replaced == [] db.expire_all() assert db.get(Playlist, pl.id).name == "L"