"""Replacement-cycle regressions. All collectors/downloads are fixtures.""" from contextlib import ExitStack from datetime import datetime, timedelta, timezone import json from types import SimpleNamespace import unittest from unittest.mock import AsyncMock, patch from backend.app import db from backend.app.config import settings from backend.app.models import NormalizedState, RequestType, Snapshot from backend.app.routers import requests as requests_router from backend.app.services import snapshot as service, media_repair from backend.tests.test_backend_quality import TempDatabaseMixin class RepairPipelineTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): def setUp(self): super().setUp() self.cycle = datetime.now(timezone.utc).isoformat() self.item = {"id": 12, "title": "Example", "hasFile": False} self.jf = {"Id": "jf-1", "Name": "Example", "Type": "Movie", "ProviderIds": {"Tmdb": "123"}, "Etag": "old"} self.episodes = [ {"id": 109, "seasonNumber": 5, "episodeNumber": 9, "hasFile": False, "episodeFileId": 0}, {"id": 110, "seasonNumber": 5, "episodeNumber": 10, "hasFile": True, "episodeFileId": 42}, ] self.torrents = [] self.queue = [] self.commands = [] self.jf_episodes = [{"Id": "ep9", "ParentIndexNumber": 5, "IndexNumber": 9, "Etag": "old"}] self.media_type = RequestType.movie self.fail_collector = False def start(self, media_type=RequestType.movie): self.media_type = media_type if media_type == RequestType.tv: self.jf.update(Type="Series", ProviderIds={"Tvdb": "456"}) tracking = { "requestId": "12", "startedAt": self.cycle, "actionId": "replace_media", "collectorId": 12, "mediaType": media_type.value, "originalFileIds": [40], "previousDownloadIds": ["old"], "episodes": [{"id": 109, "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [], "jellyfinFoundAtStart": True, "jellyfinBaseline": [{"Id": "ep9", "Etag": "old", "seasonNumber": 5, "episodeNumber": 9}] if media_type == RequestType.tv else [{"Id": "jf-1", "Etag": "old"}], } db.start_request_repair(tracking) return tracking async def snapshot(self): runtime = settings.model_copy(update={"requests_data_source": "prefer_cache", "jellyfin_public_url": "https://media.test"}) lookup = AsyncMock(side_effect=RuntimeError("offline")) if self.fail_collector else AsyncMock(return_value=[self.item]) collector = SimpleNamespace( get_movie_by_tmdb_id=lookup, get_series_by_tvdb_id=lookup, get_episodes=AsyncMock(return_value=self.episodes), get_queue=AsyncMock(return_value={"records": self.queue}), get=AsyncMock(return_value=self.commands), ) jellyfin = SimpleNamespace(configured=lambda: True, search_items=AsyncMock(return_value={"Items": [self.jf]}), get_series_episodes=AsyncMock(return_value=self.jf_episodes)) with ExitStack() as stack: mocks = { "get_runtime_settings": runtime, "get_request_cache_payload": {"id": 12, "type": self.media_type.value, "status": 4, "media": {"title": "Example", "tmdbId": 123, "tvdbId": 456}}, "get_request_cache_by_id": None, "JellyseerrClient": SimpleNamespace(configured=lambda: False), "JellyfinClient": jellyfin, "QBittorrentClient": SimpleNamespace(configured=lambda: True, get_torrents_by_hashes=AsyncMock(return_value=self.torrents), get_torrents_by_tag=AsyncMock(return_value=self.torrents)), "SonarrClient": collector, "RadarrClient": collector, "ProwlarrClient": SimpleNamespace(get_health=AsyncMock(return_value=[])), "_latest_repair_action": None, } for name, value in mocks.items(): stack.enter_context(patch.object(service, name, return_value=value)) stack.enter_context(patch.object(service, "_maybe_refresh_jellyfin", new=AsyncMock())) stack.enter_context(patch.object(media_repair, "JellyfinClient", return_value=jellyfin)) return await service.build_snapshot("12") @staticmethod def stage(snapshot, name): return next(stage for stage in snapshot.presentation["pipeline"] if stage["id"] == name) async def test_movie_old_catalog_and_completed_torrent_do_not_complete_repair(self): self.start() self.torrents = [{"hash": "old", "progress": 1, "state": "uploading", "added_on": 1, "completion_on": 2}] self.queue = [{"movieId": 12, "downloadId": "old"}] snapshot = await self.snapshot() self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending") self.assertEqual(self.stage(snapshot, "available")["state"], "waiting") self.assertEqual(snapshot.presentation["status"]["label"], "Waiting for a replacement") self.assertFalse(snapshot.presentation["download"]["visible"]) self.assertTrue(snapshot.raw["jellyfin"]["catalogFound"]) self.assertFalse(snapshot.raw["jellyfin"]["available"]) self.assertIn("search_auto", [a.id for a in snapshot.actions]) for name in ["requested", "approved"]: self.assertEqual(self.stage(snapshot, name)["state"], "complete") async def test_movie_repair_search_download_import_index_and_complete(self): self.start() self.commands = [{"name": "MoviesSearch", "status": "started", "body": {"movieIds": [12]}}] searching = await self.snapshot() self.assertEqual(searching.presentation["status"]["label"], "Searching for a replacement") self.commands = [] self.torrents = [{"hash": "new", "progress": .32, "state": "downloading"}] downloading = await self.snapshot() self.assertEqual(downloading.state, NormalizedState.downloading) self.assertEqual(self.stage(downloading, "download")["torrents"][0]["progressPercent"], 32) self.assertNotIn("resume_torrent", [a.id for a in downloading.actions]) self.item.update(hasFile=True, movieFile={"id": 41}) imported = await self.snapshot() self.assertEqual(self.stage(imported, "available")["stateLabel"], "Indexing") self.assertEqual(self.stage(imported, "download")["state"], "complete") self.assertEqual(len(db.get_request_repairs("12")), 1) self.jf["Etag"] = "new" self.torrents = [] completed = await self.snapshot() self.assertEqual(completed.state, NormalizedState.completed) self.assertEqual(db.get_request_repairs("12"), []) self.assertEqual(completed.presentation["status"]["label"], "Available to watch") async def test_old_queue_record_without_torrent_is_not_new_download_attempt(self): self.start() self.queue = [{"movieId": 12, "downloadId": "old"}] snapshot = await self.snapshot() self.assertEqual(self.stage(snapshot, "download")["stateLabel"], "Pending") self.assertFalse(snapshot.presentation["download"]["visible"]) async def test_same_original_file_cannot_confirm_replacement(self): self.start() self.item.update(hasFile=True, movieFile={"id": 40}) self.jf["Etag"] = "new" snapshot = await self.snapshot() self.assertNotEqual(snapshot.state, NormalizedState.completed) self.assertEqual(self.stage(snapshot, "download")["state"], "waiting") async def test_tv_preserves_unaffected_episodes_and_verifies_exact_replacement(self): self.start(RequestType.tv) self.item["statistics"] = {"episodeFileCount": 2, "totalEpisodeCount": 2} # stale summary pending = await self.snapshot() self.assertEqual(self.stage(pending, "library")["missing"], 1) self.assertEqual(self.stage(pending, "available")["state"], "partial") self.assertEqual(self.stage(pending, "download")["stateLabel"], "Pending") self.episodes[0].update(hasFile=True, episodeFileId=43) imported = await self.snapshot() self.assertEqual(imported.state, NormalizedState.importing) self.assertEqual(self.stage(imported, "available")["state"], "partial") self.assertEqual(len(db.get_request_repairs("12")), 1) self.jf_episodes[0]["Etag"] = "new" completed = await self.snapshot() self.assertEqual(completed.state, NormalizedState.completed) self.assertEqual(db.get_request_repairs("12"), []) async def test_collector_outage_does_not_restore_old_availability(self): self.start() self.fail_collector = True snapshot = await self.snapshot() self.assertNotEqual(snapshot.state, NormalizedState.completed) self.assertEqual(snapshot.presentation["status"]["label"], "Repair status temporarily unavailable") async def test_external_movie_removal_reconciles_old_jellyfin_entry(self): snapshot = await self.snapshot() self.assertNotEqual(snapshot.state, NormalizedState.completed) self.assertFalse(snapshot.raw["jellyfin"]["available"]) async def test_history_from_previous_cycle_and_late_old_poll_are_ignored(self): old = Snapshot(request_id="12", title="Example", state=NormalizedState.completed, timeline=[{"service": "qBittorrent", "status": "completed", "details": {"torrents": [{"hash": "old"}]}}]) db.save_snapshot(old) self.start() self.assertFalse(db.get_request_download_evidence("12")["observed"]) old.state_reason = "A pre-repair poll returned late" db.save_snapshot(old) self.assertFalse(db.get_request_download_evidence("12")["observed"]) self.torrents = [{"hash": "new", "progress": .5, "state": "downloading"}] await self.snapshot() self.assertTrue(db.get_request_download_evidence("12")["observed"]) def test_same_hash_redownload_and_new_completed_job_are_kept(self): old = {"hash": "same", "progress": 1, "added_on": 1, "completion_on": 2} retry = {**old, "progress": .3} fresh = {**old, "completion_on": datetime.now(timezone.utc).timestamp() + 1} self.assertEqual(media_repair.current_cycle_torrents([old, retry, fresh], self.cycle), [retry, fresh]) def test_repair_cycle_survives_restart_and_list_does_not_say_ready(self): self.start() db.init_db() self.assertEqual(len(db.get_request_repairs("12")), 1) with patch.dict(requests_router._recent_cache, {"items": [{"request_id": 12, "status": 4, "requested_by_id": 7}]}): self.assertEqual(requests_router._get_recent_from_cache(None, 7, 10, 0, None, [4]), []) rows = requests_router._get_recent_from_cache(None, 7, 10, 0, None, [5]) self.assertEqual(rows[0]["status"], 5) async def test_live_poll_ignores_old_completed_download(self): self.start() runtime = settings.model_copy(update={"jellyseerr_base_url": None, "jellyseerr_api_key": None}) qbit = SimpleNamespace(configured=lambda: True, get_torrents_by_tag=AsyncMock(return_value=[{"progress": 1, "hash": "old", "state": "uploading"}])) with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(requests_router, "QBittorrentClient", return_value=qbit): progress = await requests_router.get_download_progress("12", {"role": "user"}) self.assertEqual(progress["state"], "not_started") self.assertFalse(progress["visible"]) self.assertEqual(progress["repairCycle"], self.cycle) async def test_failed_search_after_deletion_keeps_cycle_and_pending_pipeline(self): before = Snapshot(request_id="12", title="Example", request_type=RequestType.movie, raw={"arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 40}}}, "jellyfin": {"found": True, "item": self.jf}}) async def delete(_): self.assertEqual(len(db.get_request_repairs("12")), 1, "Must persist before removal") radarr = SimpleNamespace(configured=lambda: True, monitor_movie=AsyncMock(), delete_movie_file=AsyncMock(side_effect=delete), search=AsyncMock(side_effect=RuntimeError("search failed"))) with ExitStack() as stack: for name, value in {"_user_can_use_search_auto": True, "_linked_issue_for_replacement": None, "JellyseerrClient": SimpleNamespace(configured=lambda: False), "RadarrClient": radarr}.items(): stack.enter_context(patch.object(requests_router, name, return_value=value)) stack.enter_context(patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=before))) with self.assertRaises(requests_router.HTTPException): await requests_router.action_replace_media("12", {"file_ids": [40], "confirmed": True}, {"role": "admin"}) self.assertEqual(len(db.get_request_repairs("12")), 1) snapshot = await self.snapshot() self.assertEqual(self.stage(snapshot, "download")["state"], "waiting") def test_existing_issue_tracking_is_migrated_once_and_survives_ticket_deletion(self): tracking = {"requestId": "12", "startedAt": self.cycle, "actionId": "replace_media", "collectorId": 12, "mediaType": "movie", "originalFileIds": [40]} issue = db.create_portal_item(kind="issue", title="Repair", description="Replace movie", status="in_progress", created_by_username="reporter", created_by_id=None, issue_type="broken_media") db.add_portal_item_activity(issue["id"], event_type="replacement_started", actor_username="reporter", actor_role="user", message="Repair requested", metadata_json=json.dumps({"repairTracking": tracking})) db.init_db() db.init_db() self.assertEqual(len(db.get_request_repairs("12")), 1) db.delete_portal_item(issue["id"]) self.assertEqual(len(db.get_request_repairs("12")), 1) async def test_multiple_repairs_wait_for_every_target_not_just_latest(self): first = self.start(RequestType.tv) second = {**first, "startedAt": (datetime.now(timezone.utc) + timedelta(seconds=1)).isoformat(), "episodes": [{"id": 110, "seasonNumber": 5, "episodeNumber": 10}], "originalFileIds": [42], "jellyfinFoundAtStart": False, "jellyfinBaseline": []} db.start_request_repair(second) self.episodes[1].update(episodeFileId=43) self.jf_episodes.append({"Id": "ep10", "ParentIndexNumber": 5, "IndexNumber": 10}) snapshot = await self.snapshot() self.assertNotEqual(snapshot.state, NormalizedState.completed) self.assertEqual([r["originalFileIds"] for r in db.get_request_repairs("12")], [[40]])