202 lines
11 KiB
Python
202 lines
11 KiB
Python
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
|
|
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, 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)
|