backend/tests/test_fansub_prefs.py
Rama master · réplica de lectura del repositorio original.
import pytest
from db.models.core import FansubPreference
from db.repositories.fansub_prefs import FansubPreferenceRepository
class TestFansubPreferenceModel:
def test_model_has_required_columns(self):
cols = {c.key for c in FansubPreference.__table__.columns}
assert "sonarr_series_id" in cols
assert "preferred_groups_json" in cols
assert "excluded_groups_json" in cols
assert "bonus" in cols
assert "updated_at" in cols
def test_tablename(self):
assert FansubPreference.__tablename__ == "fansub_preferences"
class TestFansubPreferenceRepository:
def test_get_returns_none_for_unknown_series(self, app_ctx):
repo = FansubPreferenceRepository()
assert repo.get_fansub_prefs(series_id=9001) is None
def test_set_and_get_roundtrip(self, app_ctx):
repo = FansubPreferenceRepository()
repo.set_fansub_prefs(
series_id=100,
preferred=["SubsPlease", "Erai-raws"],
excluded=["HorribleSubs"],
bonus=30,
)
result = repo.get_fansub_prefs(series_id=100)
assert result["preferred_groups"] == ["SubsPlease", "Erai-raws"]
assert result["excluded_groups"] == ["HorribleSubs"]
assert result["bonus"] == 30
def test_update_replaces_existing(self, app_ctx):
repo = FansubPreferenceRepository()
repo.set_fansub_prefs(series_id=101, preferred=["GroupA"], excluded=[], bonus=20)
repo.set_fansub_prefs(series_id=101, preferred=["GroupB"], excluded=[], bonus=10)
result = repo.get_fansub_prefs(series_id=101)
assert result["preferred_groups"] == ["GroupB"]
assert result["bonus"] == 10
def test_set_with_empty_lists(self, app_ctx):
repo = FansubPreferenceRepository()
repo.set_fansub_prefs(series_id=102, preferred=[], excluded=[], bonus=0)
result = repo.get_fansub_prefs(series_id=102)
assert result["preferred_groups"] == []
assert result["excluded_groups"] == []
def test_delete_removes_row(self, app_ctx):
repo = FansubPreferenceRepository()
repo.set_fansub_prefs(series_id=103, preferred=["X"], excluded=[], bonus=5)
repo.delete_fansub_prefs(series_id=103)
assert repo.get_fansub_prefs(series_id=103) is None
class TestFansubPrefsRoutes:
def test_get_unknown_series_returns_defaults(self, client):
r = client.get("/api/v1/series/999/fansub-prefs")
assert r.status_code == 200
data = r.get_json()
assert data["series_id"] == 999
assert data["preferred_groups"] == []
assert data["excluded_groups"] == []
assert data["bonus"] == 20
def test_put_sets_prefs(self, client):
r = client.put(
"/api/v1/series/42/fansub-prefs",
json={"preferred_groups": ["SubsPlease"], "excluded_groups": [], "bonus": 25},
)
assert r.status_code == 200
data = r.get_json()
assert data["preferred_groups"] == ["SubsPlease"]
assert data["bonus"] == 25
def test_get_after_put_returns_saved_prefs(self, client):
client.put(
"/api/v1/series/43/fansub-prefs",
json={
"preferred_groups": ["Erai-raws"],
"excluded_groups": ["HorribleSubs"],
"bonus": 15,
},
)
r = client.get("/api/v1/series/43/fansub-prefs")
assert r.status_code == 200
data = r.get_json()
assert data["preferred_groups"] == ["Erai-raws"]
assert data["excluded_groups"] == ["HorribleSubs"]
def test_put_invalid_bonus_rejected(self, client):
r = client.put(
"/api/v1/series/44/fansub-prefs",
json={"preferred_groups": [], "excluded_groups": [], "bonus": "bad"},
)
assert r.status_code == 400
def test_put_groups_must_be_lists(self, client):
r = client.put(
"/api/v1/series/45/fansub-prefs",
json={"preferred_groups": "SubsPlease", "excluded_groups": [], "bonus": 10},
)
assert r.status_code == 400
def test_delete_clears_prefs(self, client):
client.put(
"/api/v1/series/46/fansub-prefs",
json={"preferred_groups": ["X"], "excluded_groups": [], "bonus": 5},
)
r = client.delete("/api/v1/series/46/fansub-prefs")
assert r.status_code == 200
r2 = client.get("/api/v1/series/46/fansub-prefs")
assert r2.get_json()["preferred_groups"] == []
def test_put_strips_empty_entries_from_groups(self, client):
"""Audit 2026-04-30: empty/whitespace strings in either list make
``"" in info`` always-True in _apply_fansub_rules, killing every
result (excluded) or bonusing every result (preferred). The route
now strips/drops them before persisting."""
r = client.put(
"/api/v1/series/47/fansub-prefs",
json={
"preferred_groups": ["SubsPlease", "", " ", "Erai-raws"],
"excluded_groups": ["", "HorribleSubs", " "],
"bonus": 20,
},
)
assert r.status_code == 200
data = r.get_json()
assert data["preferred_groups"] == ["SubsPlease", "Erai-raws"]
assert data["excluded_groups"] == ["HorribleSubs"]
class TestFansubRulesEmptyStringSafety:
"""Direct unit tests for _apply_fansub_rules — the runtime side of the
same audit fix. Even if bad data slipped past the route validation
(historical entries, direct DB writes, JSON-blob migrations), the
scoring loop now ignores empty/whitespace-only strings instead of
silently bonusing or killing every candidate."""
@staticmethod
def _run(preferred, excluded, info, bonus=20):
from wanted_search.scoring import _apply_fansub_rules
results = [{"score": 100, "release_info": info}]
_apply_fansub_rules(results, preferred, excluded, bonus)
return results[0]["score"]
def test_empty_string_in_excluded_does_not_kill_all(self):
# Pre-fix: "" in info always True → score becomes 100-999 = -899.
score = self._run(preferred=[], excluded=[""], info="GroupX 1080p")
assert score == 100, "empty excluded entry must be a no-op"
def test_whitespace_only_excluded_does_not_kill_all(self):
score = self._run(preferred=[], excluded=[" "], info="GroupX 1080p")
assert score == 100
def test_empty_string_in_preferred_does_not_universally_bonus(self):
# Pre-fix: "" in info always True → score becomes 100+20 = 120.
score = self._run(preferred=[""], excluded=[], info="GroupX 1080p", bonus=20)
assert score == 100, "empty preferred entry must be a no-op"
def test_real_excluded_still_kills(self):
score = self._run(preferred=[], excluded=["", "HorribleSubs"], info="HorribleSubs 1080p")
assert score == 100 - 999
def test_real_preferred_still_bonuses(self):
score = self._run(
preferred=["SubsPlease", ""],
excluded=[],
info="SubsPlease 1080p",
bonus=20,
)
assert score == 120
class TestFansubRulesBreakdownSync:
"""Fansub adjustments must be mirrored into score_breakdown so the UI
tooltip total matches the displayed score."""
@staticmethod
def _run(preferred, excluded, info, bonus=20):
from wanted_search.scoring import _apply_fansub_rules
results = [{"score": 100, "release_info": info, "score_breakdown": {"series": 100}}]
_apply_fansub_rules(results, preferred, excluded, bonus)
return results[0]
def test_preferred_bonus_recorded_in_breakdown(self):
r = self._run(preferred=["SubsPlease"], excluded=[], info="SubsPlease 1080p")
assert r["score"] == 120
assert r["score_breakdown"]["fansub_preferred"] == 20
assert sum(r["score_breakdown"].values()) == r["score"]
def test_excluded_penalty_recorded_in_breakdown(self):
r = self._run(preferred=[], excluded=["HorribleSubs"], info="HorribleSubs 1080p")
assert r["score"] == 100 - 999
assert r["score_breakdown"]["fansub_excluded"] == -999
assert sum(r["score_breakdown"].values()) == r["score"]
def test_missing_breakdown_is_tolerated(self):
from wanted_search.scoring import _apply_fansub_rules
results = [{"score": 100, "release_info": "SubsPlease 1080p"}]
_apply_fansub_rules(results, ["SubsPlease"], [], 20)
assert results[0]["score"] == 120