Ctrl K
ComunidadGuías…

backend/tests/conftest.py

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

Archivo original ↗Preguntar a KiBot
"""Shared pytest fixtures for all tests."""

import os
import shutil
import tempfile
from pathlib import Path

# Resolve once — avoids 8.3 short-name vs long-name mismatch on Windows
_TEMP_DIR = os.path.realpath(tempfile.gettempdir())

# config_crypto writes the Fernet master key to ``{config_dir}/.encryption_key``
# (config_dir = SUBLARR_CONFIG_DIR, default ``/config``). Since 1.6.0 feature #6,
# create_app() encrypts the auto-generated api_key, so ANY test that builds its
# own app (not just the temp_db fixture) triggers that write. ``/config`` is not
# writable on the CI runner → PermissionError. Pin it to a session-local tmp dir
# at import time (before any create_app) so encryption always lands on a writable,
# isolated path. setdefault so an explicit override still wins.
# Only mint a dir when we actually own it — calling mkdtemp() unconditionally
# leaked one per session even when an explicit override won (1888 of them here).
if "SUBLARR_CONFIG_DIR" in os.environ:
    _SESSION_CONFIG_DIR = None
else:
    _SESSION_CONFIG_DIR = tempfile.mkdtemp(prefix="sublarr-cfg-")
    os.environ["SUBLARR_CONFIG_DIR"] = _SESSION_CONFIG_DIR

import pytest

from app import create_app
from config import reload_settings
from db import close_db, get_db, init_db


def pytest_sessionfinish(session, exitstatus):
    """Force-stop every leaked APScheduler daemon thread before pytest exits.

    Some scheduler-fixture teardowns use an aggressive 2-second
    ``shutdown(timeout_s=2)`` which can time out under CI load. APScheduler's
    ``BackgroundScheduler`` then keeps polling the SQLAlchemyJobStore even
    after pytest's ``tmp_path`` cleanup deletes the SQLite file — the daemon
    thread retries forever, hammering stderr with
    ``no such table: apscheduler_jobs`` and stretching a 2-minute pytest
    process to 48+ minutes (the actual GitHub Actions failure on PR #135 /
    Backend job 75162360308). This hook bypasses the per-instance
    ``_shutting_down`` guard and force-shuts every live SublarrScheduler so
    pytest exits cleanly.
    """
    try:
        from services.scheduler import SublarrScheduler

        SublarrScheduler._force_stop_all_for_test_cleanup()
    except Exception:  # noqa: BLE001 — best-effort cleanup, never block exit
        pass
    try:
        from services.background_tasks import shutdown_background

        shutdown_background(wait=False)
    except Exception:  # noqa: BLE001 -- best-effort cleanup, never block exit
        pass
    if _SESSION_CONFIG_DIR:
        shutil.rmtree(_SESSION_CONFIG_DIR, ignore_errors=True)


def _shutdown_event_dispatchers_for_test(app):
    try:
        from app_shutdown import shutdown_event_dispatchers

        shutdown_event_dispatchers(app)
    except Exception:  # noqa: BLE001 -- best-effort cleanup, never hide test failures
        pass


