"""E2E: Users, per-user overrides, triggering runs, and reading the result. The through-line: a control the owner touches in the browser must change what the ENGINE does on the next run. A toggle that only repaints the UI is the failure this file exists to catch. """ from __future__ import annotations import re import time from collections.abc import Callable import pytest from playwright.sync_api import Page, expect from tests.e2e.conftest import ShortlistApp, build_real_rows from tests.fakes.fake_plex import DEMO_MOVIES, DEMO_SHOWS pytestmark = pytest.mark.e2e def open_rows(page) -> None: """Open every collapsed row card on a run page. A person's results live inside the row that built them, and a row with siblings starts closed. Re-queries each time rather than iterating a snapshot: clicking one row re-renders the list, which detaches every handle taken before it. """ for _ in range(8): closed = page.get_by_role("button", expanded=False) if not closed.count(): return closed.first.click() LOAD = 20_000 SLOW = 90_000 def _users_by_name(app: ShortlistApp) -> dict[str, dict]: return {user["username"]: user for user in app.api("GET", "/api/users").json()} def _wait_until(condition: Callable[[], bool], message: str, timeout_s: float = 10.0) -> None: """Poll the API until a persisted value shows up — robust to the auto-save debounce, which the 'Saved' indicator can race (it flips before the PUT the debounce fired has fully committed).""" deadline = time.monotonic() + timeout_s while time.monotonic() < deadline: if condition(): return time.sleep(0.25) raise AssertionError(message) class TestTheOwnerIsAUserToo: """The person running Shortlist gets a row like anyone else (issue #1). plex.tv's `/api/users` returns everyone the server is shared WITH and never its owner, so before this the owner had no user row at all — and a one-person server built nothing. """ def test_a_server_where_only_the_owner_is_enabled_still_builds_a_row( self, page: Page, app: ShortlistApp, reset_fake_plex ): """The solo-server case end to end: sync finds the owner, and a run gives them real picks from their own watch history — not the popular-titles fallback, which is what an empty history would silently produce.""" state = reset_fake_plex app.api("POST", "/api/users/sync") owner = _users_by_name(app)[state.owner_username] assert owner["user_type"] == "owner" assert owner["enabled"] is False # arrives switched off, like every other new user for user in _users_by_name(app).values(): app.api("PATCH", f"/api/users/{user['id']}", json={"enabled": user["id"] == owner["id"]}) run = build_real_rows(app) rows = [c for c in state.collections.values() if any(lbl.lower() == "shortlist_steve" for lbl in c.labels)] assert rows, "the owner is the only enabled user and got no row at all" for collection in rows: assert collection.item_keys assert collection.promoted_own_home, "the owner's row never reached the owner's own Home" assert run["stats"]["users_ok"] == 1 # Their picks come from THEIR history — PMS files the owner's plays under a local account # id, so asking by plex.tv id returns nothing and this row would be the cold-start one. page.goto(f"/users/{owner['id']}") expect(page.get_by_role("heading", name=state.owner_username)).to_be_visible(timeout=LOAD) expect(page.get_by_text("New viewer")).to_have_count(0) def test_the_owners_row_is_excluded_on_every_share_plex_will_accept_one_for( self, app: ShortlistApp, reset_fake_plex ): """The owner's own filter is never written (Plex cannot restrict them) — that must not be mistaken for 'this row needs no hiding'. Everyone else still has to exclude it. Everyone Plex ALLOWS, that is. A managed account with a parental profile is the one exception: plex.tv answers 422 to any label restriction while the profile is set, so no exclude can exist on that share no matter what Shortlist does. Asserting it anyway would be asserting a write that cannot succeed. What must hold instead is that the account is REPORTED rather than silently passed over — which is the rest of this test (#76).""" state = reset_fake_plex app.api("POST", "/api/users/sync") owner = _users_by_name(app)[state.owner_username] app.api("PATCH", f"/api/users/{owner['id']}", json={"enabled": True}) build_real_rows(app) unfilterable = {u.id for u in state.users.values() if u.restriction_profile} assert unfilterable, "the roster must carry a profiled account, or this proves only the easy half" for account_id, user in state.users.items(): if account_id in unfilterable: continue for field_name in ("filterMovies", "filterTelevision"): assert "Shortlist_steve" in user.filters[field_name], ( f"account {account_id} can see the owner's row in {field_name}" ) # The exception is not a blind spot: the run looked through that account's own eyes and said # what it found. A skip that reported nothing is the bug this whole check exists for. listed = _users_by_name(app) for account_id in unfilterable: username = state.users[account_id].username assert listed[username]["unhidden_rows"] > 0, ( f"{username} was skipped by the filter write AND reported as seeing nothing — " "the exact silence that hid this for a year" ) class TestUsers: def test_selected_pause_survives_reload_without_changing_access_or_other_people( self, page: Page, app: ShortlistApp ): before = _users_by_name(app) page.goto("/users") page.get_by_label("Search users").fill("sarah") # The checkboxes appear once selecting is switched on. page.get_by_role("button", name="Select people", exact=True).click() page.get_by_role("checkbox", name="Select sarah", exact=True).check() page.get_by_role("button", name="Pause rebuilding", exact=True).click() _wait_until( lambda: _users_by_name(app)["sarah"]["prefs"].get("paused") is True, "selected pause must reach the stored preferences", ) page.reload() sarah = page.get_by_role("row").filter(has=page.get_by_role("link", name="sarah", exact=True)) expect(sarah.get_by_text("Paused", exact=True)).to_be_visible(timeout=LOAD) paused = _users_by_name(app) assert {name: user["enabled"] for name, user in paused.items()} == { name: user["enabled"] for name, user in before.items() } for name in before.keys() - {"sarah"}: assert paused[name]["prefs"] == before[name]["prefs"] page.get_by_role("button", name="Select people", exact=True).click() # a reload leaves selecting off page.get_by_role("checkbox", name="Select sarah", exact=True).check() page.get_by_role("button", name="Resume rebuilding", exact=True).click() _wait_until( lambda: _users_by_name(app)["sarah"]["prefs"].get("paused") is False, "selected resume must reach the stored preferences", ) assert _users_by_name(app)["sarah"]["enabled"] == before["sarah"]["enabled"] def test_disabling_a_user_persists_and_leaves_the_others_alone(self, page: Page, app: ShortlistApp): page.goto("/users") jess = page.get_by_role("switch", name="Shortlist row for jess") expect(jess).to_be_checked(timeout=LOAD) jess.click() expect(jess).not_to_be_checked(timeout=LOAD) page.reload() expect(page.get_by_role("switch", name="Shortlist row for jess")).not_to_be_checked(timeout=LOAD) # Exactly one user changed — a broadcast PATCH would pass a "it persisted" test too. users = _users_by_name(app) assert users["jess"]["enabled"] is False assert users["sarah"]["enabled"] is True assert users["mike"]["enabled"] is True def test_per_user_overrides_persist(self, page: Page, app: ShortlistApp): sarah_id = _users_by_name(app)["sarah"]["id"] page.goto(f"/users/{sarah_id}") expect(page.get_by_role("heading", name="sarah")).to_be_visible(timeout=LOAD) # Pause is a user-level control in the header (switch is "on" when active). paused = page.get_by_role("switch", name="Pause or resume sarah") expect(paused).to_be_checked(timeout=LOAD) paused.click() expect(paused).not_to_be_checked(timeout=LOAD) # Size is a PER-ROW override now: open a row's drawer, switch on a custom size and type 10. # The drawer AUTO-SAVES (no Save button — the whole app is one save paradigm). page.get_by_role("button", name="Customize for this person").first.click() page.get_by_role("switch", name="Custom row size for this person").click() titles = page.get_by_label("Titles for this person") titles.fill("10") titles.blur() # the free number field commits on blur expect(page.get_by_text("Saved").first).to_be_visible(timeout=LOAD) # Pause reaches the user's prefs; the size override lives on the row, not user prefs. Poll the # API for the persisted value rather than trusting the indicator, which races the debounce. assert _users_by_name(app)["sarah"]["prefs"]["paused"] is True expect_row_size = 10 def _size_persisted() -> bool: rows = app.api("GET", f"/api/users/{sarah_id}/rows").json() return any(r["override"].get("row_size") == expect_row_size for r in rows) _wait_until(_size_persisted, "the per-row size override must persist") # Exactly this user changed — mike's prefs stay empty. assert _users_by_name(app)["mike"]["prefs"] == {} def test_a_paused_user_is_skipped_by_the_next_run(self, page: Page, app: ShortlistApp, reset_fake_plex): """The override has to reach the ENGINE, not just the users table.""" state = reset_fake_plex sarah_id = _users_by_name(app)["sarah"]["id"] page.goto(f"/users/{sarah_id}") expect(page.get_by_role("heading", name="sarah")).to_be_visible(timeout=LOAD) # The header pause switch is "on" when active; clicking it pauses this user. paused = page.get_by_role("switch", name="Pause or resume sarah") expect(paused).to_be_checked(timeout=LOAD) paused.click() expect(paused).not_to_be_checked(timeout=LOAD) run = build_real_rows(app) built = {result["slug"] for result in app.api("GET", f"/api/runs/{run['id']}").json()["users"]} assert built == {"mike", "jess"}, "a paused user must not be rebuilt" labels = {label.lower() for c in state.collections.values() for label in c.labels} # sarah's OWNER label is the one that must be absent — that is what "not rebuilt" looks like # on the server. The constant `shortlist` label is on every row we write and names nobody, so # it is expected here and says nothing about who was built. assert labels == {"shortlist", "shortlist_mike", "shortlist_jess"}, ( "sarah's OWNER label must be absent — that is what 'not rebuilt' looks like on the server" ) assert "shortlist_sarah" not in labels class TestRuns: def test_a_run_started_from_the_ui_lands_in_runs_with_per_user_rows( self, page: Page, app: ShortlistApp, reset_fake_plex ): state = reset_fake_plex page.goto("/runs") expect(page.get_by_text("No run history")).to_be_visible(timeout=LOAD) page.get_by_role("button", name="Run all rows now").click() # The row appears the moment the run is queued — the owner is never left guessing. run_link = page.get_by_role("link", name="#1") expect(run_link).to_be_visible(timeout=LOAD) expect(page.get_by_role("cell", name="manual")).to_be_visible() app.wait_for_run(1) page.reload() # The result cell leads with the title-cased status ("OK · 1 warning") — scoped to the table, # since the page's stats bar also shows "OK" as the last-run status. The users cell keeps "3 ok". expect(page.get_by_role("table").get_by_role("cell", name=re.compile(r"^OK\b"))).to_be_visible(timeout=LOAD) expect(page.get_by_role("cell", name="3 ok")).to_be_visible() # 5 rows for 3 users: sarah and the cold-start jess each get one per library; mike watches only TV. assert len(state.collections) == 5 page.get_by_role("link", name="#1").click() expect(page.get_by_role("heading", name="Run #1")).to_be_visible(timeout=LOAD) # Run detail is tabbed, and Rows lands first. A person's results live inside the row that # built them, so open every row before reading them. open_rows(page) # Every user is a clickable tab in the run's nav; the selected one's rows show below it. for username in ("sarah", "mike", "jess"): expect(page.get_by_role("tab", name=re.compile(username, re.IGNORECASE))).to_be_visible() def test_run_detail_shows_what_changed(self, page: Page, app: ShortlistApp, reset_fake_plex): """The diff is the audit trail (rule 10): everything Added on the first run, and on the next run the staleness guard rotates fresh titles in — without ever shrinking a row.""" state = reset_fake_plex first = build_real_rows(app) # The engine must record a per-(row, library) breakdown — the whole run page is built from it. run = app.api("GET", f"/api/runs/{first['id']}").json() assert any(b["added"] for u in run["users"] for b in u["breakdown"]), "no per-library breakdown recorded" page.goto(f"/runs/{first['id']}") expect(page.get_by_role("heading", name=f"Run #{first['id']}")).to_be_visible(timeout=LOAD) open_rows(page) # First run: every pick is new, so the selected user's row shows a "+N new" badge and nothing # removed. Picks render as a ranked list, and their titles are answerable from here. expect(page.get_by_text(re.compile(r"\+\d+ new")).first).to_be_visible() expect(page.get_by_text(re.compile(r"\d+ removed"))).to_have_count(0) # Asserted against a title the API says was actually delivered, rather than a pattern the # demo library's names happen to match — those are real film and show titles now, chosen so # the docs screenshots look like a real library, and they will change again. delivered = next(p["title"] for u in run["users"] for b in u["breakdown"] for p in b["picks"]) expect(page.locator("body")).to_contain_text(delivered) def row_sizes() -> dict[str, int]: sizes: dict[str, int] = {} for collection in state.collections.values(): for label in collection.labels: if label.lower().startswith("shortlist_"): sizes[label.lower()] = sizes.get(label.lower(), 0) + len(collection.item_keys) return sizes before = row_sizes() second = build_real_rows(app) page.goto(f"/runs/{second['id']}") expect(page.get_by_role("heading", name=f"Run #{second['id']}")).to_be_visible(timeout=LOAD) open_rows(page) # "Don't repeat the last 3 runs' picks" rotates the row; it must never leave it short. # When fresh candidates run out, held-back titles backfill — a row shrinking (or being # pruned) because the pool was thin for one night would be a regression, not variety. assert row_sizes() == before, "a re-run must keep every row exactly as full as it was" for user in app.api("GET", f"/api/runs/{second['id']}").json()["users"]: diff = user["diff"] assert len(diff.get("added", [])) == len(diff.get("removed", [])), ( f"{user['slug']}: a rotated-out title must be replaced, not simply dropped" ) # The second run's page renders its users (the per-user nav), so its results are reachable. expect(page.get_by_role("tab").first).to_be_visible(timeout=LOAD) def test_a_users_picks_explain_themselves(self, page: Page, app: ShortlistApp): """Every pick carries its "Because you watched …" reason — the product's whole promise.""" build_real_rows(app) sarah_id = _users_by_name(app)["sarah"]["id"] run_id = app.api("GET", "/api/runs").json()[0]["id"] sarah_picks = next( u["picks"] for u in app.api("GET", f"/api/runs/{run_id}").json()["users"] if u["slug"] == "sarah" ) page.goto(f"/users/{sarah_id}") expect(page.get_by_role("heading", name="sarah")).to_be_visible(timeout=LOAD) # Every poster carries its own reason under the title, so the first such caption is what # shows the page has loaded. reason_re = re.compile(r"Because you watched .+|Worth another watch|They asked for these") expect(page.get_by_text(re.compile(r"^Because you watched .+")).first).to_be_visible(timeout=LOAD) # Rows collapse to the first 5 picks (collapseAfter=5), so expand every row before counting: # this test asserts EVERY pick carries a reason, not just the first few. # Re-query each iteration: clicking one toggle re-renders the DOM and stales the locator list. while (toggle := page.get_by_role("button", name=re.compile(r"Show all \d+")).first).is_visible(): toggle.click() # Every pick is a list item, and every one of them names why it is there. sarah watches movies # AND TV, so both libraries' rows show a reason. picks = page.get_by_role("listitem").filter(has_text=re.compile(r"suggested by")) expect(picks).to_have_count(len(sarah_picks)) expect(picks.filter(has_text=reason_re)).to_have_count(len(sarah_picks)) titles = {p["title"] for p in sarah_picks} assert titles & {t for t, _ in DEMO_MOVIES} and titles & {t for t, _ in DEMO_SHOWS}, ( "sarah's row should mix both libraries — otherwise this test proves nothing about them" ) # Ranked, and the rank the engine chose is the rank shown. expect(page.get_by_text(sarah_picks[0]["title"], exact=True).first).to_be_visible() def test_the_jess_gets_the_cold_start_row_and_says_so(self, page: Page, app: ShortlistApp): """No history -> the popular-titles fallback, labelled honestly rather than faked.""" build_real_rows(app) jess_id = _users_by_name(app)["jess"]["id"] page.goto(f"/users/{jess_id}") expect(page.get_by_role("heading", name="jess")).to_be_visible(timeout=LOAD) expect(page.get_by_text("New viewer").first).to_be_visible() expect(page.get_by_text("Popular on this server").first).to_be_visible()