tests/e2e/test_settings_e2e.py
Rama dev · réplica de lectura del repositorio original.
"""E2E: the Settings page — connection tests, defaults, schedule, and the way out.
Settings is where an owner goes when something is wrong, so every control here has to tell the
truth: a Test button that says "Connected" when it isn't, or a Save that silently doesn't, is
worse than no button at all.
"""
from __future__ import annotations
import re
import pytest
from playwright.sync_api import Page, expect
from tests.e2e.conftest import ShortlistApp, build_real_rows
from tests.fakes.fake_plex import FakePlexState
pytestmark = pytest.mark.e2e
LOAD = 20_000
SLOW = 60_000
def _open_settings(page: Page, section: str = "connections") -> None:
page.goto(f"/settings#{section}")
expect(page.get_by_role("heading", name="Settings", exact=True)).to_be_visible(timeout=LOAD)
def test_requests_shortcut_reveals_connections_and_preserves_the_request_form(page: Page, app: ShortlistApp):
"""Section shortcuts switch tabs without resetting the Requests form."""
app.api("PUT", "/api/settings", json={"values": {"requests.enabled": True}})
_open_settings(page, "requests")
page.get_by_role("button", name="Go to Connections", exact=True).first.click()
expect(page).to_have_url(re.compile(r"/settings/connections#connections$"))
expect(page.get_by_test_id("connection-radarr")).to_be_visible()
# Requests lives on the Defaults tab now. A tab out of view stays mounted (hidden), so the form
# is kept rather than torn down and rebuilt — attached, not visible, is that promise.
expect(page.locator("#requests")).to_be_attached()
page.goto("/settings#requests")
expect(page.get_by_role("button", name="Radarr & Sonarr")).to_have_attribute("aria-pressed", "true")
assert app.api("GET", "/api/settings").json()["requests.enabled"] is True
class TestConnectionCards:
def test_every_test_button_reports_the_real_state_of_its_connection(self, page: Page, app: ShortlistApp):
"""All four services, each hitting its real client — not one happy-path card.
Tautulli is deliberately unconfigured here, which is the state a fresh install is in:
the card must say so in plain English instead of claiming success.
"""
_open_settings(page)
plex = page.get_by_test_id("connection-plex")
plex.get_by_role("button", name="Test").click()
# Spelled out, not "PMS": the abbreviation is ours, and the owner reading this card has no
# reason to know it (audit finding, Sep 2026).
expect(plex).to_contain_text(
f"Connected to {FakePlexState.friendly_name} (Plex Media Server {FakePlexState.version})",
timeout=LOAD,
)
tmdb = page.get_by_test_id("connection-tmdb")
tmdb.get_by_role("button", name="Test").click()
expect(tmdb).to_contain_text("TMDB key works", timeout=LOAD)
# "No AI" is a choice, not a connection: the card offers one "Set up" and no Test, because
# there is nothing to test until a provider is chosen.
llm = page.get_by_test_id("connection-llm")
expect(llm.get_by_role("button", name="Set up")).to_be_visible(timeout=LOAD)
expect(llm.get_by_role("button", name="Test")).to_have_count(0)
# An unconfigured connection says so plainly and never claims a connection it doesn't have —
# and its Test is disabled until a key is on file (you can't test what isn't set up), rather
# than letting the owner test nothing and read a raw error.
#
# Asserted on the status label the card carries next to its state dot, NOT on a sentence in
# the body. The body used to end "Not set up yet — choose Set up to connect." on every
# unconfigured card, which is five identical sentences down one page, each directly under a
# button reading "Set up". The state is still stated; it is stated once.
tautulli = page.get_by_test_id("connection-tautulli")
expect(tautulli).to_contain_text("Not set up")
# `status` renders only inside an `sr-only` span, and `to_contain_text` reads textContent —
# so the line above passes off an invisible string and would stay green if every visible
# signal regressed. The visible one is the button, which reads "Set up" precisely when the
# connection isn't.
expect(tautulli.get_by_role("button", name="Set up")).to_be_visible()
# No Test at all until a key is on file: there is nothing to test.
expect(tautulli.get_by_role("button", name="Test")).to_have_count(0)
expect(tautulli).not_to_contain_text("Connected —")
class TestDefaults:
def test_a_local_ai_server_can_be_pointed_at_from_settings(self, page: Page, app: ShortlistApp):
"""The wizard is only walked once; everyone who adds a local AI server later does it HERE.
Covers the whole path an existing owner takes: pick the provider, get a URL field at all,
type the address their server is known by, and have it persist under the right key (#7).
"""
page.goto("/settings")
# The provider config lives on the merged "AI & Web search" card (testId kept from when it
# was the standalone AI-provider card, so this scopes to it without matching field labels).
card = page.get_by_test_id("connection-llm")
expect(card).to_be_visible(timeout=LOAD)
card.get_by_role("button", name=re.compile("^(Edit|Set up)$")).first.click()
# A local model has no web search of its own, so it is only offered under a backend that
# does the searching for it. Choosing Exa first is what a real owner has to do, and picking
# the backend BEFORE the provider is the order the card asks for.
page.get_by_label("Where to search").get_by_role("button", name="Exa").click()
# "AI provider" renders as a segmented group, not a <select> — pick the option by its label.
page.get_by_label("AI provider").get_by_role("button", name=re.compile(r"^Local")).click()
# The URL field must APPEAR — without it there is no way to say where the server is.
url = page.get_by_label("Server URL")
expect(url).to_be_visible(timeout=LOAD)
url.fill("http://llama.local:8080")
page.get_by_role("button", name="Save").first.click()
app.wait_for_setting("curator.provider", "openai_compatible", timeout_s=5)
settings = app.api("GET", "/api/settings").json()
assert settings["curator.provider"] == "openai_compatible"
assert settings["curator.openai_base_url"] == "http://llama.local:8080"
def test_row_name_and_size_survive_a_reload(self, page: Page, app: ShortlistApp):
_open_settings(page, "defaults")
row_name = page.get_by_role("textbox", name="Row name template")
row_name.fill("🍿 Tonight's picks for {top_seed}")
# The preview must show what Plex will show, not the raw template.
expect(page.get_by_text("🍿 Tonight's picks for Fargo")).to_be_visible()
# How many titles is a free number field now; blur commits the typed value.
row_size = page.get_by_role("spinbutton", name="How many titles")
row_size.fill("22")
row_size.blur()
# No Save button — the section auto-saves (debounced). Poll until it reaches the database.
app.wait_for_setting("row.name_template", "🍿 Tonight's picks for {top_seed}", timeout_s=6)
app.wait_for_setting("row.size", 22, timeout_s=6)
# Reload: only a value that reached the database can come back.
page.reload()
expect(page.get_by_role("textbox", name="Row name template")).to_have_value(
"🍿 Tonight's picks for {top_seed}", timeout=LOAD
)
expect(page.get_by_role("spinbutton", name="How many titles")).to_have_value("22", timeout=LOAD)
def test_pause_all_stops_runs_without_disabling_anyone(self, page: Page, app: ShortlistApp):
"""The Danger Zone switch must actually pause runs — it used to 422 as an unknown key."""
_open_settings(page, "danger")
page.get_by_role("button", name="Pause all").click()
# It persisted...
expect(page.get_by_role("alert")).to_have_count(0, timeout=LOAD)
app.wait_for_setting("paused_all", True, timeout_s=5)
# ...and a run now processes nobody, while every user stays enabled.
run_id = app.api("POST", "/api/runs", json={"dry_run": True}).json()["run_id"]
run = app.wait_for_run(run_id)
assert run["stats"].get("users_ok", 0) == 0
assert run["stats"].get("users_error", 0) == 0
assert all(u["enabled"] for u in app.api("GET", "/api/users").json())
class TestDangerZone:
def test_uninstall_dry_run_previews_the_damage_without_doing_any(
self, page: Page, app: ShortlistApp, reset_fake_plex
):
"""The preview must be honest about what would go, and must not touch Plex.
This is the trust feature: an owner who cannot see what uninstall would do will never
press it. So the preview lists every collection by name — and afterwards the server is
byte-for-byte unchanged.
"""
state = reset_fake_plex
build_real_rows(app)
before_collections = {c.rating_key: c.title for c in state.collections.values()}
before_filters = {user.id: dict(user.filters) for user in state.users.values()}
# 5 rows for 3 users: sarah and the cold-start jess each get one per library; mike watches only TV.
assert len(before_collections) == 5
_open_settings(page, "danger")
# Uninstall is its own page now; the Danger Zone links to it.
page.get_by_role("link", name="Uninstall Shortlist…").click()
expect(page.get_by_role("heading", name="Uninstall Shortlist")).to_be_visible(timeout=LOAD)
# The preview loads on arrival; there is no button to press for it.
body = page.locator("body")
expect(body).to_contain_text("5 collections", timeout=SLOW)
expect(body).to_contain_text("3 share filters")
expect(body).to_contain_text("Preview only — nothing was changed.")
for title in before_collections.values():
expect(body).to_contain_text(title)
# The destructive button stays locked until the phrase is typed — a preview is not consent.
commit = page.get_by_role("button", name="Uninstall and restore server")
expect(commit).to_be_disabled()
# "Keep Shortlist" is the way out — it goes back to Settings without touching anything.
page.get_by_role("link", name="Keep Shortlist").click()
expect(page.get_by_role("heading", name="Settings", exact=True)).to_be_visible(timeout=LOAD)
# Nothing moved on the fake Plex: not one collection, not one share filter.
assert {c.rating_key: c.title for c in state.collections.values()} == before_collections
assert {user.id: dict(user.filters) for user in state.users.values()} == before_filters
# (The committed uninstall lives in test_privacy_uninstall_e2e.py — this test is only
# about the promise that a PREVIEW costs nothing.)
def test_held_tags_preview_the_inbox_and_survive_a_reload(page: Page, app: ShortlistApp):
"""Pick a TMDB tag, see which waiting movies it would hold, and find it still picked after a reload."""
from pathlib import Path
from shortlist.server.db.models import RequestCandidate
from shortlist.server.db.session import make_engine, make_session_factory
from tests.db_helpers import disposing_engine
from tests.e2e.conftest import THANKSGIVING_TAG
app.api("PUT", "/api/settings", json={"values": {"requests.enabled": True, "requests.auto_send": True}})
with disposing_engine(make_engine(Path(app.config_dir))) as engine, make_session_factory(engine)() as session:
# 9011 carries the fake's thanksgiving tag; 9999 carries none.
session.add(RequestCandidate(tmdb_id=9011, media_type="movie", title="Tagged film", status="pending", demand=3))
session.add(
RequestCandidate(tmdb_id=9999, media_type="movie", title="Untagged film", status="pending", demand=2)
)
session.commit()
_open_settings(page, "requests")
expect(page.get_by_text("Nothing picked, so any movie can be requested automatically")).to_be_visible(timeout=LOAD)
page.get_by_label("Search TMDB tags").fill("thanks")
page.get_by_role("button", name="Add tag thanksgiving", exact=True).click()
held = page.get_by_role("list", name="Movies these picks would hold")
expect(page.get_by_text("Would hold 1 of the 2 movies waiting in your inbox:")).to_be_visible(timeout=LOAD)
expect(held).to_contain_text("Tagged film")
expect(held).to_contain_text("tag “thanksgiving”")
expect(held).not_to_contain_text("Untagged film")
app.wait_for_setting("requests.hold_tags", {str(THANKSGIVING_TAG): "thanksgiving"}, timeout_s=6)
page.reload()
expect(page.get_by_role("button", name="Remove tag thanksgiving")).to_be_visible(timeout=LOAD)