import copy import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException from backend.app.services.request_language import language_info, original_profile, is_original_profile, apply_original_to_movie, movie_search_outcome from backend.app.routers import requests class RequestLanguageTests(unittest.IsolatedAsyncioTestCase): def test_metadata_is_not_audio_evidence(self): for code in ('en', '', 'xx', 'invalid'): self.assertIsNone(language_info({'originalLanguage': code})) self.assertEqual(language_info({'original_language': 'es'}), {'code': 'es'}) async def test_copy_preserves_quality_and_reuses_verified_profile(self): default = {'id': 6, 'name': 'HD', 'language': {'id': 1, 'name': 'English'}, 'items': [{'quality': {'id': 7}, 'allowed': True}], 'minFormatScore': 50, 'formatItems': [{'format': 10, 'score': -1000}], 'upgradeAllowed': True} original = copy.deepcopy(default) client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[default]), post=AsyncMock(return_value={'id': 20})) self.assertEqual(await original_profile(client, 6), 20) payload = client.post.await_args.kwargs['payload'] self.assertEqual(payload['language']['id'], -2) self.assertEqual(payload['items'], default['items']) self.assertEqual(payload['formatItems'], default['formatItems']) self.assertEqual(payload['minFormatScore'], 50) self.assertEqual(default, original) self.assertTrue(is_original_profile(payload)) client.get_quality_profiles.return_value.append({**payload, 'id': 20}) self.assertEqual(await original_profile(client, 6), 20) self.assertEqual(client.post.await_count, 1) payload['minFormatScore'] = 0 self.assertFalse(is_original_profile(payload)) async def test_missing_default_never_creates_profile(self): client = SimpleNamespace(get_quality_profiles=AsyncMock(return_value=[]), post=AsyncMock()) with self.assertRaises(HTTPException): await original_profile(client, 6) client.post.assert_not_awaited() async def test_only_explicit_verified_movie_consent_changes_destination(self): runtime = SimpleNamespace(jellyseerr_base_url='http://seerr', jellyseerr_api_key='key', radarr_base_url='http://radarr', radarr_api_key='key') seerr = SimpleNamespace(configured=lambda: True, get_movie=AsyncMock(), get_tv=AsyncMock(), create_request=AsyncMock(return_value={'status': 1})) for code, consent, media_type, expected in [('es', True, 'movie', 20), ('es', False, 'movie', 6), ('en', True, 'movie', None), ('es', 'yes', 'movie', None), ('ja', True, 'tv', 6)]: details = {'title': 'Title', 'originalLanguage': code, 'seasons': [{'seasonNumber': 1}]} seerr.get_movie.return_value = seerr.get_tv.return_value = details seerr.create_request.reset_mock() with patch.object(requests, 'get_runtime_settings', return_value=runtime), \ patch.object(requests, 'JellyseerrClient', return_value=seerr), \ patch.object(requests, '_resolve_request_destination', new=AsyncMock(return_value={'server_id': 1, 'profile_id': 6, 'root_folder': '/media'})), \ patch.object(requests, 'apply_original_to_movie', new=AsyncMock(return_value=None)), \ patch.object(requests, 'original_profile', new=AsyncMock(return_value=20)) as clone: payload = {'mediaType': media_type, 'tmdbId': 1417, 'acceptOriginalLanguage': consent, 'seasons': [1]} if expected is None: with self.assertRaises(HTTPException): await requests.create_request(payload, {'username': 'viewer'}) seerr.create_request.assert_not_awaited() clone.assert_not_awaited() else: await requests.create_request(payload, {'username': 'viewer'}) self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], expected) self.assertEqual(clone.await_count, int(expected == 20)) async def test_existing_radarr_movie_is_updated_and_read_back(self): movie = {'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9, 'monitored': True} client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[movie]), update_movie=AsyncMock(), get_movie=AsyncMock(return_value={**movie, 'qualityProfileId': 20})) with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)): self.assertEqual(await apply_original_to_movie(client, 613), 20) self.assertEqual(client.update_movie.await_args.args[0]['qualityProfileId'], 20) self.assertTrue(client.update_movie.await_args.args[0]['monitored']) async def test_failed_profile_verification_does_not_claim_success(self): client = SimpleNamespace(get_movie_by_tmdb_id=AsyncMock(return_value=[{'id': 6940, 'tmdbId': 613, 'qualityProfileId': 9}]), update_movie=AsyncMock(), get_movie=AsyncMock(return_value={'qualityProfileId': 9})) with patch('backend.app.services.request_language.original_profile', new=AsyncMock(return_value=20)): with self.assertRaises(HTTPException): await apply_original_to_movie(client, 613) async def test_search_reports_real_outcomes(self): for command_status, queue, expected in [('completed', [], 'pending'), ('failed', [], 'attention'), ('started', [], 'pending'), ('completed', [{'movieId': 6940}], 'downloading')]: client = SimpleNamespace(get=AsyncMock(return_value={'status': command_status}), get_queue=AsyncMock(return_value={'records': queue}), get_movie=AsyncMock(return_value={'hasFile': False})) result = await movie_search_outcome(client, 6940, {'id': 1}, attempts=1, delay=0) self.assertEqual(result['status'], expected) async def test_language_endpoint_checks_consent_identity_and_access(self): for payload in ({}, {'acceptOriginalLanguage': 'true'}, {'acceptOriginalLanguage': True, 'languageCode': 'es'}): with patch.object(requests, '_request_language_context', new=AsyncMock(return_value=(SimpleNamespace(), 613, {'code': 'de'}))), \ patch.object(requests, 'apply_original_to_movie', new=AsyncMock()) as apply: with self.assertRaises(HTTPException): await requests.accept_request_language('3976', payload, {'role': 'admin'}) apply.assert_not_awaited() with self.assertRaises(HTTPException): await requests.accept_request_language('3976', {'acceptOriginalLanguage': True}, {'role': 'user', 'auto_search_enabled': False}) async def test_radarr_queue_filters_before_pagination(self): from backend.app.clients.radarr import RadarrClient client = RadarrClient('http://radarr.test', 'test') with patch.object(client, 'get', new=AsyncMock(return_value={'records': []})) as get: await client.get_queue(6940) get.assert_awaited_once_with('/api/v3/queue', params={'movieIds': 6940, 'pageSize': 1000}) async def test_tv_search_distinguishes_no_download_and_queue(self): from backend.app.services.request_language import series_search_outcome client = SimpleNamespace(get=AsyncMock(return_value={'status': 'completed'}), get_queue=AsyncMock(return_value={'records': []})) self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'pending') client.get_queue.return_value = {'records': [{'seriesId': 50}]} self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading') class SearchHandoffTests(unittest.IsolatedAsyncioTestCase): async def test_radarr_completed_before_queue_refresh(self): client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}), get_queue=AsyncMock(side_effect=[{'records':[]}, {'records':[]}, {'records':[{'movieId':2206}]}]), get_movie=AsyncMock(return_value={'hasFile':False})) result = await movie_search_outcome(client, 2206, {'id':1}, attempts=3, delay=0) self.assertEqual(result['status'], 'downloading') self.assertEqual(client.get_queue.await_count, 3) async def test_sonarr_completed_before_queue_refresh(self): from backend.app.services.request_language import series_search_outcome client = SimpleNamespace(get=AsyncMock(return_value={'status':'completed'}), get_queue=AsyncMock(side_effect=[{'records':[{'seriesId':999}]}, {'records':[{'seriesId':50}]}])) result = await series_search_outcome(client, 50, [{'id':1}], attempts=2, delay=0) self.assertEqual(result['status'], 'downloading')