@pytest.fixture
def temp_db(tmp_path):
    """Create a temporary database for testing.

    Everything lives under pytest's ``tmp_path`` so cleanup is *self-healing*.
    The previous ``NamedTemporaryFile(delete=False)`` + best-effort ``unlink``
    lost the file permanently whenever SQLite still held the WAL lock at
    teardown — and it never even tried to remove the ``-wal``/``-shm``
    siblings. On Windows nothing else ever collects ``%TEMP%``, so that leaked
    ~0.7 MB per test: 106k files / 71 GB in six weeks, still growing at ~3 GB
    per day. Under ``tmp_path`` a failed removal is merely deferred — pytest
    garbage-collects the whole session tree on a later run, by which time this
    process has exited and the lock is gone.
    """
    # Beside tmp_path, not inside it. Many tests hand `tmp_path` to the app as
    # a *media folder* and then assert on what it contains; `test.db` and its
    # `-wal`/`-shm` siblings are files, so anywhere under that root they show
    # up. A subdirectory is not enough — the standalone scanner walks
    # recursively and counted them as media. Together that broke 19 tests in
    # test_format_upgrade_alias and test_standalone_scanner.
    #
    # tmp_path.parent is pytest's numbered session directory, which pytest
    # garbage-collects wholesale on a later run — so the self-healing cleanup
    # this fixture is built around still holds. tmp_path.name is unique per
    # test, so two tests cannot collide here.
    db_dir = tmp_path.parent / f"{tmp_path.name}_db"
    db_dir.mkdir(exist_ok=True)
    db_path = str(db_dir / "test.db")
    # Pre-create the empty file so init_db() sees exactly what
    # NamedTemporaryFile used to hand it.
    Path(db_path).touch()

    # Set environment variable
    os.environ["SUBLARR_DB_PATH"] = db_path
    os.environ["SUBLARR_API_KEY"] = ""  # Disable auth for tests
    os.environ["SUBLARR_LOG_LEVEL"] = "ERROR"  # Reduce log noise in tests
    plugins_dir = tmp_path / "plugins"  # CI: /config not writable
    plugins_dir.mkdir()
    os.environ["SUBLARR_PLUGINS_DIR"] = str(plugins_dir)
    # Allow video-sync path-security check to pass for tmp_path fixtures
    os.environ["SUBLARR_MEDIA_PATH"] = _TEMP_DIR

    # Reload settings and initialize database
    reload_settings()
    init_db()

    yield db_path

    # Cleanup — close the DB so pytest's own tmp_path removal can succeed.
    # We deliberately do NOT unlink here: if this fails, pytest's retention
    # sweep reclaims the tree later. Nothing is left behind permanently.
    close_db()
    if "SUBLARR_DB_PATH" in os.environ:
        del os.environ["SUBLARR_DB_PATH"]
    if "SUBLARR_API_KEY" in os.environ:
        del os.environ["SUBLARR_API_KEY"]
    if "SUBLARR_LOG_LEVEL" in os.environ:
        del os.environ["SUBLARR_LOG_LEVEL"]
    if "SUBLARR_PLUGINS_DIR" in os.environ:
        del os.environ["SUBLARR_PLUGINS_DIR"]
    if "SUBLARR_MEDIA_PATH" in os.environ:
        del os.environ["SUBLARR_MEDIA_PATH"]
    reload_settings()  # clear singleton cached DB path


@pytest.fixture(autouse=True)
def _isolate_settings_env():
    """Snapshot + restore os.environ and the settings singleton around every test.

    Several route tests call reload_settings() which reads SUBLARR_* env vars and
    caches a process-global Settings singleton. Under xdist a test that mutates an
    env var (or leaves a reloaded singleton) could leak into later tests on the
    same worker, causing order-dependent flakes (e.g. subtitle_processor media-path
    checks). Restoring both makes each test start from a clean baseline.
    """
    import config_singleton

    snapshot = dict(os.environ)
    yield
    os.environ.clear()
    os.environ.update(snapshot)
    config_singleton._settings = None


@pytest.fixture(autouse=True)
def _reset_budget_singleton():
    """Ensure every test sees a fresh ProviderBudgetManager singleton.

    Prevents order-dependent failures where a test that patches get_budget_manager
    leaves state visible to later tests in the same process.
    """
    from services.provider_budget import reset_singleton_for_tests

    reset_singleton_for_tests()
    yield
    reset_singleton_for_tests()


@pytest.fixture(autouse=True)
def _reset_standalone_legacy_evidence():
    """The arr evidence behind the standalone legacy-key fallback is cached
    per process; a test that stored a Sonarr URL must not switch the fallback
    off for the next test on the same worker."""
    from services import standalone_owner_keys

    standalone_owner_keys.reset_cache()
    yield
    standalone_owner_keys.reset_cache()


@pytest.fixture(autouse=True)
def _reset_suspect_sidecar_cache():
    """The suspect-sidecar lookup is cached per process for 60 s.

    A test that quarantined something left its flags visible to the next test
    on the same worker, so "nothing is suspect" depended on test order.
    """
    from services import suspect_sidecars

    suspect_sidecars.invalidate_cache()
    yield
    suspect_sidecars.invalidate_cache()


@pytest.fixture
def app_ctx(temp_db):
    """Provide Flask application context for DB-layer unit tests."""
    app = create_app(testing=True)
    with app.app_context():
        try:
            yield app
        finally:
            _shutdown_event_dispatchers_for_test(app)


@pytest.fixture
def client(temp_db):
    """Create a test client for Flask app."""
    app = create_app(testing=True)
    app.config["TESTING"] = True
    app.config["WTF_CSRF_ENABLED"] = False

    with app.test_client() as client:
        try:
            yield client
        finally:
            _shutdown_event_dispatchers_for_test(app)


