Ctrl K
ComunidadGuías…

backend/tests/test_issue_199_197_revive.py

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

Archivo original ↗Preguntar a KiBot
"""#199 part 2 + #197 — give exhausted items a bounded second chance.

Attempt exhaustion is permanent and provider-blind. An item that burned its
attempts while a provider was broken stays parked forever, and fixing that
provider afterwards does nothing for it. Two triggers — elapsed time, and a
provider becoming usable again — share one mechanism, because both mean the
same thing: the earlier verdict is stale.
"""

from datetime import UTC, datetime, timedelta

from db.models.core import WantedItem
from db.repositories.wanted import WantedRepository
from extensions import db


def _seed(**kw):
    now = datetime.now(UTC)
    base = {
        "item_type": "episode",
        "title": "t",
        "file_path": "/x.mkv",
        "status": "wanted",
        "search_count": 0,
        "added_at": now,
        "updated_at": now,
    }
    base.update(kw)
    row = WantedItem(**base)
    db.session.add(row)
    db.session.commit()
    return row


class TestFindExhaustedIds:
    def test_finds_only_the_ones_that_gave_up(self, app_ctx):
        repo = WantedRepository()
        now = datetime.now(UTC)
        trying = _seed(file_path="/trying.mkv", search_count=1)
        gave_up = _seed(file_path="/gaveup.mkv", search_count=3)
        slow = _seed(
            file_path="/slow.mkv",
            search_count=3,
            failure_kind="no_result_slow",
            retry_after=now + timedelta(days=10),
        )
        try:
            found = set(repo.find_exhausted_ids(max_attempts=3))
            assert gave_up.id in found
            assert trying.id not in found
            assert slow.id not in found, "slow-mode items have not given up"
        finally:
            for r in (trying, gave_up, slow):
                db.session.delete(r)
            db.session.commit()

    def test_idle_since_excludes_recently_searched(self, app_ctx):
        repo = WantedRepository()
        now = datetime.now(UTC)
        fresh = _seed(file_path="/fresh.mkv", search_count=3, last_search_at=now)
        stale = _seed(
            file_path="/stale.mkv", search_count=3, last_search_at=now - timedelta(days=40)
        )
        try:
            found = set(
                repo.find_exhausted_ids(max_attempts=3, idle_since=now - timedelta(days=30))
            )
            assert stale.id in found
            assert fresh.id not in found
        finally:
            for r in (fresh, stale):
                db.session.delete(r)
            db.session.commit()

    def test_limit_caps_one_run(self, app_ctx):
        """A large parked backlog has to come back in slices — the reporting
        install had 869 exhausted items at once."""
        repo = WantedRepository()
        rows = [_seed(file_path=f"/cap{i}.mkv", search_count=3) for i in range(5)]
        try:
            assert len(repo.find_exhausted_ids(max_attempts=3, limit=2)) == 2
        finally:
            for r in rows:
                db.session.delete(r)
            db.session.commit()


class TestReviveByAge:
    def test_disabled_by_default_does_nothing(self, app_ctx, monkeypatch):
        """Reviving on its own schedule is a behaviour change nobody asked
        for on upgrade."""
        from services import wanted_revive

        row = _seed(
            file_path="/off.mkv",
            search_count=3,
            last_search_at=datetime.now(UTC) - timedelta(days=999),
        )
        try:
            assert wanted_revive.revive_exhausted_by_age() == 0
            db.session.refresh(row)
            assert row.search_count == 3
        finally:
            db.session.delete(row)
            db.session.commit()

    def test_enabled_revives_the_old_ones_only(self, app_ctx, monkeypatch):
        from services import wanted_revive

        now = datetime.now(UTC)
        old = _seed(file_path="/old.mkv", search_count=3, last_search_at=now - timedelta(days=90))
        recent = _seed(
            file_path="/recent.mkv", search_count=3, last_search_at=now - timedelta(days=2)
        )

        class S:
            wanted_max_search_attempts = 3
            wanted_adaptive_backoff_enabled = True
            wanted_revive_exhausted_after_days = 30
            wanted_revive_max_per_run = 200

        monkeypatch.setattr(wanted_revive, "peek_settings", lambda: S(), raising=False)
        monkeypatch.setattr("config.peek_settings", lambda: S())
        try:
            assert wanted_revive.revive_exhausted_by_age() == 1
            db.session.refresh(old)
            db.session.refresh(recent)
            assert old.search_count == 0, "the parked item is back in rotation"
            assert old.retry_after is None
            assert recent.search_count == 3, "a recent failure is not stale yet"
        finally:
            for r in (old, recent):
                db.session.delete(r)
            db.session.commit()


