159 lines
10 KiB
Python
159 lines
10 KiB
Python
from contextlib import ExitStack
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from backend.app.config import settings
|
|
from backend.app.models import NormalizedState, RequestType, Snapshot
|
|
from backend.app.services import snapshot as snapshot_service
|
|
from backend.app.services.collector_search import read_search_status, search_status
|
|
|
|
|
|
def command(name="MoviesSearch", status="started", **body):
|
|
return {"name": name, "status": status, "body": body}
|
|
|
|
|
|
class CollectorSearchTests(unittest.IsolatedAsyncioTestCase):
|
|
def test_movie_search_is_scoped_to_the_movie(self):
|
|
self.assertEqual(search_status([command(movieIds=[12])], RequestType.movie, 12), "searching")
|
|
self.assertEqual(search_status([command(movieIds=[13])], RequestType.movie, 12), "idle")
|
|
|
|
def test_queued_search_and_running_search_priority(self):
|
|
queued = command(status="queued", movieIds=[12])
|
|
self.assertEqual(search_status([queued], RequestType.movie, 12), "queued")
|
|
self.assertEqual(search_status([queued, command(movieIds=[12])], RequestType.movie, 12), "searching")
|
|
|
|
def test_terminal_commands_are_not_searching(self):
|
|
for state in ["completed", "failed", "aborted", "cancelled", "orphaned", 2, 3, 4, 5, 6]:
|
|
with self.subTest(state=state):
|
|
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), "idle")
|
|
ended = {**command(movieIds=[12]), "ended": "2026-09-06T00:00:00Z"}
|
|
self.assertEqual(search_status([ended], RequestType.movie, 12), "idle")
|
|
|
|
def test_numeric_statuses(self):
|
|
for state, expected in [(0, "queued"), (1, "searching")]:
|
|
self.assertEqual(search_status([command(status=state, movieIds=[12])], RequestType.movie, 12), expected)
|
|
|
|
def test_series_and_season_searches(self):
|
|
for name in ["SeriesSearch", "SeasonSearch"]:
|
|
with self.subTest(name=name):
|
|
self.assertEqual(search_status([command(name, seriesId=12, seasonNumber=5)], RequestType.tv, 12), "searching")
|
|
self.assertEqual(search_status([command(name, seriesId=13, seasonNumber=5)], RequestType.tv, 12), "idle")
|
|
|
|
def test_episode_search_uses_episode_ids_not_numbers(self):
|
|
episodes = [{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9}]
|
|
for ids, expected in [([109], "searching"), ([9], "idle"), ([110], "idle")]:
|
|
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=ids)], RequestType.tv, 12, episodes), expected)
|
|
self.assertEqual(search_status([command("EpisodeSearch", episodeIds=[109])], RequestType.tv, 13, episodes), "idle")
|
|
|
|
def test_background_tasks_and_unscoped_searches_are_not_title_searches(self):
|
|
for name in ["RssSync", "RefreshMovie", "RefreshSeries", "MissingEpisodeSearch", "MoviesSearch"]:
|
|
with self.subTest(name=name):
|
|
self.assertEqual(search_status([command(name)], RequestType.movie, 12), "idle")
|
|
|
|
def test_empty_commands_are_idle_but_missing_response_is_unknown(self):
|
|
self.assertEqual(search_status([], RequestType.movie, 12), "idle")
|
|
for payload in [None, {}, {"error": "unavailable"}]:
|
|
self.assertEqual(search_status(payload, RequestType.movie, 12), "unavailable")
|
|
|
|
async def test_check_is_read_only_with_a_short_timeout(self):
|
|
client = SimpleNamespace(get=AsyncMock(return_value=[command(movieIds=[12])]))
|
|
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "searching")
|
|
client.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
|
|
|
async def test_service_failure_is_unknown_not_idle(self):
|
|
client = SimpleNamespace(get=AsyncMock(side_effect=TimeoutError()))
|
|
self.assertEqual(await read_search_status(client, RequestType.movie, 12), "unavailable")
|
|
|
|
|
|
class LibrarySearchPresentationTests(unittest.TestCase):
|
|
def presentation(self, search="idle", *, media_type=RequestType.movie, available=0, missing=1,
|
|
arr_state="added", download_state="not_started", jellyfin=False):
|
|
snapshot = Snapshot(request_id="12", title="Example", request_type=media_type,
|
|
state=NormalizedState.added_to_arr)
|
|
return snapshot_service._build_presentation(
|
|
snapshot, approved=True, arr_state=arr_state,
|
|
arr_details={"search": {"state": search}, "availability": {
|
|
"available": available, "missing": missing, "total": available + missing,
|
|
}}, prowlarr_state="ok",
|
|
download={"visible": download_state != "not_started", "state": download_state, "torrents": []},
|
|
jellyfin_found=jellyfin, jellyfin_link=None,
|
|
)
|
|
|
|
def stage(self, presentation, stage_id="library"):
|
|
return next(stage for stage in presentation["pipeline"] if stage["id"] == stage_id)
|
|
|
|
def test_card_uses_actual_search_state(self):
|
|
for state, badge, style in [("idle", "Not searching", "waiting"), ("searching", "Searching", "active"),
|
|
("queued", "Search queued", "active"), ("unavailable", "Search unknown", "attention")]:
|
|
with self.subTest(state=state):
|
|
presentation = self.presentation(state)
|
|
library = self.stage(presentation)
|
|
self.assertEqual(library["stateLabel"], badge)
|
|
self.assertEqual(library["state"], style)
|
|
self.assertEqual(library["searchStatus"], state)
|
|
self.assertEqual(self.stage(presentation, "search")["summary"], library["summary"])
|
|
self.assertEqual(library["available"], 0)
|
|
self.assertEqual(library["missing"], 1)
|
|
|
|
def test_partial_tv_retains_counts_and_search_activity(self):
|
|
for state in ["idle", "searching", "queued", "unavailable"]:
|
|
with self.subTest(state=state):
|
|
presentation = self.presentation(state, media_type=RequestType.tv, available=22, missing=2, jellyfin=True)
|
|
library = self.stage(presentation)
|
|
self.assertEqual(library["state"], "partial")
|
|
self.assertEqual(library["searchStatus"], state)
|
|
self.assertIn("22 of 24 episodes collected", library["summary"])
|
|
self.assertNotIn("is still looking", presentation["status"]["meaning"])
|
|
|
|
def test_collected_titles_dont_look_stuck_searching(self):
|
|
for jellyfin in [True, False]:
|
|
library = self.stage(self.presentation("idle", arr_state="available", available=1, missing=0, jellyfin=jellyfin))
|
|
self.assertEqual(library["state"], "complete")
|
|
self.assertIn("no search needed", library["summary"])
|
|
|
|
def test_download_has_its_own_state_without_claiming_searching(self):
|
|
library = self.stage(self.presentation("idle", download_state="downloading"))
|
|
self.assertEqual(library["stateLabel"], "Downloading")
|
|
self.assertIn("Not currently searching", library["summary"])
|
|
|
|
def test_an_old_missing_download_does_not_mark_search_complete(self):
|
|
search = self.stage(self.presentation("idle", download_state="missing"), "search")
|
|
self.assertEqual(search["state"], "waiting")
|
|
|
|
|
|
class SearchSnapshotIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_movie_eligibility_is_not_search_activity_and_tv_commands_are_checked(self):
|
|
for media_type in [RequestType.movie, RequestType.tv]:
|
|
for commands, expected in [([], "idle"), ([command("MoviesSearch", movieIds=[12]), command("EpisodeSearch", episodeIds=[109])], "searching")]:
|
|
with self.subTest(media_type=media_type, search=expected), ExitStack() as stack:
|
|
runtime = settings.model_copy(update={"requests_data_source": "prefer_cache"})
|
|
item = {"id": 12, "title": "Example", "hasFile": False, "isAvailable": True, "monitored": True}
|
|
collector = SimpleNamespace(
|
|
get_movie_by_tmdb_id=AsyncMock(return_value=[item]),
|
|
get_series_by_tvdb_id=AsyncMock(return_value=[item]),
|
|
get_episodes=AsyncMock(return_value=[{"id": 109, "seriesId": 12, "seasonNumber": 5, "episodeNumber": 9, "monitored": True, "hasFile": False}]),
|
|
get_queue=AsyncMock(return_value={"records": []}),
|
|
get=AsyncMock(return_value=commands),
|
|
)
|
|
mocks = {
|
|
"get_runtime_settings": runtime,
|
|
"get_request_cache_payload": {"id": 12, "type": media_type.value, "status": 2,
|
|
"media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}},
|
|
"get_request_cache_by_id": None,
|
|
"JellyseerrClient": SimpleNamespace(configured=lambda: False),
|
|
"JellyfinClient": SimpleNamespace(configured=lambda: False),
|
|
"QBittorrentClient": SimpleNamespace(configured=lambda: False),
|
|
"SonarrClient": collector, "RadarrClient": collector,
|
|
"ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])),
|
|
"get_request_download_evidence": {}, "get_request_repairs": [], "_latest_repair_action": None, "save_snapshot": None,
|
|
}
|
|
for name, value in mocks.items():
|
|
stack.enter_context(patch.object(snapshot_service, name, return_value=value))
|
|
stack.enter_context(patch.object(snapshot_service, "_maybe_refresh_jellyfin", new=AsyncMock()))
|
|
snapshot = await snapshot_service.build_snapshot("12")
|
|
collector.get.assert_awaited_once_with("/api/v3/command", timeout_seconds=3.0)
|
|
self.assertEqual(snapshot.state, NormalizedState.searching if expected == "searching" else NormalizedState.added_to_arr)
|
|
library = next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == "library")
|
|
self.assertEqual(library["searchStatus"], expected)
|