@pytest.fixture
def temp_dir():
    """Create a temporary directory for file operations."""
    temp_path = tempfile.mkdtemp()
    yield temp_path
    shutil.rmtree(temp_path)


@pytest.fixture
def sample_subtitle_file(temp_dir):
    """Create a sample subtitle file for testing."""
    subtitle_path = Path(temp_dir) / "test.ass"
    subtitle_content = """[Script Info]
Title: Test Subtitle
ScriptType: v4.00+

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0,0,0,,Test subtitle line
"""
    subtitle_path.write_text(subtitle_content, encoding="utf-8")
    return str(subtitle_path)


@pytest.fixture
def mock_provider_manager(monkeypatch):
    """Mock the ProviderManager singleton."""
    from unittest.mock import MagicMock

    from providers.base import SubtitleFormat, SubtitleResult

    manager = MagicMock()
    manager.search.return_value = []
    manager.search_and_download_best.return_value = None
    manager.download.return_value = None
    manager._circuit_breakers = {}

    monkeypatch.setattr("providers.get_provider_manager", lambda: manager)
    return manager


@pytest.fixture(autouse=True)
def reset_provider_manager():
    """Ensure each test ends with a clean provider manager singleton.

    Post-test teardown via invalidate_manager() is sufficient: it clears
    both the module-level _manager global (in providers.manager_singleton
    after the B1P T3 refactor) and any Flask app.extensions entry.
    Does NOT conflict with mock_provider_manager — that patches the getter function,
    while this resets the underlying singleton reference.
    """
    # Clear from extensions if an app context is already active (prevents stale mock bleed)
    try:
        from flask import current_app, has_app_context

        if has_app_context():
            current_app.extensions.pop("provider_manager", None)
    except RuntimeError:
        pass
    yield
    import providers as _prov_module

    _prov_module.invalidate_manager()  # post-test: shutdown cleanly, then null


@pytest.fixture(autouse=True)
def reset_wanted_scanner():
    """Reset WantedScanner singleton before each test.

    Symmetric fixture to reset_provider_manager — prevents stale scanner
    state from leaking between tests.
    Post-test uses invalidate_scanner() to stop the scheduler thread cleanly.
    """
    import services.wanted_scanner as _ws_module

    _ws_module._scanner = None
    # Clear from extensions if an app context is already active
    try:
        from flask import current_app, has_app_context

        if has_app_context():
            current_app.extensions.pop("wanted_scanner", None)
    except RuntimeError:
        pass
    yield
    _ws_module.invalidate_scanner()


@pytest.fixture
def mock_sonarr(monkeypatch):
    """Mock SonarrClient — patch at source module so all callers see the mock.

    NOTE: wanted_search.py imports get_sonarr_client lazily inside function bodies,
    so we must patch at sonarr_client.get_sonarr_client, NOT wanted_search.get_sonarr_client.
    Do not combine with mock_provider_manager in the same test if the code path calls both
    get_sonarr_client and get_provider_manager — fixture teardown order is not guaranteed.
    """

    class MockSonarrClient:
        def get_episode(self, episode_id):
            return {
                "id": episode_id,
                "title": "Test Episode",
                "seasonNumber": 1,
                "episodeNumber": 1,
                "series": {
                    "title": "Test Series",
                    "year": 2023,
                    "tvdbId": 12345,
                    "imdbId": "tt1234567",
                    "genres": ["Animation"],
                },
            }

        def get_series(self, series_id):
            return {
                "id": series_id,
                "title": "Test Series",
                "year": 2023,
                "tvdbId": 12345,
            }

    mock_instance = MockSonarrClient()
    monkeypatch.setattr("sonarr_client.get_sonarr_client", lambda *a, **kw: mock_instance)
    return mock_instance


@pytest.fixture
def mock_radarr(monkeypatch):
    """Mock RadarrClient — patch at source module.

    NOTE: Same lazy-import pattern as sonarr — patch at radarr_client.get_radarr_client.
    Do not combine with mock_provider_manager in the same test if the code path calls both
    get_radarr_client and get_provider_manager — fixture teardown order is not guaranteed.
    """

    class MockRadarrClient:
        def get_movie(self, movie_id):
            return {
                "id": movie_id,
                "title": "Test Movie",
                "year": 2023,
                "tmdbId": 99999,
                "imdbId": "tt9999999",
                "genres": ["Action"],
            }

    mock_instance = MockRadarrClient()
    monkeypatch.setattr("radarr_client.get_radarr_client", lambda *a, **kw: mock_instance)
    return mock_instance


