import json import unittest from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch import httpx from fastapi import FastAPI from fastapi.testclient import TestClient from backend.app import db from backend.app.clients.jellystat import HistoryLimitError, JellystatClient, JellystatError from backend.app.routers import admin, insights as router from backend.app.services import insights from backend.app.services.jellyfin_identity import link_user, linked_user_id from backend.tests.test_backend_quality import TempDatabaseMixin NOW = datetime(2026, 9, 7, 12, tzinfo=timezone.utc) USER = {"username": "viewer", "role": "user", "auth_provider": "jellyfin", "jellyseerr_user_id": 42} LIBRARIES = [{"Id": "movies", "CollectionType": "movies"}, {"Id": "music", "CollectionType": "music"}] def play(id="play-1", **extra): return {"Id": id, "UserId": "jf-viewer", "UserName": "PRIVATE NAME", "NowPlayingItemId": "movie-1", "NowPlayingItemName": "Arrival", "ParentId": "movies", "PlaybackDuration": 3600, "ActivityDateInserted": NOW.isoformat(), "RemoteEndPoint": "PRIVATE IP", "DeviceId": "PRIVATE DEVICE", "PlayState": {"secret": "PRIVATE STATE"}, "Client": "Jellyfin Web", "PlayMethod": "DirectPlay", **extra} class JellystatClientTests(unittest.IsolatedAsyncioTestCase): async def history(self, handler, **kwargs): original = httpx.AsyncClient with patch("backend.app.clients.jellystat.httpx.AsyncClient", side_effect=lambda **options: original(transport=httpx.MockTransport(handler), **options)): return await JellystatClient("http://jellystat/base", "secret-api-key").get_user_history( kwargs.get("user_id", "jf-viewer"), NOW - timedelta(days=7), NOW) async def test_paginates_and_sends_only_backend_identity_and_header_credential(self): calls = [] def handler(request): calls.append(request) self.assertEqual(request.headers["x-api-token"], "secret-api-key") self.assertNotIn("secret-api-key", str(request.url)) if request.url.path == "/base/api/getLibraries": return httpx.Response(200, json=LIBRARIES) self.assertEqual(request.method, "POST") self.assertEqual(request.url.path, "/base/api/getUserHistory") self.assertEqual(json.loads(request.content), {"userid": "jf-viewer"}) self.assertNotIn("search", request.url.params) self.assertEqual(json.loads(request.url.params["filters"])[0]["field"], "ActivityDateInserted") return httpx.Response(200, json={"pages": 2, "results": [play(request.url.params["page"])]}) history, libraries = await self.history(handler) self.assertEqual(len(calls), 3) self.assertEqual(len(history), 2) self.assertEqual(libraries, LIBRARIES) async def test_rejects_foreign_history_malformed_responses_and_overflow(self): for payload, exception in [ ({"pages": 1, "results": [play(UserId="someone-else")]}, JellystatError), ({"pages": 1, "results": [play(UserId=None)]}, JellystatError), ({"results": []}, JellystatError), ({"pages": 51, "results": []}, HistoryLimitError), ({"pages": 2, "results": []}, JellystatError), ({"pages": 0, "results": [play()]}, JellystatError), ]: with self.subTest(payload=payload): def handler(request): return httpx.Response(200, json=LIBRARIES if request.method == "GET" else payload) with self.assertRaises(exception): await self.history(handler) async def test_empty_history_is_valid(self): result, _ = await self.history(lambda request: httpx.Response(200, json=LIBRARIES if request.method == "GET" else {"pages": 0, "results": []})) self.assertEqual(result, []) async def test_upstream_failure_is_sanitized(self): with self.assertRaises(JellystatError) as error: await self.history(lambda _: httpx.Response(401, text="private upstream error")) self.assertNotIn("private", str(error.exception)) self.assertNotIn("secret-api-key", str(error.exception)) class SummaryTests(unittest.TestCase): def test_units_media_counts_deduplication_ranges_streaks_and_privacy(self): rows = [play(), play(), play("rewatch"), play("episode", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration="1200", ActivityDateInserted=(NOW - timedelta(days=1)).isoformat()), play("episode-rewatch", EpisodeId="e1", SeriesName="Severance", NowPlayingItemId="series-1", PlaybackDuration=1200, ActivityDateInserted=(NOW - timedelta(days=2)).isoformat()), play("song", ParentId="music", NowPlayingItemId="song-1", PlaybackDuration=180), play("old", ActivityDateInserted=(NOW - timedelta(days=8)).isoformat()), play("zero", PlaybackDuration=0)] data = insights.summarize(rows, LIBRARIES, NOW - timedelta(days=7), NOW) self.assertEqual(data["summary"], {"minutes": 163, "plays": 5, "movies": 1, "episodes": 1, "active_days": 3, "current_streak": 3, "longest_streak": 3}) self.assertAlmostEqual(sum(day["minutes"] for day in data["daily"]), 163) self.assertEqual(data["top_titles"][0]["title"], "Arrival") self.assertEqual(len(data["recent"]), 5) self.assertNotIn("PRIVATE", json.dumps(data)) def test_invalid_durations_do_not_become_zero_or_nan(self): for value in [-1, "NaN", "Infinity", "nonsense"]: with self.subTest(value=value), self.assertRaises(JellystatError): insights.summarize([play(PlaybackDuration=value)], LIBRARIES, NOW - timedelta(days=7), NOW) def test_empty_history_has_zero_filled_days(self): result = insights.summarize([], LIBRARIES, NOW - timedelta(days=7), NOW) self.assertEqual(result["summary"]["minutes"], 0) self.assertEqual(len(result["daily"]), 8) self.assertEqual(result["summary"]["current_streak"], 0) class InsightsIntegrationTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): def setUp(self): super().setUp() insights._cache.clear() db.create_user("viewer", "Test-Password123!", auth_provider="jellyfin") self.runtime = SimpleNamespace(jellyfin_base_url="http://jellyfin", jellyfin_api_key="jf-key", jellystat_base_url="http://jellystat", jellystat_api_key="stats-key") async def test_identity_does_not_change_with_username_reuse_or_server_changes(self): link_user("viewer", "jf-original", "http://jellyfin/") link_user("viewer", "jf-replacement", "http://jellyfin") self.assertEqual(linked_user_id("viewer", "http://jellyfin"), "jf-original") self.assertIsNone(linked_user_id("viewer", "http://other-server")) async def test_local_account_cannot_claim_same_name_and_verified_user_can_bootstrap(self): with patch.object(insights.JellyfinClient, "get_users", new_callable=AsyncMock, return_value=[{"Id": "jf-viewer", "Name": "viewer"}]) as remote: self.assertIsNone(await insights.resolve_identity({**USER, "auth_provider": "local"}, self.runtime)) remote.assert_not_called() self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer") self.assertEqual(await insights.resolve_identity(USER, self.runtime), "jf-viewer") self.assertEqual(remote.await_count, 1) async def test_requests_use_seerr_id_even_when_name_matches_another_user(self): for request_id, seerr_id in [(1, 42), (2, 99)]: db.upsert_request_cache(request_id, request_id, "movie", 2, "Request", 2026, "viewer", "viewer", seerr_id, NOW.isoformat(), NOW.isoformat(), "{}") report = insights.request_summary(USER, NOW - timedelta(days=7), NOW) self.assertEqual(report["total"], 1) self.assertEqual(report["recent"][0]["request_id"], 1) async def test_cache_isolated_by_identity_period_and_configuration(self): link_user("viewer", "jf-viewer", "http://jellyfin") db.create_user("second", "Test-Password123!", auth_provider="jellyfin") link_user("second", "jf-second", "http://jellyfin") with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \ patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock, return_value=([], LIBRARIES)) as remote: await insights.get_insights(USER, 7) await insights.get_insights(USER, 7) self.assertEqual(remote.await_count, 1) await insights.get_insights({**USER, "username": "second"}, 7) await insights.get_insights(USER, 30) self.runtime.jellystat_api_key = "rotated-key" await insights.get_insights(USER, 7) self.assertEqual(remote.await_count, 4) async def test_disabled_integration_never_calls_upstream(self): self.runtime.jellystat_api_key = None with patch.object(insights, "get_runtime_settings", return_value=self.runtime), \ patch.object(JellystatClient, "get_user_history", new_callable=AsyncMock) as remote: result = await insights.get_insights(USER, 30) self.assertEqual(result["state"], "not_configured") self.assertIsNone(result["summary"]) remote.assert_not_called() async def test_settings_mask_jellystat_credential(self): db.set_setting("jellystat_api_key", "private-stats-key") result = await admin.list_settings() setting = next(row for row in result["settings"] if row["key"] == "jellystat_api_key") self.assertTrue(setting["sensitive"]) self.assertTrue(setting["isSet"]) self.assertNotIn("private-stats-key", json.dumps(result)) class InsightsRouteTests(unittest.TestCase): def app(self, authenticated=True): app = FastAPI() app.include_router(router.router) if authenticated: app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}} return TestClient(app) def test_requires_authentication(self): self.assertEqual(self.app(False).get("/insights").status_code, 401) def test_query_accepts_period_and_forbids_identity_and_scope_overrides(self): with patch.object(router, "get_insights", new_callable=AsyncMock, return_value={"state": "ready"}) as report: client = self.app() for days in [7, 30, 90, 365]: response = client.get(f"/insights?days={days}") self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.headers["cache-control"], "no-store") report.assert_awaited_with({**USER, "features": {"stats": True}}, 365) for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]: self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query) def test_errors_do_not_leak_upstream_details(self): with patch.object(router, "get_insights", new_callable=AsyncMock, side_effect=JellystatError("PRIVATE key and URL")): response = self.app().get("/insights") self.assertEqual(response.status_code, 502) self.assertNotIn("PRIVATE", response.text)