Align missing-episode searches and permit reviewed profile overrides
This commit is contained in:
@@ -1128,6 +1128,18 @@ class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
from backend.app.config import settings
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-release-tests-secret-1234567890123456')
|
||||
secret.start()
|
||||
self.addCleanup(secret.stop)
|
||||
|
||||
def selection(self, payload, request_id, source):
|
||||
payload['selectionToken'] = requests_router.manual_releases.issue_selection(
|
||||
{**payload, 'requiresOverride': False, 'rejections': []}, request_id,
|
||||
{'username': 'viewer'}, source, None)
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _runtime() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
@@ -1155,7 +1167,7 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
|
||||
]
|
||||
),
|
||||
search_releases=AsyncMock(
|
||||
search_episode_releases=AsyncMock(
|
||||
side_effect=[
|
||||
[
|
||||
{
|
||||
@@ -1194,15 +1206,17 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
"3909", user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
|
||||
sonarr.search_releases.assert_any_await(42, 1)
|
||||
sonarr.search_releases.assert_any_await(42, 2)
|
||||
sonarr.search_episode_releases.assert_any_await(101)
|
||||
sonarr.search_episode_releases.assert_any_await(201)
|
||||
self.assertEqual(result["collector"], "Sonarr")
|
||||
self.assertEqual(len(result["releases"]), 1)
|
||||
self.assertEqual(len(result["releases"]), 2)
|
||||
self.assertTrue(result["releases"][0]["fullSeason"])
|
||||
self.assertEqual(result["releases"][0]["seasonNumber"], 1)
|
||||
self.assertEqual(result["releases"][0]["quality"], "WEBDL-1080p")
|
||||
self.assertTrue(result["releases"][0]["bestPick"])
|
||||
self.assertTrue(result["qualityFiltered"])
|
||||
self.assertFalse(result["qualityFiltered"])
|
||||
self.assertNotIn("selectionToken", result["releases"][1])
|
||||
self.assertTrue(result["releases"][1]["requiresOverride"])
|
||||
|
||||
async def test_movie_manual_search_uses_radarr(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
@@ -1292,14 +1306,14 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
requests_router, "save_action"
|
||||
):
|
||||
result = await requests_router.action_grab(
|
||||
"3909", payload, user={"username": "viewer", "role": "user"}
|
||||
"3909", self.selection(payload, "3909", self._runtime().sonarr_base_url), user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
|
||||
sonarr.grab_release.assert_awaited_once_with("season-one", 7)
|
||||
sonarr.push_release.assert_not_awaited()
|
||||
self.assertEqual(result["response"], {"collector": "Sonarr", "queued": True})
|
||||
|
||||
async def test_stale_movie_release_still_routes_through_radarr_push(self) -> None:
|
||||
async def test_stale_movie_release_requires_fresh_search(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="4000",
|
||||
title="Example Movie",
|
||||
@@ -1335,15 +1349,12 @@ class CollectorManualDownloadTests(unittest.IsolatedAsyncioTestCase):
|
||||
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
|
||||
requests_router, "save_action"
|
||||
):
|
||||
result = await requests_router.action_grab(
|
||||
"4000", payload, user={"username": "viewer", "role": "user"}
|
||||
)
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
await requests_router.action_grab(
|
||||
"4000", self.selection(payload, "4000", self._runtime().radarr_base_url), user={"username": "viewer", "role": "user"})
|
||||
self.assertEqual(error.exception.status_code, 409)
|
||||
radarr.push_release.assert_not_awaited()
|
||||
|
||||
radarr.push_release.assert_awaited_once()
|
||||
pushed = radarr.push_release.await_args.args[0]
|
||||
self.assertEqual(pushed["downloadUrl"], "http://prowlarr.test/download/1")
|
||||
self.assertEqual(pushed["protocol"], "torrent")
|
||||
self.assertEqual(result["response"], {"collector": "Radarr", "queued": True})
|
||||
|
||||
|
||||
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
|
||||
|
||||
@@ -26,7 +26,7 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
|
||||
|
||||
def test_defaults_persist_and_invites_share_existing_setting(self):
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
|
||||
self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False, ignore_profile_limits=False))
|
||||
update_permissions({'stats': False, 'invites': True}, self.user['username'])
|
||||
db.init_db()
|
||||
fresh = db.get_user_by_username(self.user['username'])
|
||||
@@ -118,3 +118,22 @@ class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': kind}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={'kind': None}).status_code, 403)
|
||||
self.assertEqual(self.client.post('/portal/items', json={}).status_code, 403)
|
||||
|
||||
def test_manual_override_permission_is_checked_again_at_download(self):
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.services import manual_releases
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot=Snapshot(request_id='42',title='Example',request_type=RequestType.tv,raw={'arr':{'item':{'id':55}}})
|
||||
release={'guid':'out','indexerId':1,'title':'Example','requiresOverride':True,'rejections':['Quality is not wanted in profile']}
|
||||
payload={**release,'ignoreProfileLimits':True,'selectionToken':manual_releases.issue_selection(release,'42',self.user,'http://sonarr',55)}
|
||||
collector=SimpleNamespace(configured=lambda:True,grab_release=AsyncMock(return_value={}))
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,'SonarrClient',return_value=collector),patch.object(requests,'save_action'):
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,403)
|
||||
collector.grab_release.assert_not_awaited()
|
||||
update_permissions({'ignore_profile_limits':True},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json=payload).status_code,200)
|
||||
update_permissions({'ignore_profile_limits':False},self.user['username'])
|
||||
self.assertEqual(self.client.post('/requests/42/actions/grab',json={**payload,'requiresOverride':False,'approved':True}).status_code,403)
|
||||
collector.grab_release.assert_awaited_once()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import asyncio
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from fastapi import HTTPException
|
||||
from backend.app.config import settings
|
||||
from backend.app.services import manual_releases as manual
|
||||
from backend.app.routers import requests
|
||||
from backend.app.models import Snapshot, RequestType
|
||||
from backend.app.feature_access import permissions, update_permissions
|
||||
from backend.app import db
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class ManualSelectionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
secret = patch.object(settings, 'jwt_secret', 'manual-selection-test-secret-123456789')
|
||||
secret.start(); self.addCleanup(secret.stop)
|
||||
self.user = {'username': 'viewer', 'role': 'user', 'features': {'ignore_profile_limits': True}}
|
||||
self.release = {'guid': 'release', 'indexerId': 7, 'title': 'Example', 'requiresOverride': True,
|
||||
'rejections': ['WEBDL-2160p is not wanted in profile']}
|
||||
self.payload = {**self.release, 'ignoreProfileLimits': True,
|
||||
'selectionToken': manual.issue_selection(self.release, '42', self.user, 'http://sonarr', 55)}
|
||||
|
||||
def test_profile_only_rejections_are_overridable(self):
|
||||
for reason in ['WEBDL-2160p is not wanted in profile', 'Custom format score below minimum', 'File is larger than maximum size', 'Language is not wanted']:
|
||||
self.assertTrue(manual.decision({'approved': False, 'rejections': [reason]})[1])
|
||||
for reason in ['Unknown series', 'Release is blocklisted', 'No download client available', 'Already in queue']:
|
||||
self.assertFalse(manual.decision({'rejections': [self.release['rejections'][0], reason]})[1])
|
||||
self.assertFalse(manual.decision({'approved': True, 'downloadAllowed': False})[0])
|
||||
|
||||
def test_receipt_binds_request_user_source_item_and_release(self):
|
||||
self.assertTrue(manual.verify_selection(self.payload, '42', self.user, 'http://sonarr', 55)['override'])
|
||||
attempts = [({**self.payload, 'guid': 'other'}, '42', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '43', self.user, 'http://sonarr', 55),
|
||||
(self.payload, '42', {**self.user, 'username': 'other'}, 'http://sonarr', 55),
|
||||
(self.payload, '42', self.user, 'http://other', 55),
|
||||
(self.payload, '42', self.user, 'http://sonarr', 56),
|
||||
({**self.payload, 'selectionToken': 'forged'}, '42', self.user, 'http://sonarr', 55)]
|
||||
for args in attempts:
|
||||
with self.assertRaises(HTTPException): manual.verify_selection(*args)
|
||||
|
||||
def test_permission_revocation_and_literal_confirmation_enforced(self):
|
||||
for payload, user, code in [(self.payload, {**self.user, 'features': {}}, 403),
|
||||
({**self.payload, 'ignoreProfileLimits': 'true'}, self.user, 400)]:
|
||||
with self.assertRaises(HTTPException) as error:
|
||||
manual.verify_selection(payload, '42', user, 'http://sonarr', 55)
|
||||
self.assertEqual(error.exception.status_code, code)
|
||||
|
||||
|
||||
class ManualPermissionTests(TempDatabaseMixin, unittest.TestCase):
|
||||
def test_default_off_individual_and_bulk(self):
|
||||
for name in ('one', 'two'): db.create_user(name, 'Password123!', role='user')
|
||||
one, two = [db.get_user_by_username(n) for n in ('one', 'two')]
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': True}, 'one')
|
||||
self.assertTrue(permissions(one)['ignore_profile_limits'])
|
||||
self.assertFalse(permissions(two)['ignore_profile_limits'])
|
||||
update_permissions({'ignore_profile_limits': False})
|
||||
self.assertFalse(permissions(one)['ignore_profile_limits'])
|
||||
|
||||
|
||||
class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_episode_batch_is_bounded_and_exposes_next_page(self):
|
||||
episodes = [{'id': i, 'seasonNumber': 1, 'monitored': True, 'hasFile': False} for i in range(1, 26)]
|
||||
episodes += [{'id': 26, 'seasonNumber': 1, 'monitored': True, 'hasFile': True}]
|
||||
active = peak = 0
|
||||
async def search(identity):
|
||||
nonlocal active, peak
|
||||
active += 1; peak = max(peak, active)
|
||||
await asyncio.sleep(0.001); active -= 1
|
||||
return []
|
||||
sonarr = SimpleNamespace(configured=lambda: True, get_episodes=AsyncMock(return_value=episodes), search_episode_releases=AsyncMock(side_effect=search))
|
||||
runtime = SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test')
|
||||
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
|
||||
first = await requests.action_search('42', {'username':'viewer','role':'user'})
|
||||
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20)
|
||||
self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset'])
|
||||
self.assertEqual(sonarr.search_episode_releases.await_count,25)
|
||||
self.assertLessEqual(peak,3)
|
||||
self.assertEqual(first['totalMissingEpisodes'],25)
|
||||
|
||||
async def test_auto_search_preserves_current_profile(self):
|
||||
for kind, service in [(RequestType.tv,'SonarrClient'),(RequestType.movie,'RadarrClient')]:
|
||||
client=SimpleNamespace(configured=lambda:True, update_series=AsyncMock(), update_movie=AsyncMock(),
|
||||
get_episodes=AsyncMock(return_value=[{'id':1,'seasonNumber':1,'monitored':True,'hasFile':False}]),
|
||||
search_episodes=AsyncMock(return_value={'id':1}), search=AsyncMock(return_value={'id':1}))
|
||||
runtime=SimpleNamespace(jellyseerr_base_url=None,jellyseerr_api_key=None,sonarr_base_url='http://sonarr',sonarr_api_key='test',radarr_base_url='http://radarr',radarr_api_key='test',sonarr_quality_profile_id=6,radarr_quality_profile_id=6)
|
||||
snapshot=Snapshot(request_id='42',request_type=kind,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
|
||||
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'build_snapshot',new=AsyncMock(return_value=snapshot)),patch.object(requests,service,return_value=client),patch.object(requests,'save_action'),patch.object(requests,'series_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})),patch.object(requests,'movie_search_outcome',new=AsyncMock(return_value={'status':'attention','message':'Nothing queued'})):
|
||||
await requests.action_search_auto('42',{'username':'admin','role':'admin'})
|
||||
client.update_series.assert_not_awaited(); client.update_movie.assert_not_awaited()
|
||||
Reference in New Issue
Block a user