Ctrl K
ComunidadGuías…

tests/e2e/test_settings_e2e.py

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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)