@pytest.fixture
def provider_error_factory():
    """Factory for mock providers that raise specific errors on search/download."""

    def _make(error_class=Exception, message="provider error", name="failing_provider"):
        class FailingProvider:
            pass

        FailingProvider.name = name

        def search(self, query):
            raise error_class(message)

        def download(self, result):
            raise error_class(message)

        FailingProvider.search = search
        FailingProvider.download = download

        return FailingProvider()

    return _make


@pytest.fixture
def create_test_subtitle(temp_dir):
    """Factory fixture to create test subtitle files (ASS or SRT)."""

    def _create(fmt="ass", lang="en", lines=None):
        if lines is None:
            lines = ["Hello World", "How are you"]

        base_path = Path(temp_dir) / f"test.{lang}"

        if fmt == "ass":
            content = "[Script Info]\nTitle: Test\nScriptType: v4.00+\n\n"
            content += "[V4+ Styles]\n"
            content += "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
            content += "Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,0,2,10,10,10,1\n\n"
            content += "[Events]\n"
            content += (
                "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
            )
            for i, line in enumerate(lines):
                start = f"0:00:{i * 3 + 1:02d}.00"
                end = f"0:00:{i * 3 + 3:02d}.00"
                content += f"Dialogue: 0,{start},{end},Default,,0,0,0,,{line}\n"
            path = str(base_path) + ".ass"
        else:
            content = ""
            for i, line in enumerate(lines, 1):
                content += f"{i}\n00:00:{(i - 1) * 3 + 1:02d},000 --> 00:00:{(i - 1) * 3 + 3:02d},000\n{line}\n\n"
            path = str(base_path) + ".srt"

        Path(path).write_text(content, encoding="utf-8")
        return path

    return _create


@pytest.fixture
def mock_requests(monkeypatch):
    """Mock requests library for HTTP calls."""
    import requests

    class MockResponse:
        def __init__(self, json_data, status_code=200):
            self.json_data = json_data
            self.status_code = status_code
            self.text = str(json_data)

        def json(self):
            return self.json_data

        def raise_for_status(self):
            if self.status_code >= 400:
                raise requests.exceptions.HTTPError(f"HTTP {self.status_code}")

    def mock_get(*args, **kwargs):
        return MockResponse({})

    def mock_post(*args, **kwargs):
        return MockResponse({})

    monkeypatch.setattr(requests, "get", mock_get)
    monkeypatch.setattr(requests, "post", mock_post)

    return {"get": mock_get, "post": mock_post}


def pytest_collection_modifyitems(config, items):
    """Run only this CI shard's share of the suite (PYTEST_SHARD=i/n, 1-based).

    The serial-per-runner suite took 45 min on a 4-core GitHub runner, so CI
    splits it across parallel jobs. Assignment hashes the test id with crc32
    (stable across processes, unlike ``hash()``), so every xdist worker and the
    controller agree, and the shards partition the suite with no overlap.
    Unset locally: nothing is deselected.
    """
    spec = os.environ.get("PYTEST_SHARD")
    if not spec:
        return
    import zlib

    index, total = (int(part) for part in spec.split("/"))
    if not 1 <= index <= total:
        raise pytest.UsageError(f"PYTEST_SHARD={spec!r}: expected i/n with 1 <= i <= n")
    keep, drop = [], []
    for item in items:
        shard = zlib.crc32(item.nodeid.encode()) % total + 1
        (keep if shard == index else drop).append(item)
    if drop:
        config.hook.pytest_deselected(items=drop)
    items[:] = keep


@pytest.fixture(autouse=True)
def _reset_ui_auth_flag_cache():
    """``ui_auth.is_ui_auth_enabled`` caches its answer on the module for 30 s.
    A test that enables UI auth in its own database left that answer behind,
    and the next tests in the same xdist worker got 401 until it expired —
    the sporadic "Authentication required" failures (proven 2026-09-27 by
    expiring the cache). Every test starts with an empty cache."""
    import ui_auth

    ui_auth._auth_enabled_cache = None
    yield
    ui_auth._auth_enabled_cache = None


@pytest.fixture
def auto_repair_on():
    """Enable the opt-in automatic repairs (services.auto_repair) for a test."""
    from unittest.mock import patch

    with patch("services.auto_repair.auto_repair_enabled", return_value=True):
        yield