Reconcile verified account IDs and make language repairs observable
This commit is contained in:
@@ -99,6 +99,7 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
||||
with db._connect() as conn:
|
||||
conn.execute('UPDATE users SET jellyseerr_user_id=42 WHERE id=?', (self.extra,))
|
||||
db.create_user('Other', 'Password-123456!', auth_provider='jellyfin', jellyseerr_user_id=42)
|
||||
self.jf['users'].append({'id': 'd' * 32, 'name': 'Other'})
|
||||
self.assertFalse((await duplicates.repair_duplicates(self.keep))['can_confirm'])
|
||||
|
||||
async def test_transaction_rolls_back_archive_and_history_on_failure(self):
|
||||
@@ -166,3 +167,16 @@ class DuplicateAccountTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
||||
with TestClient(app) as client:
|
||||
for path in ('check', 'confirm'):
|
||||
self.assertEqual(client.post('/admin/identities/duplicates/' + path, json={'user_id': self.keep}).status_code, 403)
|
||||
|
||||
|
||||
async def test_email_alias_consolidates_by_verified_id_and_preserves_activity(self):
|
||||
with db._connect() as conn:
|
||||
conn.execute("UPDATE users SET username='old@example.test',auth_provider='jellyseerr' WHERE id=?", (self.extra,))
|
||||
db.upsert_user_activity('old@example.test', '127.0.0.1', 'browser')
|
||||
preview = await duplicates.repair_duplicates(self.keep)
|
||||
self.assertTrue(preview['can_confirm'], preview['issues'])
|
||||
await duplicates.repair_duplicates(self.keep, self.keep, preview['revision'], {'username': 'admin'})
|
||||
self.assertIsNone(db.get_user_by_id(self.extra))
|
||||
with db._connect() as conn:
|
||||
self.assertEqual(conn.execute('SELECT username FROM user_activity').fetchone()[0], 'Viewer')
|
||||
self.assertFalse(db.create_user_if_missing('new-alias@example.test', 'unused', auth_provider='jellyseerr', jellyseerr_user_id=42))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from backend.app import db
|
||||
from backend.app.services import jellyfin_sync
|
||||
from backend.app.services.jellyfin_identity import link_user, user_for_identity
|
||||
from backend.app.routers import admin
|
||||
from backend.tests.test_backend_quality import TempDatabaseMixin
|
||||
|
||||
|
||||
class IdentitySyncTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_sync_reuses_id_when_names_differ_and_preserves_settings(self):
|
||||
db.create_user('old@example.test', 'Password-123456!', auth_provider='jellyseerr', jellyseerr_user_id=42,
|
||||
auto_search_enabled=False, email='kept@example.test')
|
||||
original = db.get_user_by_username('old@example.test')
|
||||
runtime = SimpleNamespace(jellyfin_base_url='http://jf', jellyfin_api_key='test')
|
||||
jf = SimpleNamespace(configured=lambda: True, get_users=AsyncMock(return_value=[{'Id': 'a' * 32, 'Name': 'NewName'}]))
|
||||
with patch.object(jellyfin_sync, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(jellyfin_sync, 'JellyfinClient', return_value=jf), \
|
||||
patch.object(jellyfin_sync, 'get_cached_jellyseerr_users', return_value=[{'id': 42, 'jellyfinUserId': 'a' * 32, 'email': 'upstream@example.test'}]), \
|
||||
patch.object(jellyfin_sync, 'save_jellyfin_users_cache'):
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
self.assertEqual(await jellyfin_sync.sync_jellyfin_users(), 0)
|
||||
kept = user_for_identity('a' * 32, 'http://jf')
|
||||
self.assertEqual(kept['id'], original['id'])
|
||||
self.assertFalse(kept['auto_search_enabled'])
|
||||
self.assertEqual(kept['email'], 'kept@example.test')
|
||||
self.assertIsNone(db.get_user_by_username('NewName'))
|
||||
self.assertEqual(kept['auth_provider'], 'jellyfin')
|
||||
|
||||
async def test_resync_no_longer_deletes_accounts(self):
|
||||
db.create_user('Keep', 'Password-123456!')
|
||||
runtime = SimpleNamespace(jellyseerr_base_url='http://seer', jellyseerr_api_key='test')
|
||||
with patch.object(admin, 'get_runtime_settings', return_value=runtime), \
|
||||
patch.object(admin, '_fetch_all_jellyseerr_users', new=AsyncMock(return_value=[{'id': 42}])), \
|
||||
patch.object(jellyfin_sync, 'sync_jellyfin_users', new=AsyncMock(return_value=0)), \
|
||||
patch.object(admin, 'delete_non_admin_users') as delete:
|
||||
result = await admin.jellyseerr_users_resync()
|
||||
self.assertEqual(result['cleared'], 0)
|
||||
delete.assert_not_called()
|
||||
self.assertIsNotNone(db.get_user_by_username('Keep'))
|
||||
|
||||
def test_jellyfin_lookup_is_scoped_to_server(self):
|
||||
db.create_user('Viewer', 'Password-123456!', auth_provider='jellyfin')
|
||||
link_user('Viewer', 'a' * 32, 'http://jf')
|
||||
self.assertIsNotNone(user_for_identity('a' * 32, 'http://jf'))
|
||||
self.assertIsNone(user_for_identity('a' * 32, 'http://other-server'))
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
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
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
||||
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:
|
||||
@@ -65,3 +66,54 @@ class RequestLanguageTests(unittest.IsolatedAsyncioTestCase):
|
||||
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', [], 'attention'), ('failed', [], 'attention'),
|
||||
('started', [], 'searching'), ('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'], 'attention')
|
||||
client.get_queue.return_value = {'records': [{'seriesId': 50}]}
|
||||
self.assertEqual((await series_search_outcome(client, 50, [{'id': 1}], attempts=1))['status'], 'downloading')
|
||||
|
||||
Reference in New Issue
Block a user