class TestReviveAfterProviderChange:
    def test_no_age_restriction(self, app_ctx, monkeypatch):
        """The point of #197 is retroactive effect: fixing a provider should
        help items that gave up five minutes ago as much as ones from
        months back."""
        from services import wanted_revive

        class S:
            wanted_max_search_attempts = 3
            wanted_adaptive_backoff_enabled = True
            wanted_revive_max_per_run = 200

        monkeypatch.setattr("config.peek_settings", lambda: S())
        recent = _seed(
            file_path="/justnow.mkv",
            search_count=3,
            last_search_at=datetime.now(UTC),
        )
        try:
            assert wanted_revive.revive_after_provider_change(["jimaku"]) == 1
            db.session.refresh(recent)
            assert recent.search_count == 0
        finally:
            db.session.delete(recent)
            db.session.commit()


class TestTheSettingsAreReachable:
    """Since 1.14.0 /config validates its keys against the _SettingsView
    subclasses. A setting no view declares is a setting nobody can turn on —
    the feature would ship switched off with no way to switch it on."""

    def _declared(self) -> set[str]:
        import config_views

        fields: set[str] = set()
        for name in dir(config_views):
            obj = getattr(config_views, name)
            declared = getattr(obj, "_fields", None)
            if isinstance(declared, frozenset):
                fields |= set(declared)
        return fields

    def test_the_new_keys_are_declared(self, app_ctx):
        declared = self._declared()
        assert "wanted_revive_exhausted_after_days" in declared
        assert "wanted_revive_max_per_run" in declared

    def test_the_check_would_notice_a_missing_one(self, app_ctx):
        """Guards the guard: if _declared() returned everything, the test
        above would pass for any key at all."""
        assert "definitely_not_a_setting" not in self._declared()


class TestReviveReachesTheRoute:
    """The two triggers above were only ever tested by calling them directly.

    The route that fires the provider trigger was not, and that is where it
    broke: ``update_config`` assigns a local ``_provider_keys`` set partway
    through, which shadows the module-level function of the same name for the
    whole function body. The later call therefore tried to call a set, raised
    TypeError, and the surrounding ``except Exception`` — there so a bonus
    behaviour can never fail the settings save — logged a warning and moved
    on. Silent, and on every single provider change since the feature shipped.
    """

    def test_enabling_a_provider_revives_the_items_that_gave_up(self, client):
        # The payload has to be an actual addition. Saving "opensubtitles" over
        # the default empty value looks like enabling one provider but is the
        # opposite — empty means every provider is enabled, so that save turns
        # six of them off. #214 is exactly this confusion, and the revive no
        # longer fires on it, so the starting point is now stated explicitly.
        with client.application.app_context():
            from db.config import save_config_entry

            save_config_entry("providers_enabled", "opensubtitles")
            gave_up = _seed(file_path="/parked.mkv", search_count=3)
            item_id = gave_up.id

        resp = client.put("/api/v1/config", json={"providers_enabled": "opensubtitles,subdl"})
        assert resp.status_code == 200, resp.get_data(as_text=True)[:200]

        with client.application.app_context():
            row = db.session.get(WantedItem, item_id)
            assert row.search_count == 0, (
                "an item that gave up stayed parked after a provider was enabled"
            )

    def test_a_setting_that_is_not_a_provider_leaves_the_backlog_alone(self, client):
        """Every settings save must not reset the queue — only provider ones."""
        with client.application.app_context():
            gave_up = _seed(file_path="/untouched.mkv", search_count=3)
            item_id = gave_up.id

        resp = client.put("/api/v1/config", json={"log_level": "INFO"})
        assert resp.status_code == 200, resp.get_data(as_text=True)[:200]

        with client.application.app_context():
            row = db.session.get(WantedItem, item_id)
            assert row.search_count == 3