fix: a first watch state and a first Watch later survive two requests at once
The first checkpoint of an item and the final flush beside it both read no state and both inserted; the loser failed on uq_plex_user_item. Two first saves each made a Watch later list. Both are created by an ON CONFLICT DO NOTHING insert and then read; a partial unique index holds one Watch later per user, and the migration folds an existing second list into the first. Plex keeps one per-item state map (rollup.item_state_map).
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"""Migration 0112's data step, DB-lane: a user who already has two Watch later lists keeps the oldest,
|
||||
with the other's videos moved in after its own (one it already held is not added twice), and the
|
||||
one-per-user index then refuses a second list.
|
||||
|
||||
The schema half is checked by test_migrations.py; a video lost on the way is invisible there.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.db import engine
|
||||
|
||||
|
||||
def test_a_second_watch_later_is_folded_into_the_first(db, make_user):
|
||||
user, other = make_user(), make_user()
|
||||
db.add(Channel(id="UCm", title="M"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ("v1", "v2", "v3"):
|
||||
db.add(Video(id=v, channel_id="UCm", published_at=now, duration_seconds=60))
|
||||
db.commit()
|
||||
cfg = Config("alembic.ini")
|
||||
command.downgrade(cfg, "0111_audit_request_id")
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
ids = conn.execute(
|
||||
text(
|
||||
"INSERT INTO playlists (user_id, name, kind, position) VALUES"
|
||||
" (:u, 'Watch later', 'watch_later', 0), (:u, 'Watch later', 'watch_later', 1),"
|
||||
" (:o, 'Watch later', 'watch_later', 0) RETURNING id"
|
||||
),
|
||||
{"u": user.id, "o": other.id},
|
||||
).scalars()
|
||||
first, second, theirs = list(ids)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO playlist_items (playlist_id, video_id, position) VALUES"
|
||||
" (:a, 'v1', 0), (:b, 'v2', 0), (:b, 'v1', 1), (:c, 'v3', 0)"
|
||||
),
|
||||
{"a": first, "b": second, "c": theirs},
|
||||
)
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
with engine.connect() as conn:
|
||||
lists = conn.execute(
|
||||
text("SELECT id, user_id FROM playlists WHERE kind = 'watch_later' ORDER BY id")
|
||||
).all()
|
||||
assert [tuple(r) for r in lists] == [(first, user.id), (theirs, other.id)]
|
||||
items = conn.execute(
|
||||
text(
|
||||
"SELECT playlist_id, video_id, position FROM playlist_items ORDER BY playlist_id, position"
|
||||
)
|
||||
).all()
|
||||
assert [tuple(r) for r in items] == [(first, "v1", 0), (first, "v2", 1), (theirs, "v3", 0)]
|
||||
with pytest.raises(IntegrityError), engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO playlists (user_id, name, kind, position)"
|
||||
" VALUES (:u, 'Watch later', 'watch_later', 2)"
|
||||
),
|
||||
{"u": user.id},
|
||||
)
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.plex import library, queries, service
|
||||
from app.plex import library, queries, rollup, service
|
||||
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
|
||||
from app.plex.query import LibraryFilters
|
||||
|
||||
@@ -71,9 +71,9 @@ def test_a_users_watch_state_is_theirs_alone(db, make_user):
|
||||
_state(db, owner, it, status="watched")
|
||||
assert queries.state(db, owner, it.id).status == "watched"
|
||||
assert queries.state(db, stranger, it.id) is None
|
||||
assert queries.states(db, stranger, [it.id]) == {}
|
||||
assert set(queries.states(db, owner, [it.id])) == {it.id}
|
||||
assert queries.states(db, owner, []) == {}
|
||||
assert rollup.item_state_map(db, stranger, [it.id]) == {}
|
||||
assert set(rollup.item_state_map(db, owner, [it.id])) == {it.id}
|
||||
assert rollup.item_state_map(db, owner, []) == {}
|
||||
|
||||
|
||||
def test_the_grid_shows_the_callers_state_and_hides_only_what_they_hid(db, make_user):
|
||||
@@ -421,3 +421,18 @@ def test_only_the_owners_own_link_pushes_their_watch_state(db, make_user):
|
||||
db.commit()
|
||||
assert watch_sync.link_for_push(db, owner.id).id == link.id
|
||||
assert watch_sync.link_for_push(db, other.id) is None
|
||||
|
||||
|
||||
def test_a_first_write_that_lost_the_race_updates_the_winners_row(db, make_user):
|
||||
"""The first checkpoint of an item and the `final` flush beside it both read "no state"; the one
|
||||
that writes second must land on the row the first made, not fail on `uq_plex_user_item`."""
|
||||
user = make_user()
|
||||
it = _movie(db, _lib(db), "m-race")
|
||||
_state(db, user, it, position_seconds=10) # the request that won, committed
|
||||
st = service.checkpoint(db, user, it.id, None, 42, NOW, dirty=True) # read "none" before it
|
||||
assert (st.position_seconds, st.synced_to_plex) == (42, False)
|
||||
st = service.set_status(db, user, it.id, None, "hidden")
|
||||
assert st.status == "hidden"
|
||||
db.commit()
|
||||
rows = db.scalars(select(PlexState).where(PlexState.item_id == it.id)).all()
|
||||
assert [(r.position_seconds, r.status) for r in rows] == [(42, "hidden")]
|
||||
|
||||
@@ -308,3 +308,21 @@ def test_a_pin_is_one_per_thing_and_only_its_owners(db, make_user):
|
||||
pins.remove(db, pins.own_pin(db, user, pin.id))
|
||||
db.commit()
|
||||
assert db.execute(select(ComfortPin)).first() is None
|
||||
|
||||
|
||||
def test_a_watch_later_save_that_lost_the_race_uses_the_winners_list(db, make_user, monkeypatch):
|
||||
"""The first two saves at once both read "no Watch later"; the second must land in the list the
|
||||
first made, not make one of its own."""
|
||||
user = make_user()
|
||||
won = service.watch_later(db, user)
|
||||
db.commit()
|
||||
real = queries.watch_later
|
||||
stale = [True] # this request's first read happened before the winner committed
|
||||
|
||||
def read(d, u):
|
||||
return None if stale and stale.pop() else real(d, u)
|
||||
|
||||
monkeypatch.setattr(queries, "watch_later", read)
|
||||
assert service.watch_later(db, user).id == won.id
|
||||
db.commit()
|
||||
assert [p.id for p in queries.playlists_for(db, user) if p.kind == "watch_later"] == [won.id]
|
||||
|
||||
Reference in New Issue
Block a user