Track repair collection cycles and reconcile request availability
Magent CI/CD / verify (push) Successful in 10m47s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 1m49s

This commit is contained in:
2026-09-06 22:06:30 +12:00
parent 8d720de500
commit 009bb35032
11 changed files with 762 additions and 151 deletions
+244
View File
@@ -0,0 +1,244 @@
"""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]])