Files
Magent/backend/tests/test_backend_quality.py
T

2761 lines
114 KiB
Python

import os
from types import SimpleNamespace
import tempfile
import unittest
from unittest.mock import AsyncMock, call, patch
import httpx
from fastapi import HTTPException
from passlib.context import CryptContext
from starlette.requests import Request
from backend.app import db
from backend.app.clients.base import _operation_error_message, _operation_result_message
from backend.app.clients.jellyfin import _availability_message
from backend.app.clients.qbittorrent import _torrent_result_message
from backend.app.auth import _load_current_user_from_token, require_admin
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
from backend.app.routers import auth as auth_router
from backend.app.routers import admin as admin_router
from backend.app.routers import branding as branding_router
from backend.app.routers import portal as portal_router
from backend.app.routers import requests as requests_router
from backend.app.routers import site as site_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, create_access_token, validate_password_policy
from backend.app.services import password_reset
from backend.app.services import issue_resolution
from backend.app.services.operation_progress import (
begin_operation,
finish_operation,
finish_remote_call,
get_operation,
reset_operation,
start_remote_call,
)
from backend.app.services.snapshot import (
_apply_arr_identity,
_build_presentation,
_build_repair_activity,
_episode_availability,
_torrent_progress,
_unmonitored_season_options,
)
def _build_request(ip: str = "127.0.0.1", user_agent: str = "backend-test") -> Request:
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/auth/password/forgot",
"raw_path": b"/auth/password/forgot",
"query_string": b"",
"headers": [(b"user-agent", user_agent.encode("utf-8"))],
"client": (ip, 12345),
"server": ("testserver", 8000),
}
async def receive() -> dict:
return {"type": "http.request", "body": b"", "more_body": False}
return Request(scope, receive)
class TempDatabaseMixin:
def setUp(self) -> None:
super_method = getattr(super(), "setUp", None)
if callable(super_method):
super_method()
self._tempdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self._original_sqlite_path = settings.sqlite_path
self._original_journal_mode = getattr(settings, "sqlite_journal_mode", "DELETE")
self._original_settings_encryption_key = settings.settings_encryption_key
settings.sqlite_path = os.path.join(self._tempdir.name, "test.db")
settings.sqlite_journal_mode = "DELETE"
settings.settings_encryption_key = "bWFnZW50LXNlY3VyaXR5LXRlc3Qta2V5LTMyLWJ5dGU="
db.init_db()
def tearDown(self) -> None:
settings.sqlite_path = self._original_sqlite_path
settings.sqlite_journal_mode = self._original_journal_mode
settings.settings_encryption_key = self._original_settings_encryption_key
self._tempdir.cleanup()
super_method = getattr(super(), "tearDown", None)
if callable(super_method):
super_method()
class PasswordPolicyTests(unittest.TestCase):
def test_validate_password_policy_rejects_short_passwords(self) -> None:
with self.assertRaisesRegex(ValueError, PASSWORD_POLICY_MESSAGE):
validate_password_policy("short")
def test_validate_password_policy_trims_whitespace(self) -> None:
self.assertEqual(validate_password_policy(" password1234 "), "password1234")
class SecurityHardeningTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
self._jwt_secret = patch.object(
settings, "jwt_secret", "security-hardening-tests-secret-123456789"
)
self._jwt_secret.start()
self.addCleanup(self._jwt_secret.stop)
def test_sensitive_settings_are_encrypted_at_rest(self) -> None:
db.set_setting("jellyfin_api_key", "private-api-key")
with db._connect() as conn:
stored = conn.execute(
"SELECT value FROM settings WHERE key = ?", ("jellyfin_api_key",)
).fetchone()[0]
self.assertTrue(stored.startswith("enc:v1:"))
self.assertNotIn("private-api-key", stored)
self.assertEqual(db.get_setting("jellyfin_api_key"), "private-api-key")
def test_invites_are_hashed_and_rotation_invalidates_old_link(self) -> None:
created = db.create_signup_invite(code="TopSecretInvite42")
invite_id = int(created["id"])
with db._connect() as conn:
stored = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (invite_id,)
).fetchone()[0]
self.assertTrue(stored.startswith("sha256:"))
self.assertNotIn("TOPSECRETINVITE42", stored.upper())
self.assertFalse(db.get_signup_invite_by_id(invite_id)["code_available"])
self.assertIsNotNone(db.get_signup_invite_by_code("TopSecretInvite42"))
rotated = db.rotate_signup_invite_code(invite_id, "ReplacementInvite99")
self.assertTrue(rotated["code_available"])
self.assertIsNone(db.get_signup_invite_by_code("TopSecretInvite42"))
self.assertIsNotNone(db.get_signup_invite_by_code("ReplacementInvite99"))
def test_legacy_invites_and_plaintext_settings_migrate_in_place(self) -> None:
created = db.create_signup_invite(code="TemporaryInvite77")
with db._connect() as conn:
conn.execute(
"UPDATE signup_invites SET code = ?, code_hint = NULL WHERE id = ?",
("Legacy-Code-77", int(created["id"])),
)
conn.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)",
("radarr_api_key", "legacy-plaintext-key", "2026-09-17T00:00:00+00:00"),
)
db.init_db()
migrated = db.get_signup_invite_by_code("Legacy-Code-77")
self.assertEqual(migrated["id"], created["id"])
self.assertEqual(db.get_setting("radarr_api_key"), "legacy-plaintext-key")
with db._connect() as conn:
invite_code = conn.execute(
"SELECT code FROM signup_invites WHERE id = ?", (int(created["id"]),)
).fetchone()[0]
stored_setting = conn.execute(
"SELECT value FROM settings WHERE key = 'radarr_api_key'"
).fetchone()[0]
self.assertTrue(invite_code.startswith("sha256:"))
self.assertTrue(stored_setting.startswith("enc:v1:"))
def test_legacy_password_hash_is_replaced_with_argon2(self) -> None:
password = "Example-password123!"
db.create_user("legacy", password)
legacy_hash = CryptContext(schemes=["pbkdf2_sha256"]).hash(password)
with db._connect() as conn:
conn.execute(
"UPDATE users SET password_hash = ? WHERE username = ?",
(legacy_hash, "legacy"),
)
self.assertIsNotNone(db.verify_user_password("legacy", password))
self.assertTrue(db.get_user_by_username("legacy")["password_hash"].startswith("$argon2"))
def test_auth_version_revokes_existing_token(self) -> None:
db.create_user("viewer", "Example-password123!")
user = db.get_user_by_username("viewer")
token = create_access_token(
"viewer", "user", auth_version=int(user["auth_version"])
)
self.assertEqual(_load_current_user_from_token(token)["username"], "viewer")
db.increment_user_auth_version("viewer")
with self.assertRaises(HTTPException) as context:
_load_current_user_from_token(token)
self.assertEqual(context.exception.status_code, 401)
async def test_request_mutations_require_owner_or_admin(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="secret"
)
client = SimpleNamespace(
configured=lambda: True,
get_request=AsyncMock(
return_value={"id": 42, "requestedBy": {"username": "owner"}}
),
)
with patch.object(requests_router, "JellyseerrClient", return_value=client):
with self.assertRaises(HTTPException) as context:
await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "someone-else", "role": "user"}
)
self.assertEqual(context.exception.status_code, 403)
owned = await requests_router._ensure_request_mutation_access(
runtime, 42, {"username": "owner", "role": "user"}
)
self.assertEqual(owned["id"], 42)
self.assertIsNone(
await requests_router._ensure_request_mutation_access(
SimpleNamespace(), 42, {"username": "admin", "role": "admin"}
)
)
def test_account_deletion_removes_or_anonymizes_personal_data(self) -> None:
db.create_user(
"viewer", "Example-password123!", email="viewer@example.test"
)
user = db.get_user_by_username("viewer")
now = "2026-09-17T00:00:00+00:00"
db.upsert_request_cache(
42,
99,
"movie",
2,
"Example",
2026,
"viewer",
"viewer",
int(user["id"]),
now,
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
)
with db._connect() as conn:
conn.execute(
"INSERT INTO snapshots (request_id, state, created_at, payload_json) VALUES (?, ?, ?, ?)",
(
"42",
"available",
now,
'{"requestedBy":{"username":"viewer","email":"viewer@example.test"}}',
),
)
db.save_action("42", "created", "Created", "ok", "Created by viewer")
item = db.create_portal_item(
kind="issue",
title="Example",
description="Example",
created_by_username="viewer",
created_by_id=int(user["id"]),
)
result = db.delete_user_data_by_username("viewer")
self.assertTrue(result["deleted"])
self.assertIsNone(db.get_user_by_username("viewer"))
with db._connect() as conn:
request_row = conn.execute(
"SELECT requested_by, requested_by_id, payload_json FROM requests_cache WHERE request_id = 42"
).fetchone()
snapshot_json = conn.execute(
"SELECT payload_json FROM snapshots WHERE request_id = '42'"
).fetchone()[0]
action_message = conn.execute(
"SELECT message FROM actions WHERE request_id = '42'"
).fetchone()[0]
portal_owner = conn.execute(
"SELECT created_by_username, created_by_id FROM portal_items WHERE id = ?",
(item["id"],),
).fetchone()
self.assertEqual(request_row[0], "Deleted user")
self.assertIsNone(request_row[1])
self.assertNotIn("viewer", request_row[2].lower())
self.assertNotIn("viewer", snapshot_json.lower())
self.assertNotIn("viewer", action_message.lower())
self.assertTrue(portal_owner[0].startswith("deleted-user-"))
self.assertIsNone(portal_owner[1])
async def test_branding_upload_rejects_oversized_images_before_decode(self) -> None:
upload = SimpleNamespace(
filename="logo.png",
content_type="image/png",
read=AsyncMock(return_value=b"x" * (5 * 1024 * 1024 + 1)),
)
with self.assertRaises(HTTPException) as context:
await branding_router.save_branding_image(upload)
self.assertEqual(context.exception.status_code, 413)
upload.read.assert_awaited_once_with(5 * 1024 * 1024 + 1)
class NetworkSecurityTests(unittest.TestCase):
def test_notification_targets_reject_loopback(self) -> None:
with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
validate_notification_target_url("http://127.0.0.1:8080/webhook")
def test_forwarded_headers_require_trusted_proxy(self) -> None:
original_enabled = settings.magent_proxy_enabled
original_trust = settings.magent_proxy_trust_forwarded_headers
original_proxies = settings.magent_proxy_trusted_proxies
settings.magent_proxy_enabled = True
settings.magent_proxy_trust_forwarded_headers = True
settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
try:
self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
finally:
settings.magent_proxy_enabled = original_enabled
settings.magent_proxy_trust_forwarded_headers = original_trust
settings.magent_proxy_trusted_proxies = original_proxies
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
def test_status_router_requires_admin(self) -> None:
dependencies = [getattr(dependency, "dependency", None) for dependency in status_router.router.dependencies]
self.assertIn(require_admin, dependencies)
async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
class FakeClient:
def __init__(self) -> None:
self.cookies = httpx.Cookies()
async def post(self, *_args, **_kwargs) -> httpx.Response:
self.cookies.set("QBT_SID_8080", "session")
return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
await client._login(FakeClient())
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
with patch.object(
client,
"get_app_version",
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
class OperationProgressTests(unittest.TestCase):
def test_remote_interaction_is_visible_until_operation_completes(self) -> None:
operation_id = "operation-progress-test"
token = begin_operation(
operation_id,
label="Recheck request status",
path="/requests/3914/actions/recheck",
)
try:
event_id = start_remote_call("Radarr")
active = get_operation(operation_id)
self.assertEqual(active["status"], "running")
self.assertEqual(active["events"][-1]["service"], "Radarr")
self.assertEqual(active["events"][-1]["state"], "active")
finish_remote_call(
event_id,
success=True,
status_code=200,
message="Radarr responded in 0.2s.",
)
finish_operation(operation_id, success=True, status_code=200)
finally:
reset_operation(token)
completed = get_operation(operation_id)
self.assertEqual(completed["status"], "complete")
self.assertEqual(completed["events"][-2]["state"], "complete")
self.assertEqual(completed["events"][-2]["status_code"], 200)
self.assertEqual(completed["events"][-1]["service"], "Magent")
class OperationMessageTests(unittest.TestCase):
def test_radarr_lookup_explains_whether_movie_was_found(self) -> None:
found = _operation_result_message(
"Radarr",
"GET",
"/api/v3/movie",
[{"title": "Arrival"}],
)
missing = _operation_result_message(
"Radarr",
"GET",
"/api/v3/movie",
[],
)
self.assertEqual(found, 'Radarr found "Arrival" in its library list.')
self.assertEqual(missing, "This movie is not currently in Radarr.")
def test_radarr_add_explains_that_download_search_started(self) -> None:
message = _operation_result_message(
"Radarr",
"POST",
"/api/v3/movie",
{"title": "Arrival", "id": 42},
payload={
"title": "Arrival",
"addOptions": {"searchForMovie": True},
},
)
self.assertEqual(message, 'Radarr added "Arrival" and started looking for a download.')
def test_queue_and_indexer_health_results_are_summarized(self) -> None:
queue_message = _operation_result_message(
"Sonarr",
"GET",
"/api/v3/queue",
{"totalRecords": 0, "records": []},
)
health_message = _operation_result_message(
"Prowlarr",
"GET",
"/api/v1/health",
[],
)
self.assertEqual(queue_message, "Sonarr has no matching downloads in its queue.")
self.assertEqual(health_message, "The download search sources are working normally.")
def test_download_and_jellyfin_results_include_actual_state(self) -> None:
torrent_message = _torrent_result_message(
[{"name": "Arrival.2016", "state": "downloading", "progress": 0.42}]
)
self.assertEqual(
torrent_message,
'Downloading — 42% complete.',
)
self.assertEqual(
_availability_message({"TotalRecordCount": 0, "Items": []}),
"Jellyfin did not find this title in its library search.",
)
def test_bazarr_subtitle_search_is_explained_in_plain_english(self) -> None:
message = _operation_result_message(
"Bazarr",
"PATCH",
"/api/episodes/subtitles",
{"status": True},
params={"language": "en", "episodeid": 42},
)
self.assertEqual(
message,
"Bazarr accepted a fresh EN subtitle search for the selected episode.",
)
def test_library_search_does_not_claim_playable_media(self) -> None:
message = _availability_message({"TotalRecordCount": 1, "Items": [{"Name": "Example"}]})
self.assertIn("still needs to check the exact title and file", message)
self.assertNotIn("available to watch", message)
def test_finished_or_paused_download_is_not_described_as_stuck(self) -> None:
for state in ["stalledUP", "stoppedUP", "pausedUP"]:
self.assertIn("finished", _torrent_result_message([{"state": state, "progress": 1}]))
self.assertIn("paused", _torrent_result_message([{"state": "stoppedDL", "progress": .3}]))
def test_http_failures_are_translated_without_exposing_stack_traces(self) -> None:
self.assertEqual(
_operation_error_message("Radarr", 500),
"Radarr encountered an internal error while processing the request.",
)
self.assertEqual(
_operation_error_message("Sonarr", 401),
"Sonarr rejected Magent's login details.",
)
class SiteInfoTests(unittest.TestCase):
def test_site_public_exposes_requests_navigation_toggle(self) -> None:
runtime = SimpleNamespace(
site_build_number="test-build",
site_banner_enabled=False,
site_banner_message="",
site_banner_tone="info",
site_banner_background_color=None,
site_banner_border_color=None,
site_login_message="",
site_login_show_jellyfin_login=True,
site_login_show_local_login=True,
site_login_show_forgot_password=True,
site_login_show_signup_link=True,
site_nav_show_requests=False,
)
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["navigation"], {"showRequests": False})
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
runtime = settings.model_copy(update={
"site_banner_enabled": True,
"site_banner_message": "Planned maintenance",
"site_banner_tone": "warning",
"site_banner_background_color": "#123ABC",
"site_banner_border_color": "red",
"site_login_message": "Use your Grizzlyflix account to sign in.",
})
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
info = site_router._build_site_info(False)
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
self.assertIsNone(info["banner"]["borderColor"])
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_banner_colours_are_normalized_before_saving(self) -> None:
with patch.object(admin_router, "set_setting") as save:
result = await admin_router.update_settings({
"site_banner_background_color": "#A1B2C3",
"site_banner_border_color": "#010203",
})
self.assertEqual(result, {"status": "ok", "updated": 2})
self.assertEqual(
save.call_args_list,
[
call("site_banner_background_color", "#a1b2c3"),
call("site_banner_border_color", "#010203"),
],
)
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
with self.assertRaises(HTTPException) as raised:
await admin_router.update_settings({
"site_banner_border_color": "red; background: url(example)",
})
self.assertEqual(raised.exception.status_code, 400)
class RequestCacheTests(unittest.TestCase):
def tearDown(self) -> None:
requests_router._detail_cache.clear()
requests_router._failed_detail_cache.clear()
def test_successful_detail_cache_write_clears_prior_failure(self) -> None:
key = "request:123"
requests_router._failure_cache_set(key)
self.assertTrue(requests_router._failure_cache_has(key))
requests_router._cache_set(key, {"id": 123})
self.assertFalse(requests_router._failure_cache_has(key))
self.assertEqual(requests_router._cache_get(key), {"id": 123})
class RequestVisibilityTests(TempDatabaseMixin, unittest.TestCase):
def test_non_admin_snapshot_excludes_advanced_identifying_data(self) -> None:
snapshot = Snapshot(
request_id="3925",
title="Example",
timeline=[
TimelineHop(
service="Seerr",
status="approved",
details={"requestedBy": "viewer@example.com"},
)
],
raw={
"jellyseerr": {"requestedBy": {"email": "viewer@example.com"}},
"qbittorrent": {"downloadIds": ["secret-hash"]},
},
actions=[
ActionOption(
id="search_releases",
label="Search and choose a download",
risk="safe",
)
],
presentation={"status": {"label": "Needs attention"}},
)
filtered = requests_router._filter_snapshot_for_user(
snapshot, {"username": "helper", "role": "user"}
)
self.assertEqual(filtered.timeline, [])
self.assertEqual(filtered.raw, {})
self.assertEqual([action.id for action in filtered.actions], ["search_releases"])
self.assertEqual(filtered.presentation["status"]["label"], "Needs attention")
def test_admin_snapshot_retains_advanced_diagnostics(self) -> None:
snapshot = Snapshot(
request_id="3925",
title="Example",
timeline=[TimelineHop(service="Seerr", status="approved")],
raw={"jellyseerr": {"id": 3925}},
)
filtered = requests_router._filter_snapshot_for_user(
snapshot, {"username": "admin", "role": "admin"}
)
self.assertEqual(len(filtered.timeline), 1)
self.assertEqual(filtered.raw["jellyseerr"]["id"], 3925)
def test_non_admin_cannot_request_advanced_history(self) -> None:
with self.assertRaises(HTTPException) as context:
requests_router._require_advanced_request_access(
{"username": "helper", "role": "user"}
)
self.assertEqual(context.exception.status_code, 403)
def test_my_requests_cache_only_returns_signed_in_users_rows(self) -> None:
previous = dict(requests_router._recent_cache)
requests_router._recent_cache["items"] = [
{"request_id": 100, "requested_by_id": 7, "requested_by_norm": "zak"},
{"request_id": 101, "requested_by_id": 8, "requested_by_norm": "someone-else"},
]
try:
rows = requests_router._get_recent_from_cache(
requested_by_norm="zak",
requested_by_id=7,
limit=10,
offset=0,
since_iso=None,
)
finally:
requests_router._recent_cache.clear()
requests_router._recent_cache.update(previous)
self.assertEqual([row["request_id"] for row in rows], [100])
class RequestPresentationTests(unittest.TestCase):
def test_repair_activity_shows_collector_search_before_download(self) -> None:
snapshot = Snapshot(
request_id="144",
title="Toy Story 2",
request_type=RequestType.movie,
state=NormalizedState.searching,
)
activity = _build_repair_activity(
snapshot,
action={
"action_id": "replace_media",
"status": "ok",
"message": "Radarr removed the file and started a replacement search.",
"created_at": "2026-09-01T09:06:55+00:00",
},
arr_state="searching",
arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
download={"visible": False, "state": "not_started", "torrents": []},
jellyfin_found=False,
)
self.assertIsNotNone(activity)
self.assertEqual(activity["state"], "searching")
self.assertEqual(activity["headline"], "Replacement search in progress")
self.assertEqual(activity["steps"][1]["state"], "complete")
self.assertEqual(activity["steps"][2]["state"], "waiting")
def test_repair_activity_tracks_replacement_download(self) -> None:
snapshot = Snapshot(
request_id="144",
title="Toy Story 2",
request_type=RequestType.movie,
state=NormalizedState.downloading,
)
activity = _build_repair_activity(
snapshot,
action={
"action_id": "replace_media",
"status": "ok",
"message": "Radarr started a replacement search.",
"created_at": "2026-09-01T09:06:55+00:00",
},
arr_state="searching",
arr_details={"availability": {"available": 0, "missing": 1, "total": 1}},
download={
"visible": True,
"state": "downloading",
"summary": "Downloading (1 active).",
"torrents": [{"progress": 0.25}],
},
jellyfin_found=False,
)
self.assertIsNotNone(activity)
self.assertEqual(activity["state"], "downloading")
self.assertEqual(activity["headline"], "Replacement download in progress")
self.assertEqual(activity["steps"][2]["state"], "active")
def test_repair_activity_reports_collected_file_and_media_index(self) -> None:
snapshot = Snapshot(
request_id="144",
title="Toy Story 2",
request_type=RequestType.movie,
state=NormalizedState.importing,
)
activity = _build_repair_activity(
snapshot,
action={
"action_id": "replace_media",
"status": "ok",
"message": "Radarr started a replacement search.",
"created_at": "2026-09-01T09:06:55+00:00",
},
arr_state="available",
arr_details={"availability": {"available": 1, "missing": 0, "total": 1}},
download={"visible": False, "state": "not_started", "torrents": []},
jellyfin_found=False,
)
self.assertIsNotNone(activity)
self.assertEqual(activity["state"], "indexing")
self.assertEqual(activity["steps"][2]["state"], "complete")
self.assertEqual(
activity["steps"][2]["detail"],
"Radarr reports the replacement file as collected and imported.",
)
self.assertEqual(activity["steps"][3]["state"], "active")
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
def test_episode_availability_counts_only_aired_monitored_episodes(self) -> None:
episodes = [
{"seasonNumber": 1, "episodeNumber": 1, "monitored": True, "hasFile": True},
{"seasonNumber": 1, "episodeNumber": 2, "monitored": True, "hasFile": False},
{"seasonNumber": 1, "episodeNumber": 3, "monitored": False, "hasFile": False},
{
"seasonNumber": 1,
"episodeNumber": 4,
"monitored": True,
"hasFile": False,
"airDateUtc": "2999-01-01T00:00:00Z",
},
]
availability = _episode_availability(episodes)
self.assertEqual(availability["available"], 1)
self.assertEqual(availability["missing"], 1)
self.assertEqual(availability["total"], 2)
def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
series = {
"seasons": [
{"seasonNumber": 0, "monitored": False},
{"seasonNumber": 7, "monitored": True},
{
"seasonNumber": 8,
"monitored": False,
"statistics": {"episodeCount": 16, "episodeFileCount": 2},
},
{"seasonNumber": 9, "monitored": False},
]
}
episodes = [
{"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
{"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
]
options = _unmonitored_season_options(series, episodes)
self.assertEqual(
options,
[
{"seasonNumber": 8, "episodeCount": 16, "available": 2},
{"seasonNumber": 9, "episodeCount": 2, "available": 1},
],
)
def test_presentation_hides_download_without_download_evidence(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.added_to_arr,
actions=[],
)
presentation = _build_presentation(
snapshot,
approved=True,
arr_state="added",
arr_details={
"availability": {"available": 0, "missing": 6, "total": 6, "seasons": []}
},
prowlarr_state="ok",
download={
"visible": False,
"state": "not_started",
"summary": "No download attempt has been observed.",
"torrents": [],
},
jellyfin_found=False,
jellyfin_link=None,
)
self.assertFalse(presentation["download"]["visible"])
self.assertIn("waiting for 6 episodes", presentation["status"]["label"])
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
self.assertEqual(download_stage["summary"], "No download attempt yet")
def test_available_content_replaces_stale_download_warning_with_completion(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.completed,
actions=[],
)
presentation = _build_presentation(
snapshot,
approved=True,
arr_state="available",
arr_details={
"availability": {"available": 6, "missing": 0, "total": 6, "seasons": []}
},
prowlarr_state="ok",
download={
"visible": True,
"state": "missing",
"summary": "A previous download was observed, but it is not currently visible in qBittorrent.",
"torrents": [],
},
jellyfin_found=True,
jellyfin_link="https://media.test/title/3909",
)
self.assertFalse(presentation["download"]["visible"])
self.assertEqual(presentation["download"]["state"], "completed")
self.assertEqual(presentation["nextStep"]["title"], "Ready to watch")
self.assertEqual(presentation["nextStep"]["actionIds"], [])
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
self.assertEqual(download_stage["label"], "Download complete")
self.assertEqual(download_stage["state"], "complete")
self.assertFalse(download_stage["visible"])
self.assertEqual(
download_stage["summary"],
"The requested content has been collected and is available to watch. No further action is needed.",
)
search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search")
available_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "available")
self.assertEqual(search_stage["state"], "complete")
self.assertEqual(search_stage["actionIds"], [])
self.assertEqual(available_stage["state"], "complete")
self.assertEqual(available_stage["stateLabel"], "Ready")
self.assertEqual(available_stage["label"], "Available to watch")
self.assertEqual(available_stage["summary"], "This title is ready to watch in Jellyfin.")
self.assertEqual(available_stage["link"], "https://media.test/title/3909")
def test_partially_available_content_keeps_missing_download_attention(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.importing,
actions=[],
)
presentation = _build_presentation(
snapshot,
approved=True,
arr_state="added",
arr_details={
"availability": {"available": 3, "missing": 3, "total": 6, "seasons": []}
},
prowlarr_state="ok",
download={
"visible": True,
"state": "missing",
"summary": "A previous download is no longer visible.",
"torrents": [],
},
jellyfin_found=True,
jellyfin_link="https://media.test/title/3909",
)
self.assertTrue(presentation["download"]["visible"])
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
self.assertEqual(download_stage["label"], "Download")
self.assertEqual(download_stage["state"], "attention")
self.assertTrue(download_stage["visible"])
def test_collector_file_waits_for_media_server_before_marking_available(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="I See You",
request_type=RequestType.movie,
state=NormalizedState.importing,
actions=[],
)
presentation = _build_presentation(
snapshot,
approved=True,
arr_state="available",
arr_details={
"availability": {"available": 1, "missing": 0, "total": 1, "seasons": []}
},
prowlarr_state="ok",
download={
"visible": False,
"state": "not_started",
"summary": "No download attempt has been observed.",
"torrents": [],
},
jellyfin_found=False,
jellyfin_link=None,
)
self.assertEqual(presentation["status"]["label"], "Collected — waiting for the media server")
self.assertEqual(presentation["nextStep"]["title"], "Wait for the media server to index this title")
search_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "search")
download_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "download")
available_stage = next(stage for stage in presentation["pipeline"] if stage["id"] == "available")
self.assertEqual(search_stage["state"], "complete")
self.assertEqual(download_stage["state"], "complete")
self.assertEqual(available_stage["state"], "active")
self.assertEqual(available_stage["stateLabel"], "Indexing")
self.assertEqual(available_stage["label"], "Adding to Jellyfin")
self.assertEqual(
available_stage["summary"],
"The download is complete. Jellyfin is indexing this title now.",
)
class RequestCreationFlowTests(unittest.IsolatedAsyncioTestCase):
def test_sparse_seerr_request_is_enriched_with_media_lookup(self) -> None:
sparse = {
"id": 3925,
"type": "movie",
"status": 2,
"media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112},
}
details = {
"title": "Batman v Superman: Dawn of Justice",
"releaseDate": "2016-03-23",
"posterPath": "/poster.jpg",
}
enriched = requests_router._merge_request_media_details(sparse, details)
parsed = requests_router._parse_request_payload(enriched)
self.assertEqual(parsed["title"], "Batman v Superman: Dawn of Justice")
self.assertEqual(parsed["year"], 2016)
self.assertEqual(enriched["media"]["posterPath"], "/poster.jpg")
self.assertNotIn("title", sparse["media"])
async def test_seerr_search_percent_encodes_multi_word_titles(self) -> None:
client = requests_router.JellyseerrClient("http://seerr.test", "key")
client.get = AsyncMock(return_value={"results": []})
await client.search("Ricky Gervais Alley Cats", page=2)
client.get.assert_awaited_once_with(
"/api/v1/search?query=Ricky%20Gervais%20Alley%20Cats&page=2"
)
async def test_seerr_request_includes_validated_destination_and_profile(self) -> None:
client = requests_router.JellyseerrClient("http://seerr.test", "key")
client.post = AsyncMock(return_value={"id": 42})
await client.create_request(
media_type="tv",
media_id=123,
seasons=[1, 2],
server_id=0,
profile_id=7,
root_folder="/TV98",
)
client.post.assert_awaited_once_with(
"/api/v1/request",
payload={
"mediaType": "tv",
"mediaId": 123,
"seasons": [1, 2],
"serverId": 0,
"profileId": 7,
"rootFolder": "/TV98",
},
)
async def test_seerr_write_completes_csrf_cookie_handshake(self) -> None:
observed: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
observed.append(request)
if request.method == "GET":
return httpx.Response(
200,
headers=[
("set-cookie", "_csrf=secret-value; Path=/; Secure; HttpOnly; SameSite=Strict"),
("set-cookie", "XSRF-TOKEN=csrf%2Etoken; Path=/; Secure; SameSite=Strict"),
],
json={"id": 1},
)
return httpx.Response(201, json={"id": 42})
transport = httpx.MockTransport(handler)
seerr = requests_router.JellyseerrClient("https://seerr.test", "api-key")
async with httpx.AsyncClient(transport=transport) as client:
response = await seerr._send_request(
client,
"POST",
"https://seerr.test/api/v1/request",
headers=seerr.headers(),
params=None,
payload={"mediaType": "movie", "mediaId": 209112},
)
self.assertEqual(response.status_code, 201)
self.assertEqual([request.method for request in observed], ["GET", "POST"])
write_request = observed[1]
self.assertEqual(write_request.headers.get("XSRF-TOKEN"), "csrf.token")
self.assertEqual(write_request.headers.get("Origin"), "https://seerr.test")
self.assertIn("_csrf=secret-value", write_request.headers.get("Cookie", ""))
self.assertIn("XSRF-TOKEN=csrf%2Etoken", write_request.headers.get("Cookie", ""))
async def test_base_request_passes_payload_to_transport_hook(self) -> None:
captured: dict = {}
async def send_request(
_client: httpx.AsyncClient,
method: str,
url: str,
*,
headers: dict,
params: dict | None,
payload: dict | None,
) -> httpx.Response:
captured.update(
method=method,
url=url,
headers=headers,
params=params,
payload=payload,
)
return httpx.Response(200, request=httpx.Request(method, url), json={"ok": True})
client = requests_router.JellyseerrClient("https://seerr.test", "api-key")
with patch.object(client, "_send_request", new=send_request):
result = await client._request(
"POST",
"/api/v1/request",
payload={"mediaType": "movie", "mediaId": 209112},
)
self.assertEqual(result, {"ok": True})
self.assertEqual(captured["payload"], {"mediaType": "movie", "mediaId": 209112})
async def test_request_destination_uses_admin_default_before_seerr(self) -> None:
runtime = SimpleNamespace(
sonarr_base_url="http://sonarr.test",
sonarr_api_key="key",
sonarr_quality_profile_id=7,
sonarr_root_folder="/tv",
)
seerr = SimpleNamespace(
get_service_settings=AsyncMock(
return_value=[
{
"id": 4,
"name": "Main Sonarr",
"isDefault": True,
"is4k": False,
"activeProfileId": 10,
"activeDirectory": "/tv",
}
]
)
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_quality_profiles=AsyncMock(
return_value=[{"id": 7, "name": "WEB-1080p"}, {"id": 10, "name": "Optimal"}]
),
get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/tv"}]),
)
with patch.object(requests_router, "SonarrClient", return_value=sonarr):
destination = await requests_router._resolve_request_destination(
runtime, seerr, "tv"
)
self.assertEqual(destination["profile_id"], 7)
self.assertEqual(destination["default_profile_id"], 7)
self.assertEqual(destination["root_folder"], "/tv")
self.assertEqual(destination["profiles"], [
{"id": 7, "name": "WEB-1080p"},
{"id": 10, "name": "Optimal"},
])
async def test_request_defaults_inherit_seerr_only_when_unset(self) -> None:
for media_type, service in [('movie', 'radarr'), ('tv', 'sonarr')]:
runtime = SimpleNamespace(**{
service + '_base_url': 'http://collector.test', service + '_api_key': 'key',
service + '_quality_profile_id': None, service + '_root_folder': '/media',
})
seerr = SimpleNamespace(get_service_settings=AsyncMock(return_value=[{
'id': 1, 'isDefault': True, 'activeProfileId': 7, 'activeDirectory': '/media',
}]))
collector = SimpleNamespace(configured=lambda: True,
get_quality_profiles=AsyncMock(return_value=[{'id': 7, 'name': 'HD'}]),
get_root_folders=AsyncMock(return_value=[{'path': '/media'}]))
with patch.object(requests_router, 'RadarrClient' if service == 'radarr' else 'SonarrClient', return_value=collector):
result = await requests_router._resolve_request_destination(runtime, seerr, media_type)
self.assertEqual(result['profile_id'], 7)
seerr.get_service_settings.return_value[0]['activeProfileId'] = 999
with self.assertRaises(HTTPException):
await requests_router._resolve_request_destination(runtime, seerr, media_type)
async def test_request_creation_ignores_browser_quality_override(self) -> None:
runtime = SimpleNamespace(jellyseerr_base_url='http://seerr.test', jellyseerr_api_key='key')
seerr = SimpleNamespace(configured=lambda: True,
get_movie=AsyncMock(return_value={'title': 'Movie'}),
create_request=AsyncMock(return_value={'status': 1}))
destination = {'server_id': 1, 'profile_id': 7, 'root_folder': '/movies'}
with patch.object(requests_router, 'get_runtime_settings', return_value=runtime), \
patch.object(requests_router, 'JellyseerrClient', return_value=seerr), \
patch.object(requests_router, '_resolve_request_destination', new_callable=AsyncMock, return_value=destination) as resolve:
await requests_router.create_request({'mediaType': 'movie', 'tmdbId': 123, 'profileId': 999}, {'username': 'viewer'})
resolve.assert_awaited_once_with(runtime, seerr, 'movie')
self.assertEqual(seerr.create_request.await_args.kwargs['profile_id'], 7)
async def test_request_destination_rejects_stale_profile_id(self) -> None:
runtime = SimpleNamespace(
radarr_base_url="http://radarr.test",
radarr_api_key="key",
radarr_quality_profile_id=999,
radarr_root_folder="/movies",
)
seerr = SimpleNamespace(
get_service_settings=AsyncMock(
return_value=[
{
"id": 2,
"name": "Main Radarr",
"isDefault": True,
"is4k": False,
"activeProfileId": 6,
"activeDirectory": "/movies",
}
]
)
)
radarr = SimpleNamespace(
configured=lambda: True,
get_quality_profiles=AsyncMock(return_value=[{"id": 6, "name": "HD"}]),
get_root_folders=AsyncMock(return_value=[{"id": 1, "path": "/movies"}]),
)
with patch.object(requests_router, "RadarrClient", return_value=radarr):
with self.assertRaises(HTTPException) as context:
await requests_router._resolve_request_destination(
runtime, seerr, "movie"
)
self.assertEqual(context.exception.status_code, 409)
self.assertIn("not available in Radarr", context.exception.detail)
class RequestRecheckTests(unittest.IsolatedAsyncioTestCase):
async def test_recheck_refreshes_seerr_cache_and_returns_rebuilt_snapshot(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr.test",
jellyseerr_api_key="seerr-key",
)
fresh_request = {
"id": 3914,
"type": "movie",
"status": 2,
"createdAt": "2026-08-30T00:00:00Z",
"updatedAt": "2026-08-30T01:00:00Z",
"requestedBy": {"username": "viewer"},
"media": {
"id": 9001,
"mediaType": "movie",
"tmdbId": 524251,
"title": "I See You",
"year": 2019,
},
}
seerr = SimpleNamespace(
configured=lambda: True,
get_request=AsyncMock(return_value=fresh_request),
)
snapshot = Snapshot(
request_id="3914",
title="I See You",
request_type=RequestType.movie,
state=NormalizedState.importing,
presentation={
"status": {"label": "Collected — waiting for the media server"},
},
)
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
requests_router, "JellyseerrClient", return_value=seerr
), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object(
requests_router, "_cache_set"
) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "save_action") as save_action:
result = await requests_router.action_recheck(
"3914", user={"username": "viewer", "role": "user"}
)
seerr.get_request.assert_awaited_once_with("3914")
upsert.assert_called_once()
cache_set.assert_called_once_with("request:3914", fresh_request)
save_action.assert_called_once()
self.assertEqual(result["status"], "ok")
self.assertIs(result["snapshot"], snapshot)
async def test_recheck_hydrates_sparse_seerr_request_before_caching(self) -> None:
runtime = SimpleNamespace(jellyseerr_base_url="http://seerr.test", jellyseerr_api_key="key")
sparse_request = {
"id": 3925,
"type": "movie",
"status": 2,
"requestedBy": {"username": "viewer"},
"media": {"id": 2444, "mediaType": "movie", "tmdbId": 209112},
}
seerr = SimpleNamespace(
configured=lambda: True,
get_request=AsyncMock(return_value=sparse_request),
get_movie=AsyncMock(
return_value={
"title": "Batman v Superman: Dawn of Justice",
"releaseDate": "2016-03-23",
}
),
)
snapshot = Snapshot(
request_id="3925",
title="Batman v Superman: Dawn of Justice",
request_type=RequestType.movie,
state=NormalizedState.downloading,
presentation={"status": {"label": "Download in progress"}},
)
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
requests_router, "JellyseerrClient", return_value=seerr
), patch.object(
requests_router,
"_get_media_details",
new=AsyncMock(
return_value={
"title": "Batman v Superman: Dawn of Justice",
"releaseDate": "2016-03-23",
}
),
), patch.object(requests_router, "upsert_request_cache") as upsert, patch.object(
requests_router, "_cache_set"
) as cache_set, patch.object(requests_router, "_refresh_recent_cache_from_db"), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "save_action"):
await requests_router.action_recheck(
"3925", user={"username": "viewer", "role": "user"}
)
cached_record = upsert.call_args.kwargs
self.assertEqual(cached_record["title"], "Batman v Superman: Dawn of Justice")
cached_payload = cache_set.call_args.args[1]
self.assertEqual(cached_payload["media"]["title"], "Batman v Superman: Dawn of Justice")
class SnapshotIdentityTests(unittest.TestCase):
def test_radarr_identity_replaces_unknown_cached_title(self) -> None:
snapshot = Snapshot(request_id="3925", title="Unknown", request_type=RequestType.movie)
_apply_arr_identity(
snapshot,
{"title": "Batman v Superman: Dawn of Justice", "year": 2016},
)
self.assertEqual(snapshot.title, "Batman v Superman: Dawn of Justice")
self.assertEqual(snapshot.year, 2016)
class LiveDownloadProgressTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_live_download_progress_uses_saved_hash_and_current_qbittorrent_value(self) -> None:
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
qbittorrent_base_url="http://qbittorrent.test",
qbittorrent_username="magent",
qbittorrent_password="secret",
)
evidence = {
"observed": True,
"torrents": [{"hash": "abc123", "progress": 0.12}],
}
current = [{"hash": "abc123", "name": "Example", "progress": 0.1344, "state": "downloading"}]
with patch.object(requests_router, "get_runtime_settings", return_value=runtime), patch.object(
requests_router,
"get_request_download_evidence",
return_value=evidence,
), patch.object(
requests_router.QBittorrentClient,
"get_torrents_by_hashes",
new=AsyncMock(return_value=current),
) as get_torrents:
result = await requests_router.get_download_progress(
"3909", user={"username": "viewer", "role": "user"}
)
get_torrents.assert_awaited_once_with("abc123")
self.assertEqual(result["state"], "downloading")
self.assertEqual(result["torrents"][0]["progressPercent"], 13.4)
class ArrAddPayloadTests(unittest.IsolatedAsyncioTestCase):
async def test_radarr_add_resolves_title_before_posting_movie(self) -> None:
client = requests_router.RadarrClient("http://radarr.test", "radarr-key")
with patch.object(
client,
"get",
new=AsyncMock(return_value={"title": "A Grand Day Out", "tmdbId": 530}),
) as lookup, patch.object(
client,
"post",
new=AsyncMock(return_value={"id": 12, "title": "A Grand Day Out"}),
) as create:
result = await client.add_movie(530, 1, "/movies")
lookup.assert_awaited_once_with("/api/v3/movie/lookup/tmdb", params={"tmdbId": 530})
payload = create.await_args.kwargs["payload"]
self.assertEqual(payload["title"], "A Grand Day Out")
self.assertEqual(payload["tmdbId"], 530)
self.assertEqual(result["id"], 12)
async def test_sonarr_add_resolves_matching_series_title_before_posting(self) -> None:
client = requests_router.SonarrClient("http://sonarr.test", "sonarr-key")
lookup_response = [
{"title": "Wrong Show", "tvdbId": 111},
{"title": "Example Show", "tvdbId": 222},
]
with patch.object(
client,
"get",
new=AsyncMock(return_value=lookup_response),
) as lookup, patch.object(
client,
"post",
new=AsyncMock(return_value={"id": 42, "title": "Example Show"}),
) as create:
result = await client.add_series(222, 2, "/television")
lookup.assert_awaited_once_with("/api/v3/series/lookup", params={"term": "tvdb:222"})
payload = create.await_args.kwargs["payload"]
self.assertEqual(payload["title"], "Example Show")
self.assertEqual(payload["tvdbId"], 222)
self.assertEqual(result["id"], 42)
def test_arr_error_message_does_not_expose_upstream_stack_trace(self) -> None:
response = httpx.Response(
500,
request=httpx.Request("POST", "http://radarr.test/api/v3/movie"),
json={
"message": "Object reference not set to an instance of an object.",
"description": "System.NullReferenceException\n at Radarr.Internal.SecretMethod()",
},
)
error = httpx.HTTPStatusError("Radarr failed", request=response.request, response=response)
message = requests_router._format_upstream_error("Radarr", error)
self.assertIn("Object reference", message)
self.assertNotIn("NullReferenceException", message)
self.assertNotIn("SecretMethod", message)
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)
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.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(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr.test",
sonarr_api_key="sonarr-key",
radarr_base_url="http://radarr.test",
radarr_api_key="radarr-key",
)
async def test_tv_manual_search_uses_sonarr_and_keeps_season_packs(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example Show",
request_type=RequestType.tv,
raw={"arr": {"item": {"id": 42}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episodes=AsyncMock(
return_value=[
{"id": 101, "seasonNumber": 1, "monitored": True, "hasFile": False},
{"id": 201, "seasonNumber": 2, "monitored": True, "hasFile": False},
{"id": 202, "seasonNumber": 2, "monitored": True, "hasFile": True},
]
),
search_episode_releases=AsyncMock(
side_effect=[
[
{
"title": "Example.Show.S01.1080p",
"guid": "season-one",
"indexerId": 7,
"indexer": "Prowlarr",
"protocol": "torrent",
"fullSeason": True,
"seasonNumber": 1,
"approved": True,
"rejected": False,
"downloadAllowed": True,
"quality": {"quality": {"name": "WEBDL-1080p"}},
},
{
"title": "Example.Show.S01.2160p",
"guid": "outside-profile",
"indexerId": 7,
"approved": False,
"rejected": True,
"rejections": ["Quality is not wanted in profile"],
}
],
[],
]
),
)
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_search(
"3909", user={"username": "viewer", "role": "user"}
)
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"]), 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.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(
request_id="4000",
title="Example Movie",
request_type=RequestType.movie,
raw={"arr": {"item": {"id": 84}}},
)
radarr = SimpleNamespace(
configured=lambda: True,
search_releases=AsyncMock(
return_value=[
{
"title": "Example.Movie.2026.1080p",
"guid": "movie-release",
"indexerId": 9,
"indexer": "Prowlarr",
"protocol": "torrent",
"approved": True,
"rejected": False,
"downloadAllowed": True,
}
]
),
)
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_search(
"4000", user={"username": "viewer", "role": "user"}
)
radarr.search_releases.assert_awaited_once_with(84)
self.assertEqual(result["collector"], "Radarr")
self.assertEqual(result["releases"][0]["guid"], "movie-release")
self.assertTrue(result["releases"][0]["bestPick"])
def test_manual_release_filter_requires_explicit_arr_approval(self) -> None:
releases = requests_router._filter_arr_release_results(
[
{"title": "Missing decision", "guid": "missing", "indexerId": 1},
{
"title": "Temporarily rejected",
"guid": "temporary",
"indexerId": 1,
"approved": True,
"temporarilyRejected": True,
},
{
"title": "Approved release",
"guid": "approved",
"indexerId": 1,
"approved": True,
"rejected": False,
"downloadAllowed": True,
},
]
)
self.assertEqual([release["guid"] for release in releases], ["approved"])
self.assertTrue(releases[0]["bestPick"])
async def test_tv_manual_grab_is_sent_to_sonarr_not_qbittorrent(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example Show",
request_type=RequestType.tv,
)
sonarr = SimpleNamespace(
configured=lambda: True,
grab_release=AsyncMock(return_value={"guid": "season-one", "indexerId": 7}),
push_release=AsyncMock(),
)
payload = {
"title": "Example.Show.S01.1080p",
"guid": "season-one",
"indexerId": 7,
"protocol": "torrent",
}
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "SonarrClient", return_value=sonarr), patch.object(
requests_router, "save_action"
):
result = await requests_router.action_grab(
"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_requires_fresh_search(self) -> None:
snapshot = Snapshot(
request_id="4000",
title="Example Movie",
request_type=RequestType.movie,
)
response = httpx.Response(
404,
request=httpx.Request("POST", "http://radarr.test/api/v3/release"),
json={"message": "release cache expired"},
)
cache_miss = httpx.HTTPStatusError(
"release cache expired",
request=response.request,
response=response,
)
radarr = SimpleNamespace(
configured=lambda: True,
grab_release=AsyncMock(side_effect=cache_miss),
push_release=AsyncMock(return_value=[{"approved": True, "downloadAllowed": True}]),
)
payload = {
"title": "Example.Movie.2026.1080p",
"guid": "stale-release",
"indexerId": 9,
"indexer": "Prowlarr",
"protocol": "torrent",
"publishDate": "2026-08-29T00:00:00Z",
"downloadUrl": "http://prowlarr.test/download/1",
}
with patch.object(requests_router, "get_runtime_settings", return_value=self._runtime()), patch.object(
requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)
), patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(
requests_router, "save_action"
):
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()
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(
"MixedCaseUser",
"password123",
email=None,
auth_provider="local",
)
self.assertTrue(created)
updated = db.set_user_email("mixedcaseuser", "mixed@example.com")
self.assertTrue(updated)
stored = db.get_user_by_username("MIXEDCASEUSER")
self.assertIsNotNone(stored)
self.assertEqual(stored.get("email"), "mixed@example.com")
class AdminUserEmailTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_admin_can_add_and_remove_user_email(self) -> None:
db.create_user_if_missing("Viewer", "password123", auth_provider="local")
saved = await admin_router.update_user_email("viewer", {"email": "viewer@example.com"})
self.assertEqual(saved["user"]["email"], "viewer@example.com")
cleared = await admin_router.update_user_email("VIEWER", {"email": None})
self.assertIsNone(cleared["user"]["email"])
async def test_admin_cannot_assign_duplicate_user_email(self) -> None:
db.create_user_if_missing(
"FirstViewer", "password123", email="shared@example.com", auth_provider="local"
)
db.create_user_if_missing("SecondViewer", "password123", auth_provider="local")
with self.assertRaises(HTTPException) as context:
await admin_router.update_user_email(
"SecondViewer", {"email": "SHARED@example.com"}
)
self.assertEqual(context.exception.status_code, 409)
self.assertIn("another user", str(context.exception.detail))
async def test_admin_user_email_requires_valid_address(self) -> None:
db.create_user_if_missing("Viewer", "password123", auth_provider="local")
with self.assertRaises(HTTPException) as context:
await admin_router.update_user_email("Viewer", {"email": "not-an-email"})
self.assertEqual(context.exception.status_code, 400)
class SnapshotHistoryTests(TempDatabaseMixin, unittest.TestCase):
def test_duplicate_snapshots_are_not_saved_and_download_evidence_is_retained(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Example",
request_type=RequestType.tv,
state=NormalizedState.downloading,
state_reason="Downloading one episode.",
timeline=[
TimelineHop(
service="qBittorrent",
status="downloading",
details={
"summary": "Downloading one item.",
"torrents": [{"hash": "abc", "progress": 0.5}],
},
)
],
)
db.save_snapshot(snapshot)
db.save_snapshot(snapshot)
history = db.get_recent_snapshots("3909", 10)
evidence = db.get_request_download_evidence("3909")
self.assertEqual(len(history), 1)
self.assertTrue(evidence["observed"])
self.assertEqual(evidence["state"], "downloading")
class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_user_can_manage_own_profile_email(self) -> None:
db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local")
current_user = {"username": "ProfileViewer", "role": "user"}
saved = await auth_router.update_profile_email(
{"email": "viewer@example.com"}, current_user
)
self.assertEqual(saved["email"], "viewer@example.com")
self.assertEqual(
db.get_user_by_username("profileviewer").get("email"),
"viewer@example.com",
)
cleared = await auth_router.update_profile_email({"email": None}, current_user)
self.assertIsNone(cleared["email"])
self.assertIsNone(db.get_user_by_username("ProfileViewer").get("email"))
async def test_user_cannot_claim_another_accounts_email(self) -> None:
db.create_user_if_missing(
"FirstViewer", "password123", email="shared@example.com", auth_provider="local"
)
db.create_user_if_missing("SecondViewer", "password123", auth_provider="local")
with self.assertRaises(HTTPException) as context:
await auth_router.update_profile_email(
{"email": "SHARED@example.com"},
{"username": "SecondViewer", "role": "user"},
)
self.assertEqual(context.exception.status_code, 409)
async def test_profile_email_requires_valid_address(self) -> None:
db.create_user_if_missing("ProfileViewer", "password123", auth_provider="local")
with self.assertRaises(HTTPException) as context:
await auth_router.update_profile_email(
{"email": "not-an-email"},
{"username": "ProfileViewer", "role": "user"},
)
self.assertEqual(context.exception.status_code, 400)
async def test_forgot_password_is_rate_limited(self) -> None:
request = _build_request(ip="10.1.2.3")
payload = {"identifier": "resetuser@example.com"}
with patch.object(auth_router, "smtp_email_config_ready", return_value=(True, "")), patch.object(
auth_router,
"request_password_reset",
new=AsyncMock(return_value={"status": "ok", "issued": False}),
):
for _ in range(3):
result = await auth_router.forgot_password(payload, request)
self.assertEqual(result["status"], "ok")
with self.assertRaises(HTTPException) as context:
await auth_router.forgot_password(payload, request)
self.assertEqual(context.exception.status_code, 429)
self.assertEqual(
context.exception.detail,
"Too many password reset attempts. Try again shortly.",
)
async def test_request_password_reset_prefers_local_user_email(self) -> None:
db.create_user_if_missing(
"ResetUser",
"password123",
email="local@example.com",
auth_provider="local",
)
with patch.object(
password_reset,
"send_password_reset_email",
new=AsyncMock(return_value={"status": "ok"}),
) as send_email:
result = await password_reset.request_password_reset("ResetUser")
self.assertTrue(result["issued"])
self.assertEqual(result["recipient_email"], "local@example.com")
send_email.assert_awaited_once()
self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com")
async def test_profile_manual_invite_does_not_require_recipient_email(self) -> None:
current_user = {
"username": "invite-owner",
"role": "user",
"invite_management_enabled": True,
"profile_id": None,
}
result = await auth_router.create_profile_invite(
{"label": "Family", "recipient_email": None, "send_email": False},
current_user,
)
self.assertEqual(result["status"], "ok")
self.assertIsNone(result["invite"]["recipient_email"])
self.assertIsNone(result["email"])
async def test_profile_email_delivery_requires_recipient_email(self) -> None:
current_user = {
"username": "invite-owner",
"role": "user",
"invite_management_enabled": True,
"profile_id": None,
}
with self.assertRaises(HTTPException) as context:
await auth_router.create_profile_invite(
{"label": "Missing email", "send_email": True},
current_user,
)
self.assertEqual(context.exception.status_code, 400)
self.assertEqual(context.exception.detail, "recipient_email is required and must be a valid email address.")
class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
super().setUp()
access = patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
)
access.start()
self.addCleanup(access.stop)
def test_failed_repair_marks_linked_issue_as_blocked(self) -> None:
issue = {"id": 12, "status": "in_progress"}
with (
patch.object(requests_router, "update_portal_item") as update_issue,
patch.object(requests_router, "add_portal_item_activity"),
):
requests_router._record_replacement_activity(
issue,
user={"username": "viewer", "role": "user"},
event_type="replacement_failed",
message="Radarr could not start the replacement.",
)
update_issue.assert_called_once_with(12, status="blocked", issue_resolved_at=None)
async def test_movie_replacement_validates_file_then_deletes_and_searches(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Replacement Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={
"arr": {
"item": {
"id": 44,
"movieFile": {
"id": 77,
"relativePath": "Replacement.Movie.1080p.mkv",
},
}
}
},
)
radarr = SimpleNamespace(
configured=lambda: True,
monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}),
delete_movie_file=AsyncMock(return_value=None),
search=AsyncMock(return_value={"id": 1}),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
radarr_base_url="http://radarr",
radarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "RadarrClient", return_value=radarr),
patch.object(requests_router, "save_action"),
patch.object(
requests_router,
"get_portal_item",
return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3914",
"created_by_username": "admin",
},
),
patch.object(requests_router, "update_portal_item") as update_issue,
patch.object(requests_router, "add_portal_item_activity") as add_activity,
):
result = await requests_router.action_replace_media(
"3914",
{"file_id": 77, "confirmed": True, "issue_id": 12},
{"username": "admin", "role": "admin", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
radarr.monitor_movie.assert_awaited_once_with(44, True)
radarr.delete_movie_file.assert_awaited_once_with(77)
radarr.search.assert_awaited_once_with(44)
add_activity.assert_called_once()
self.assertEqual(add_activity.call_args.kwargs["event_type"], "replacement_started")
self.assertIn('"repairTracking"', add_activity.call_args.kwargs["metadata_json"])
self.assertIn('"originalFileIds":[77]', add_activity.call_args.kwargs["metadata_json"])
update_issue.assert_called_once_with(12, status="in_progress", issue_resolved_at=None)
async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Replacement Series",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episode_files=AsyncMock(return_value=[{
"id": 88,
"seasonNumber": 2,
"path": "/private/library/Replacement.Series.S02E03.mkv",
"size": 1024,
"quality": {"quality": {"name": "WEBDL-1080p"}},
}]),
get_episodes=AsyncMock(return_value=[{
"id": 101,
"episodeFileId": 88,
"seasonNumber": 2,
"episodeNumber": 3,
}]),
)
runtime = SimpleNamespace(
jellyseerr_base_url="http://seerr",
jellyseerr_api_key="secret",
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
):
result = await requests_router.replacement_options(
"3909",
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["files"][0]["name"], "Replacement.Series.S02E03.mkv")
self.assertEqual(result["files"][0]["episodes"], ["S02E03"])
self.assertNotIn("/private/library", str(result))
async def test_issue_options_mark_released_missing_episodes_as_best_fit(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Target Series",
request_type=RequestType.tv,
state=NormalizedState.downloading,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episodes=AsyncMock(return_value=[
{
"id": 101,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "Collected",
"monitored": True,
"hasFile": True,
"episodeFileId": 88,
"airDateUtc": "2020-01-01T00:00:00Z",
},
{
"id": 102,
"seasonNumber": 1,
"episodeNumber": 2,
"title": "Missing",
"monitored": True,
"hasFile": False,
"episodeFileId": 0,
"airDateUtc": "2020-01-08T00:00:00Z",
},
{
"id": 103,
"seasonNumber": 1,
"episodeNumber": 3,
"title": "Missing and unmonitored",
"monitored": False,
"hasFile": False,
"episodeFileId": 0,
"airDateUtc": "2020-01-15T00:00:00Z",
},
]),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
):
result = await requests_router.issue_target_options(
"3909",
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["seasons"][0]["missing_count"], 2)
self.assertTrue(result["seasons"][0]["best_fit"])
missing = next(item for item in result["episodes"] if item["id"] == 102)
self.assertTrue(missing["missing"])
self.assertTrue(missing["best_fit"])
unmonitored = next(item for item in result["episodes"] if item["id"] == 103)
self.assertFalse(unmonitored["monitored"])
self.assertTrue(unmonitored["missing"])
self.assertTrue(unmonitored["best_fit"])
collected = next(item for item in result["episodes"] if item["id"] == 101)
self.assertEqual(collected["file_id"], 88)
self.assertNotIn("file_name", collected)
self.assertNotIn("quality", collected)
async def test_tv_replacement_accepts_multiple_selected_episode_files(self) -> None:
snapshot = Snapshot(
request_id="3909",
title="Replacement Series",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 22}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episode_files=AsyncMock(return_value=[
{"id": 88, "relativePath": "S01E01.mkv"},
{"id": 89, "relativePath": "S01E02.mkv"},
]),
get_episodes=AsyncMock(return_value=[
{"id": 101, "episodeFileId": 88, "seasonNumber": 1, "episodeNumber": 1},
{"id": 102, "episodeFileId": 89, "seasonNumber": 1, "episodeNumber": 2},
]),
monitor_episodes=AsyncMock(return_value={"monitored": True}),
delete_episode_file=AsyncMock(return_value=None),
search_episodes=AsyncMock(return_value={"id": 1}),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3909",
"created_by_username": "viewer",
}),
patch.object(requests_router, "update_portal_item"),
patch.object(requests_router, "add_portal_item_activity"),
):
result = await requests_router.action_replace_media(
"3909",
{"file_ids": [88, 89], "confirmed": True, "issue_id": 12},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["file_ids"], [88, 89])
sonarr.monitor_episodes.assert_awaited_once_with([101, 102], True)
self.assertEqual(sonarr.delete_episode_file.await_count, 2)
sonarr.search_episodes.assert_awaited_once_with([101, 102])
async def test_missing_episode_search_monitors_explicit_unmonitored_episode(self) -> None:
snapshot = Snapshot(
request_id="113",
title="Family Guy",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 540}}},
)
sonarr = SimpleNamespace(
configured=lambda: True,
get_episodes=AsyncMock(return_value=[{
"id": 36899,
"seasonNumber": 5,
"episodeNumber": 9,
"monitored": False,
"hasFile": False,
"episodeFileId": 0,
"airDateUtc": "2006-12-17T00:00:00Z",
}]),
monitor_episodes=AsyncMock(return_value={"monitored": True}),
search_episodes=AsyncMock(return_value={"id": 9001}),
search=AsyncMock(return_value={"id": 9002}),
)
runtime = SimpleNamespace(
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_search_missing_media(
"113",
{"episode_ids": [36899], "season_numbers": [5]},
{"username": "admin", "role": "admin", "auto_search_enabled": True},
)
self.assertEqual(result["episode_ids"], [36899])
sonarr.monitor_episodes.assert_awaited_once_with([36899], True)
sonarr.search_episodes.assert_awaited_once_with([36899])
sonarr.search.assert_not_awaited()
async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
snapshot = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 540}}},
)
refreshed = Snapshot(
request_id="3580",
title="Suits",
request_type=RequestType.tv,
state=NormalizedState.importing,
presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
)
original_series = {
"id": 540,
"monitored": True,
"qualityProfileId": 7,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": False},
{"seasonNumber": 9, "monitored": False},
],
}
updated_series = {
**original_series,
"seasons": [
{"seasonNumber": 7, "monitored": True},
{"seasonNumber": 8, "monitored": True},
{"seasonNumber": 9, "monitored": True},
],
}
episodes = [
{
"id": 801,
"seasonNumber": 8,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2018-07-18T00:00:00Z",
},
{
"id": 802,
"seasonNumber": 8,
"episodeNumber": 2,
"monitored": False,
"hasFile": True,
"episodeFileId": 88,
},
{
"id": 901,
"seasonNumber": 9,
"episodeNumber": 1,
"monitored": False,
"hasFile": False,
"airDateUtc": "2019-07-17T00:00:00Z",
},
]
verified_episodes = [{**episode, "monitored": True} for episode in episodes]
sonarr = SimpleNamespace(
configured=lambda: True,
get_series=AsyncMock(side_effect=[original_series, updated_series]),
update_series=AsyncMock(return_value=updated_series),
get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
monitor_episodes=AsyncMock(return_value={"monitored": True}),
search_episodes=AsyncMock(return_value={"id": 9001}),
)
runtime = SimpleNamespace(
jellyseerr_base_url=None,
jellyseerr_api_key=None,
sonarr_base_url="http://sonarr",
sonarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(
requests_router,
"build_snapshot",
new=AsyncMock(side_effect=[snapshot, refreshed]),
),
patch.object(requests_router, "SonarrClient", return_value=sonarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_add_seasons(
"3580",
{"season_numbers": [8, 9]},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["season_numbers"], [8, 9])
self.assertEqual(result["searched_episode_count"], 2)
self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
sonarr.update_series.assert_awaited_once_with(updated_series)
sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
sonarr.search_episodes.assert_awaited_once_with([801, 901])
async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Missing Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 44}}},
)
radarr = SimpleNamespace(
configured=lambda: True,
monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}),
search=AsyncMock(return_value={"id": 9003}),
)
runtime = SimpleNamespace(
radarr_base_url="http://radarr",
radarr_api_key="secret",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "RadarrClient", return_value=radarr),
patch.object(requests_router, "save_action"),
):
result = await requests_router.action_search_missing_media(
"3914",
{"episode_ids": [], "season_numbers": []},
{"username": "admin", "role": "admin", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
radarr.monitor_movie.assert_awaited_once_with(44, True)
radarr.search.assert_awaited_once_with(44)
async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None:
snapshot = Snapshot(
request_id="3914",
title="Subtitle Movie",
request_type=RequestType.movie,
state=NormalizedState.available,
raw={"arr": {"item": {"id": 44, "movieFile": {"id": 77}}}},
)
bazarr = SimpleNamespace(
configured=lambda: True,
search_movie_subtitles=AsyncMock(return_value={"status": True}),
)
runtime = SimpleNamespace(
bazarr_base_url="http://bazarr",
bazarr_api_key="secret",
bazarr_default_language="en",
)
with (
patch.object(requests_router, "get_runtime_settings", return_value=runtime),
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "BazarrClient", return_value=bazarr),
patch.object(
requests_router,
"_ensure_request_mutation_access",
new=AsyncMock(return_value=None),
),
patch.object(requests_router, "save_action"),
patch.object(requests_router, "get_portal_item", return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3914",
"created_by_username": "viewer",
}),
patch.object(requests_router, "update_portal_item") as update_issue,
patch.object(requests_router, "add_portal_item_activity") as add_activity,
):
result = await requests_router.action_repair_subtitles(
"3914",
{"issue_id": 12, "episode_ids": [], "forced": True},
{"username": "viewer", "role": "user", "auto_search_enabled": True},
)
self.assertEqual(result["status"], "ok")
bazarr.search_movie_subtitles.assert_awaited_once_with(44, language="en", forced=True)
self.assertEqual(add_activity.call_args.kwargs["event_type"], "subtitle_repair_started")
update_issue.assert_called_once_with(12, status="in_progress", issue_resolved_at=None)
class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
portal_router._MEDIA_STATUS_CACHE.update(expires_at=0.0, payload=None)
async def test_media_status_is_live_and_removes_session_identity(self) -> None:
client = SimpleNamespace(
configured=lambda: True,
get_system_info=AsyncMock(
return_value={
"Version": "10.10.7",
"HasPendingRestart": False,
"WanAddress": "https://private.example",
}
),
get_sessions=AsyncMock(
return_value=[
{
"UserName": "private-user",
"DeviceName": "Living room television",
"NowPlayingItem": {"Name": "Private title"},
"TranscodingInfo": {"VideoCodec": "h264"},
}
]
),
)
with (
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
jellyfin_base_url="http://jellyfin",
jellyfin_api_key="secret",
)),
patch.object(portal_router, "JellyfinClient", return_value=client),
):
result = await portal_router.portal_media_status()
self.assertEqual(result["status"], "up")
self.assertEqual(result["activity"]["active_streams"], 1)
self.assertEqual(result["activity"]["transcoding_streams"], 1)
serialized = str(result)
self.assertNotIn("private-user", serialized)
self.assertNotIn("Living room television", serialized)
self.assertNotIn("Private title", serialized)
self.assertNotIn("private.example", serialized)
async def test_media_status_reports_unavailable_without_exposing_exception(self) -> None:
client = SimpleNamespace(
configured=lambda: True,
get_system_info=AsyncMock(side_effect=RuntimeError("secret upstream failure")),
)
with (
patch.object(portal_router, "get_runtime_settings", return_value=SimpleNamespace(
jellyfin_base_url="http://jellyfin",
jellyfin_api_key="secret",
)),
patch.object(portal_router, "JellyfinClient", return_value=client),
):
result = await portal_router.portal_media_status()
self.assertEqual(result["status"], "down")
self.assertNotIn("secret upstream failure", str(result))
class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
async def test_manual_invite_can_be_created_without_recipient_email(self) -> None:
payload = await admin_router.create_invite(
{
"label": "The neighbour",
"recipient_email": None,
"send_email": False,
"max_uses": 1,
},
{"username": "admin", "role": "admin"},
)
self.assertEqual(payload["status"], "ok")
self.assertEqual(payload["invite"]["label"], "The neighbour")
self.assertIsNone(payload["invite"]["recipient_email"])
self.assertTrue(payload["invite"]["enabled"])
async def test_email_delivery_still_requires_valid_recipient(self) -> None:
with self.assertRaises(HTTPException) as context:
await admin_router.create_invite(
{"label": "Family", "send_email": True},
{"username": "admin", "role": "admin"},
)
self.assertEqual(context.exception.status_code, 400)
self.assertIn("required for email delivery", str(context.exception.detail))
async def test_invite_list_reports_automatic_operational_states(self) -> None:
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
disabled = db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
used = db.create_signup_invite(code="USED", max_uses=1, recipient_email="used@example.com")
db.increment_signup_invite_use(int(used["id"]))
expired = db.create_signup_invite(
code="EXPIRED",
expires_at="2000-01-01T00:00:00+00:00",
recipient_email="expired@example.com",
)
no_profile = db.create_signup_invite(
code="NO-PROFILE",
profile_id=999,
recipient_email="profile@example.com",
)
payload = await admin_router.get_invites()
states = {invite["id"]: invite["operational_state"] for invite in payload["invites"]}
self.assertEqual(states[ready["id"]], "ready")
self.assertEqual(states[disabled["id"]], "disabled")
self.assertEqual(states[used["id"]], "exhausted")
self.assertEqual(states[expired["id"]], "expired")
self.assertEqual(states[no_profile["id"]], "profile_unavailable")
self.assertEqual(payload["summary"]["total"], 5)
self.assertEqual(payload["summary"]["ready"], 1)
self.assertEqual(payload["summary"]["attention"], 4)
self.assertEqual(payload["summary"]["used_signups"], 1)
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
def test_legacy_request_status_maps_to_workflow(self) -> None:
item = {"kind": "request", "status": "in_progress"}
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
workflow = serialized.get("workflow") or {}
self.assertEqual(workflow.get("request_status"), "approved")
self.assertEqual(workflow.get("media_status"), "processing")
def test_issue_status_maps_to_public_workflow_progress(self) -> None:
item = {
"kind": "issue",
"status": "blocked",
"issue_type": "playback",
"created_by_username": "tester",
}
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
workflow = (serialized.get("issue") or {}).get("workflow") or {}
self.assertEqual(workflow.get("current_step"), 4)
self.assertEqual(workflow.get("stage"), "repair")
self.assertEqual(workflow.get("state"), "attention")
self.assertEqual(len(workflow.get("steps") or []), 6)
self.assertEqual((workflow.get("steps") or [])[3].get("state"), "attention")
def test_resolved_issue_completes_the_public_workflow(self) -> None:
workflow = portal_router._issue_workflow_payload("closed")
self.assertEqual(workflow.get("current_step"), 6)
self.assertEqual(workflow.get("state"), "complete")
self.assertTrue(all(step.get("state") == "complete" for step in workflow.get("steps") or []))
def test_invalid_pipeline_transition_is_rejected(self) -> None:
with self.assertRaises(HTTPException) as context:
portal_router._validate_pipeline_transition(
"approved",
"processing",
"pending",
"pending",
)
self.assertEqual(context.exception.status_code, 400)
def test_portal_workflow_filters(self) -> None:
db.create_portal_item(
kind="request",
title="Request A",
description="A",
created_by_username="alpha",
created_by_id=None,
status="processing",
workflow_request_status="approved",
workflow_media_status="processing",
)
db.create_portal_item(
kind="request",
title="Request B",
description="B",
created_by_username="bravo",
created_by_id=None,
status="pending",
workflow_request_status="pending",
workflow_media_status="pending",
)
processing = db.list_portal_items(
kind="request",
workflow_request_status="approved",
workflow_media_status="processing",
limit=10,
offset=0,
)
pending_count = db.count_portal_items(
kind="request",
workflow_request_status="pending",
workflow_media_status="pending",
)
self.assertEqual(len(processing), 1)
self.assertEqual(pending_count, 1)
class PortalIssueDeletionTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def _create_issue(self) -> dict:
issue = db.create_portal_item(
kind="issue",
title="Delete this issue",
description="No longer required",
created_by_username="reporter",
created_by_id=None,
issue_type="playback",
)
db.add_portal_comment(
int(issue["id"]),
author_username="reporter",
author_role="user",
message="Issue detail",
)
db.add_portal_item_activity(
int(issue["id"]),
event_type="item_created",
actor_username="reporter",
actor_role="user",
message="Issue created",
)
return issue
async def test_only_admin_can_delete_an_issue(self) -> None:
issue = self._create_issue()
with self.assertRaises(HTTPException) as context:
await portal_router.portal_delete_item(
int(issue["id"]),
current_user={"username": "reporter", "role": "user"},
)
self.assertEqual(context.exception.status_code, 403)
self.assertIsNotNone(db.get_portal_item(int(issue["id"])))
async def test_delete_issue_removes_its_comments_and_activity(self) -> None:
issue = self._create_issue()
issue_id = int(issue["id"])
result = await portal_router.portal_delete_item(
issue_id,
current_user={"username": "admin", "role": "admin"},
)
self.assertEqual(result, {"status": "deleted", "item_id": issue_id})
self.assertIsNone(db.get_portal_item(issue_id))
self.assertEqual(db.list_portal_comments(issue_id), [])
self.assertEqual(db.list_portal_item_activity(issue_id), [])
async def test_delete_endpoint_will_not_delete_a_request(self) -> None:
request_item = db.create_portal_item(
kind="request",
title="Keep this request",
description="The media workflow must remain intact",
created_by_username="reporter",
created_by_id=None,
)
with self.assertRaises(HTTPException) as context:
await portal_router.portal_delete_item(
int(request_item["id"]),
current_user={"username": "admin", "role": "admin"},
)
self.assertEqual(context.exception.status_code, 400)
self.assertIsNotNone(db.get_portal_item(int(request_item["id"])))
class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def _create_issue(self, *, status: str = "in_progress") -> dict:
return db.create_portal_item(
kind="issue",
title="Missing content: Test movie",
description="The title is missing.",
created_by_username="reporter",
created_by_id=None,
status=status,
issue_type="missing_content",
)
async def test_marking_issue_fixed_records_contact_and_confirmation(self) -> None:
issue = self._create_issue()
reporter = {"username": "reporter", "email": "reporter@example.com"}
with (
patch.object(issue_resolution, "_workflow_settings", return_value=(2, 3, "days")),
patch.object(issue_resolution, "get_user_by_username", return_value=reporter),
patch.object(issue_resolution, "resolve_user_delivery_email", return_value="reporter@example.com"),
patch.object(issue_resolution, "send_generic_email", new=AsyncMock(return_value=None)) as send_email,
):
waiting = await issue_resolution.begin_issue_confirmation(
int(issue["id"]),
actor_username="admin",
actor_role="admin",
)
self.assertEqual(waiting["status"], "awaiting_confirmation")
state = issue_resolution.issue_resolution_state(waiting)
self.assertEqual(state["attemptsSent"], 1)
self.assertEqual(state["maximumAttempts"], 2)
send_email.assert_awaited_once()
activity = db.list_portal_item_activity(int(issue["id"]))
self.assertEqual(
[event["event_type"] for event in activity],
["resolution_proposed", "confirmation_email_sent"],
)
closed = issue_resolution.respond_to_issue_confirmation(
int(issue["id"]),
resolved=True,
actor_username="reporter",
actor_role="user",
)
self.assertEqual(closed["status"], "closed")
self.assertIsNotNone(closed["issue_resolved_at"])
self.assertEqual(db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"], "resolution_confirmed")
async def test_replacement_waits_for_jellyfin_to_refresh_existing_movie(self) -> None:
tracking = {
"requestId": "144",
"actionId": "replace_media",
"mediaType": "movie",
"collectorId": 12,
"originalFileIds": [40],
"episodes": [],
"jellyfinFoundAtStart": True,
"jellyfinBaseline": [{"Id": "jf-1", "Etag": "old", "Path": "/movies/test.mkv"}],
}
unchanged = Snapshot(
request_id="144",
title="Test movie",
raw={
"arr": {"item": {"id": 12, "hasFile": True, "movieFile": {"id": 41}}},
"jellyfin": {
"found": True,
"item": {"Id": "jf-1", "Etag": "old", "Path": "/movies/test.mkv"},
},
},
)
refreshed = unchanged.model_copy(deep=True)
refreshed.raw["jellyfin"]["item"]["Etag"] = "new"
with patch.object(issue_resolution, "build_snapshot", new=AsyncMock(return_value=unchanged)):
waiting = await issue_resolution._media_repair_evidence(tracking)
with patch.object(issue_resolution, "build_snapshot", new=AsyncMock(return_value=refreshed)):
complete = await issue_resolution._media_repair_evidence(tracking)
self.assertFalse(waiting["complete"])
self.assertEqual(waiting["phase"], "indexing")
self.assertTrue(complete["complete"])
async def test_verified_media_repair_starts_confirmation_without_admin(self) -> None:
issue = self._create_issue()
db.add_portal_item_activity(
int(issue["id"]),
event_type="replacement_started",
actor_username="reporter",
actor_role="user",
message="Radarr started the replacement.",
metadata_json=(
'{"repairTracking":{"requestId":"144","actionId":"replace_media",'
'"mediaType":"movie","collectorId":12,"originalFileIds":[40],'
'"episodes":[],"jellyfinFoundAtStart":true,'
'"jellyfinBaseline":[{"Id":"jf-1","Etag":"old"}]}}'
),
)
with (
patch.object(
issue_resolution,
"_media_repair_evidence",
new=AsyncMock(
return_value={
"complete": True,
"phase": "complete",
"message": "Radarr imported the repaired movie and Jellyfin indexed it.",
}
),
),
patch.object(
issue_resolution,
"begin_issue_confirmation",
new=AsyncMock(return_value={"status": "awaiting_confirmation"}),
) as begin_confirmation,
):
result = await issue_resolution.process_active_media_repairs()
self.assertEqual(result["completed"], 1)
begin_confirmation.assert_awaited_once_with(
int(issue["id"]),
actor_username="Magent",
actor_role="system",
)
self.assertEqual(
db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"],
"repair_verified",
)
async def test_zero_confirmation_emails_closes_issue_immediately(self) -> None:
issue = self._create_issue()
with patch.object(issue_resolution, "_workflow_settings", return_value=(0, 1, "days")):
closed = await issue_resolution.begin_issue_confirmation(
int(issue["id"]),
actor_username="admin",
actor_role="admin",
)
self.assertEqual(closed["status"], "closed")
self.assertEqual(
[event["event_type"] for event in db.list_portal_item_activity(int(issue["id"]))],
["resolution_proposed", "issue_auto_closed"],
)
async def test_saving_waiting_issue_does_not_restart_confirmation_cycle(self) -> None:
issue = self._create_issue(status="awaiting_confirmation")
user = {"username": "admin", "role": "admin"}
with patch.object(portal_router, "begin_issue_confirmation", new=AsyncMock()) as begin_confirmation:
result = await portal_router.portal_update_item(
int(issue["id"]),
{"title": "Updated title", "status": "awaiting_confirmation"},
user,
)
self.assertEqual(result["item"]["title"], "Updated title")
begin_confirmation.assert_not_awaited()
def test_public_activity_hides_internal_notes_and_admin_identity(self) -> None:
issue = self._create_issue()
db.add_portal_item_activity(
int(issue["id"]),
event_type="internal_note_added",
actor_username="private-admin-name",
actor_role="admin",
message="Internal diagnostic detail",
)
db.add_portal_item_activity(
int(issue["id"]),
event_type="status_changed",
actor_username="private-admin-name",
actor_role="admin",
message="Status changed to in progress.",
metadata_json='{"private":true}',
)
public_activity = portal_router._activity_payload(issue)
self.assertNotIn("Internal diagnostic detail", str(public_activity))
self.assertNotIn("private-admin-name", str(public_activity))
self.assertNotIn("metadata_json", public_activity[-1])
self.assertEqual(public_activity[-1]["actor_username"], "Support team")