[
  {
    "file": "app/core/security.py",
    "patch": "Index: app/core/security.py\n===================================================================\n--- app/core/security.py\t\n+++ app/core/security.py\t\n@@ -1,196 +1,197 @@\n \"\"\"JWT token creation, password hashing, and Fernet symmetric encryption.\"\"\"\n \n+import asyncio\n import base64\n import hashlib\n from datetime import UTC, datetime, timedelta\n from typing import Any\n from uuid import uuid4\n \n import jwt\n from cryptography.fernet import Fernet, InvalidToken\n from jwt.exceptions import InvalidTokenError\n from pwdlib import PasswordHash\n \n from app.core.config import get_settings\n \n password_hash = PasswordHash.recommended()\n \n ALGORITHM = \"HS256\"\n \n \n def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:\n     \"\"\"Create a signed access JWT with explicit issuer/audience/type claims.\"\"\"\n     settings = get_settings()\n     now = datetime.now(UTC)\n     if expires_delta is None:\n         expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)\n \n     to_encode = {\n         \"sub\": str(subject),\n         \"typ\": \"access\",\n         \"iss\": settings.JWT_ISSUER,\n         \"aud\": settings.JWT_AUDIENCE,\n         \"iat\": now,\n         \"nbf\": now,\n         \"exp\": now + expires_delta,\n         \"jti\": str(uuid4()),\n     }\n     return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)\n \n \n def decode_access_token(token: str) -> dict[str, Any]:\n     \"\"\"Decode and validate an access token.\"\"\"\n     settings = get_settings()\n     payload = jwt.decode(\n         token,\n         settings.SECRET_KEY,\n         algorithms=[ALGORITHM],\n         issuer=settings.JWT_ISSUER,\n         audience=settings.JWT_AUDIENCE,\n         options={\"require\": [\"sub\", \"exp\", \"iat\", \"nbf\", \"iss\", \"aud\", \"typ\", \"jti\"]},\n     )\n     if payload.get(\"typ\") != \"access\":\n         raise InvalidTokenError(\"Invalid token type\")\n     return payload\n \n \n def verify_password(plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n     \"\"\"Verify a plain password against a hash, returning optional upgraded hash.\"\"\"\n     return password_hash.verify_and_update(plain_password, hashed_password)\n \n \n def get_password_hash(password: str) -> str:\n     \"\"\"Hash a password using the configured hasher.\"\"\"\n     return password_hash.hash(password)\n \n \n class PwdlibPasswordHasher:\n     \"\"\"Password hasher adapter for application-layer ports.\"\"\"\n \n-    def hash(self, password: str) -> str:\n-        return get_password_hash(password)\n+    async def hash(self, password: str) -> str:\n+        return await asyncio.to_thread(get_password_hash, password)\n \n-    def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n-        return verify_password(plain_password, hashed_password)\n+    async def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n+        return await asyncio.to_thread(verify_password, plain_password, hashed_password)\n \n \n class PyJwtTokenService:\n     \"\"\"JWT token service adapter for application-layer ports.\"\"\"\n \n     def create_access_token(self, subject: str) -> str:\n         return create_access_token(subject=subject)\n \n \n def create_refresh_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:\n     \"\"\"Create a signed refresh JWT with longer expiry and 'refresh' type.\"\"\"\n     settings = get_settings()\n     now = datetime.now(UTC)\n     if expires_delta is None:\n         expires_delta = timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)\n \n     to_encode = {\n         \"sub\": str(subject),\n         \"typ\": \"refresh\",\n         \"iss\": settings.JWT_ISSUER,\n         \"aud\": settings.JWT_AUDIENCE,\n         \"iat\": now,\n         \"nbf\": now,\n         \"exp\": now + expires_delta,\n         \"jti\": str(uuid4()),\n     }\n     return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)\n \n \n def decode_refresh_token(token: str) -> dict[str, Any]:\n     \"\"\"Decode and validate a refresh token.\"\"\"\n     settings = get_settings()\n     payload = jwt.decode(\n         token,\n         settings.SECRET_KEY,\n         algorithms=[ALGORITHM],\n         issuer=settings.JWT_ISSUER,\n         audience=settings.JWT_AUDIENCE,\n         options={\"require\": [\"sub\", \"exp\", \"iat\", \"nbf\", \"iss\", \"aud\", \"typ\", \"jti\"]},\n     )\n     if payload.get(\"typ\") != \"refresh\":\n         raise InvalidTokenError(\"Invalid token type\")\n     return payload\n \n \n # --- Fernet symmetric encryption for debrid API keys ---\n # Key versioning: supports rotating SECRET_KEY without losing access to\n # previously encrypted data. The OLD_SECRET_KEYS config lists previous keys\n # that can still decrypt data. New data is always encrypted with SECRET_KEY.\n #\n # Format: encrypted data without prefix = legacy (v0, current SECRET_KEY)\n #         encrypted data starting with \"v1:\" = v1 format (current SECRET_KEY)\n #         Future versions: \"v2:\", \"v3:\", etc.\n \n _KEY_VERSION_PREFIX = \"v1:\"\n \n \n def _derive_fernet_key(secret: str) -> bytes:\n     \"\"\"Derive a 32-byte Fernet key from any secret string.\n \n     Uses SHA-256 to derive a 32-byte key, then URL-safe base64 encodes it,\n     which is what Fernet requires.\n     \"\"\"\n     raw = hashlib.sha256(secret.encode()).digest()\n     return base64.urlsafe_b64encode(raw)\n \n \n def _get_fernet(secret: str | None = None) -> Fernet:\n     \"\"\"Return a Fernet instance keyed from a specific secret (or SECRET_KEY).\"\"\"\n     settings = get_settings()\n     key_str = secret if secret is not None else settings.SECRET_KEY\n     return Fernet(_derive_fernet_key(key_str))\n \n \n def encrypt_api_key(plaintext: str) -> str:\n     \"\"\"Encrypt a debrid API key for storage.\n \n     Returns v1-format URL-safe token string with a version prefix.\n     Always encrypts with the current SECRET_KEY for forward compatibility.\n     \"\"\"\n     f = _get_fernet()\n     return _KEY_VERSION_PREFIX + f.encrypt(plaintext.encode()).decode()\n \n \n def decrypt_api_key(token: str) -> str:\n     \"\"\"Decrypt a previously encrypted debrid API key.\n \n     Detects the version prefix and uses the corresponding key.\n     Falls back to OLD_SECRET_KEYS if decrypting with the current SECRET_KEY\n     fails due to a key rotation.\n     \"\"\"\n     settings = get_settings()\n \n     # Strip version prefix to get the raw Fernet token\n     actual_token = token[len(_KEY_VERSION_PREFIX):] if token.startswith(_KEY_VERSION_PREFIX) else token\n \n     # Try current SECRET_KEY first\n     try:\n         f = _get_fernet(settings.SECRET_KEY)\n         return f.decrypt(actual_token.encode()).decode()\n     except InvalidToken:\n         pass\n \n     # Fall back to old secret keys for rotation scenarios\n     for old_key in settings.OLD_SECRET_KEYS:\n         try:\n             f = _get_fernet(old_key)\n             return f.decrypt(actual_token.encode()).decode()\n         except InvalidToken:\n             continue\n \n     raise ValueError(\"Failed to decrypt API key — invalid token or secret key changed\")\n \n \n def reencrypt_api_key(encrypted_token: str) -> str:\n     \"\"\"Re-encrypt an API key with the current SECRET_KEY.\n \n     Useful during key rotation: decrypt using old keys, re-encrypt with\n     the current key so old keys can eventually be retired.\n     \"\"\"\n     plaintext = decrypt_api_key(encrypted_token)\n     return encrypt_api_key(plaintext)\n",
    "additions": 5,
    "deletions": 4,
    "status": "modified"
  },
  {
    "file": "app/modules/metadata/infrastructure/tmdb_client.py",
    "patch": "Index: app/modules/metadata/infrastructure/tmdb_client.py\n===================================================================\n--- app/modules/metadata/infrastructure/tmdb_client.py\t\n+++ app/modules/metadata/infrastructure/tmdb_client.py\t\n@@ -1,291 +1,291 @@\n \"\"\"TMDB API client adapter for the metadata module.\n \n Implements the MetadataProvider port using The Movie Database (TMDB) v3 API.\n Supports search, trending, details, and genre endpoints.\n \"\"\"\n \n from __future__ import annotations\n \n from typing import Any\n \n import httpx\n \n from app.core.config import get_settings\n from app.core.logging import get_logger\n from app.modules.metadata.application.exceptions import (\n     InvalidMediaTypeError,\n     ItemNotFoundError,\n     MetadataError,\n     ProviderAuthenticationError,\n     ProviderConnectionError,\n     ProviderRateLimitError,\n )\n from app.modules.metadata.application.interfaces import MetadataProvider\n from app.modules.metadata.domain.value_objects import (\n     ExternalIds,\n     MediaItem,\n     MediaType,\n     SearchResult,\n     TMDBId,\n     TrendingWindow,\n )\n \n logger = get_logger(__name__)\n \n TMDB_API_BASE = \"https://api.themoviedb.org/3\"\n DEFAULT_TIMEOUT = 10.0\n \n \n class TMDbClient(MetadataProvider):\n     \"\"\"TMDB v3 API client adapter.\n \n     Uses Bearer token auth via TMDB_ACCESS_TOKEN for authenticated requests.\n     Falls back to api_key query param if access token is not configured.\n     \"\"\"\n \n     def __init__(self) -> None:\n         settings = get_settings()\n         self._api_key = settings.TMDB_API_KEY\n         self._access_token = settings.TMDB_ACCESS_TOKEN\n         self._default_language = settings.TMDB_LANGUAGE\n         self._client = httpx.AsyncClient(\n             base_url=TMDB_API_BASE,\n             timeout=DEFAULT_TIMEOUT,\n         )\n \n     async def close(self) -> None:\n         \"\"\"Close the underlying HTTP client.\"\"\"\n         await self._client.aclose()\n \n     # --- Auth helpers ---\n \n     def _get_headers(self) -> dict[str, str]:\n         if self._access_token:\n             return {\"Authorization\": f\"Bearer {self._access_token}\"}\n         return {}\n \n     def _get_params(self, **kwargs: Any) -> dict[str, Any]:\n         params: dict[str, Any] = dict(kwargs)\n         params.pop(\"language\", None)\n         params.pop(\"page\", None)\n         params[\"language\"] = kwargs.get(\"language\", self._default_language)\n         if \"page\" in kwargs:\n             params[\"page\"] = kwargs[\"page\"]\n         # Add api_key for legacy auth when access token is not configured\n         if not self._access_token:\n             params[\"api_key\"] = self._api_key\n         return params\n \n     # --- Error handling ---\n \n     @staticmethod\n     def _raise_for_status(response: httpx.Response, context: str = \"\") -> None:\n         \"\"\"Raise appropriate MetadataError based on HTTP status.\"\"\"\n         status = response.status_code\n         if status == 401:\n             raise ProviderAuthenticationError(\n                 \"TMDB authentication failed — check TMDB_API_KEY or TMDB_ACCESS_TOKEN\"\n             )\n         if status == 404:\n             raise ItemNotFoundError(f\"TMDB resource not found{(': ' + context) if context else ''}\")\n         if status == 429:\n             raise ProviderRateLimitError(\"TMDB rate limit exceeded\")\n         if status >= 500:\n             raise ProviderConnectionError(f\"TMDB server error ({status})\")\n         response.raise_for_status()\n \n     # --- Response mapping ---\n \n     @staticmethod\n     def _map_media_item(data: dict[str, Any], media_type: MediaType = MediaType.MOVIE) -> MediaItem:\n         \"\"\"Map a TMDB API response dict to a MediaItem domain entity.\"\"\"\n         tmdb_id = TMDBId(int(data[\"id\"]))\n \n         # Determine media type from response if available\n         detected_type = data.get(\"media_type\", media_type.value)\n         try:\n             content_type = MediaType(detected_type)\n         except ValueError:\n             content_type = media_type\n \n         title = data.get(\"title\") or data.get(\"name\", \"Unknown\")\n         original_title = data.get(\"original_title\") or data.get(\"original_name\")\n \n         return MediaItem(\n             tmdb_id=tmdb_id,\n             media_type=content_type,\n             title=title,\n             original_title=original_title,\n             overview=data.get(\"overview\"),\n             poster_path=data.get(\"poster_path\"),\n             backdrop_path=data.get(\"backdrop_path\"),\n             release_date=data.get(\"release_date\") or data.get(\"first_air_date\"),\n             vote_average=float(data[\"vote_average\"]) if \"vote_average\" in data and data[\"vote_average\"] is not None else None,\n             vote_count=int(data[\"vote_count\"]) if \"vote_count\" in data and data[\"vote_count\"] is not None else None,\n             popularity=float(data[\"popularity\"]) if \"popularity\" in data and data[\"popularity\"] is not None else None,\n             original_language=data.get(\"original_language\"),\n             genre_ids=[int(g) for g in data.get(\"genre_ids\", [])],\n-            genres=list(data.get(\"genres\", [])),\n+            genres=[g[\"name\"] for g in data.get(\"genres\", [])] if isinstance(data.get(\"genres\"), list) else [],\n             adult=data.get(\"adult\", False),\n             video=data.get(\"video\", False),\n             first_air_date=data.get(\"first_air_date\"),\n             number_of_seasons=int(data[\"number_of_seasons\"]) if \"number_of_seasons\" in data and data[\"number_of_seasons\"] is not None else None,\n             number_of_episodes=int(data[\"number_of_episodes\"]) if \"number_of_episodes\" in data and data[\"number_of_episodes\"] is not None else None,\n             runtime=int(data[\"runtime\"]) if \"runtime\" in data and data[\"runtime\"] is not None else None,\n             budget=int(data[\"budget\"]) if \"budget\" in data and data[\"budget\"] is not None else None,\n             revenue=int(data[\"revenue\"]) if \"revenue\" in data and data[\"revenue\"] is not None else None,\n             tagline=data.get(\"tagline\"),\n             raw=data,\n         )\n \n     @staticmethod\n     def _map_search_result(\n         data: dict[str, Any],\n         media_type: MediaType = MediaType.MOVIE,\n     ) -> SearchResult:\n         \"\"\"Map a TMDB search/list API response to a SearchResult.\"\"\"\n         results = data.get(\"results\", [])\n         items = [TMDbClient._map_media_item(r, media_type) for r in results]\n         return SearchResult(\n             items=items,\n             total_results=int(data.get(\"total_results\", 0)),\n             page=int(data.get(\"page\", 1)),\n             total_pages=int(data.get(\"total_pages\", 0)),\n         )\n \n     # --- API methods ---\n \n     async def search(\n         self,\n         query: str,\n         media_type: MediaType = MediaType.MOVIE,\n         page: int = 1,\n         language: str | None = None,\n     ) -> SearchResult:\n         \"\"\"Search TMDB for movies or TV shows.\"\"\"\n         if media_type not in (MediaType.MOVIE, MediaType.TV, MediaType.PERSON):\n             raise InvalidMediaTypeError(f\"Search not supported for: {media_type.value}\")\n \n         endpoint = f\"/search/{media_type.value}\"\n         params = self._get_params(query=query, page=page, language=language or self._default_language)\n         headers = self._get_headers()\n \n         try:\n             response = await self._client.get(endpoint, params=params, headers=headers)\n             self._raise_for_status(response, context=f\"search/{media_type.value}\")\n             return self._map_search_result(response.json(), media_type)\n         except httpx.TimeoutException as exc:\n             raise ProviderConnectionError(\"TMDB search timed out\") from exc\n         except httpx.HTTPStatusError:\n             raise\n         except MetadataError:\n             raise\n         except Exception as exc:\n             logger.exception(\"tmdb_search_failed\", query=query, media_type=media_type.value)\n             raise MetadataError(\"TMDB search failed\") from exc\n \n     async def get_details(\n         self,\n         tmdb_id: TMDBId,\n         media_type: MediaType = MediaType.MOVIE,\n         language: str | None = None,\n     ) -> MediaItem | None:\n         \"\"\"Get detailed information for a specific media item from TMDB.\"\"\"\n         if media_type not in (MediaType.MOVIE, MediaType.TV):\n             raise InvalidMediaTypeError(f\"Details not supported for: {media_type.value}\")\n \n         endpoint = f\"/{media_type.value}/{tmdb_id.value}\"\n         params = self._get_params(\n             append_to_response=\"external_ids,credits,videos,similar\",\n             language=language or self._default_language,\n         )\n         headers = self._get_headers()\n \n         try:\n             response = await self._client.get(endpoint, params=params, headers=headers)\n             if response.status_code == 404:\n                 return None\n             self._raise_for_status(response, context=f\"{media_type.value}/{tmdb_id.value}\")\n             data = response.json()\n             item = self._map_media_item(data, media_type)\n \n             # Extract external IDs if available\n             ext_ids = data.get(\"external_ids\")\n             if ext_ids and isinstance(ext_ids, dict):\n                 item = MediaItem(\n                     **{**item.__dict__,\n                        \"external_ids\": ExternalIds(\n                            imdb_id=ext_ids.get(\"imdb_id\"),\n                            facebook_id=ext_ids.get(\"facebook_id\"),\n                            instagram_id=ext_ids.get(\"instagram_id\"),\n                            twitter_id=ext_ids.get(\"twitter_id\"),\n                        )}\n                 )\n             return item\n         except httpx.TimeoutException as exc:\n             raise ProviderConnectionError(\"TMDB details request timed out\") from exc\n         except MetadataError:\n             raise\n         except Exception as exc:\n             logger.exception(\n                 \"tmdb_details_failed\",\n                 tmdb_id=tmdb_id.value,\n                 media_type=media_type.value,\n             )\n             raise MetadataError(\"TMDB details request failed\") from exc\n \n     async def get_trending(\n         self,\n         media_type: MediaType = MediaType.MOVIE,\n         window: TrendingWindow = TrendingWindow.DAY,\n         page: int = 1,\n         language: str | None = None,\n     ) -> SearchResult:\n         \"\"\"Get trending media from TMDB.\"\"\"\n         endpoint = f\"/trending/{media_type.value}/{window.value}\"\n         params = self._get_params(page=page, language=language or self._default_language)\n         headers = self._get_headers()\n \n         try:\n             response = await self._client.get(endpoint, params=params, headers=headers)\n             self._raise_for_status(response, context=f\"trending/{media_type.value}/{window.value}\")\n             return self._map_search_result(response.json(), media_type)\n         except httpx.TimeoutException as exc:\n             raise ProviderConnectionError(\"TMDB trending timed out\") from exc\n         except MetadataError:\n             raise\n         except Exception as exc:\n             logger.exception(\n                 \"tmdb_trending_failed\",\n                 media_type=media_type.value,\n                 window=window.value,\n             )\n             raise MetadataError(\"TMDB trending request failed\") from exc\n \n     async def get_genres(\n         self,\n         media_type: MediaType = MediaType.MOVIE,\n         language: str | None = None,\n     ) -> list[dict[str, object]]:\n         \"\"\"Get the list of available genres for a media type from TMDB.\"\"\"\n         if media_type not in (MediaType.MOVIE, MediaType.TV):\n             raise InvalidMediaTypeError(f\"Genres not supported for: {media_type.value}\")\n \n         endpoint = f\"/genre/{media_type.value}/list\"\n         params = self._get_params(language=language or self._default_language)\n         headers = self._get_headers()\n \n         try:\n             response = await self._client.get(endpoint, params=params, headers=headers)\n             self._raise_for_status(response, context=f\"genre/{media_type.value}/list\")\n             return response.json().get(\"genres\", [])  # type: ignore[no-any-return]\n         except httpx.TimeoutException as exc:\n             raise ProviderConnectionError(\"TMDB genres request timed out\") from exc\n         except MetadataError:\n             raise\n         except Exception as exc:\n             logger.exception(\n                 \"tmdb_genres_failed\",\n                 media_type=media_type.value,\n             )\n             raise MetadataError(\"TMDB genres request failed\") from exc\n",
    "additions": 1,
    "deletions": 1,
    "status": "modified"
  },
  {
    "file": "app/modules/streaming/infrastructure/_shared.py",
    "patch": "Index: app/modules/streaming/infrastructure/_shared.py\n===================================================================\n--- app/modules/streaming/infrastructure/_shared.py\t\n+++ app/modules/streaming/infrastructure/_shared.py\t\n@@ -1,293 +1,282 @@\n \"\"\"Shared helpers for debrid provider resolvers.\n \n Extracted from provider_resolvers.py to DRY up common HTTP request\n patterns, validation, and inference logic used by all resolvers.\n \"\"\"\n \n from __future__ import annotations\n \n import asyncio\n import math\n import re\n from collections.abc import Mapping\n from datetime import datetime\n from pathlib import PurePosixPath\n from typing import Any\n from urllib.parse import urlparse\n \n import httpx\n \n from app.modules.streaming.application.exceptions import ProviderStreamUnavailableError\n from app.modules.streaming.domain.value_objects import StreamQuality\n \n # ── HTTP configuration ───────────────────────────────────────────────────\n \n _DEFAULT_TIMEOUT = httpx.Timeout(connect=3.0, read=10.0, write=5.0, pool=2.0)\n _DEFAULT_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)\n _RETRYABLE_STATUS_CODES = {502, 503, 504}\n _SAFE_RETRY_METHODS = {\"GET\", \"HEAD\", \"OPTIONS\"}\n _MAX_PROVIDER_FILES = 50\n _MAX_FILENAME_LENGTH = 255\n-_MAX_FILE_SIZE_BYTES = 10 * 1024**5  # 10 PiB, guards absurd provider payloads without rejecting real media.\n+_MAX_FILE_SIZE_BYTES = 1024**4  # 1 TiB, guards absurd provider payloads without rejecting real media.\n _CONTROL_CHARS_PATTERN = re.compile(r\"[\\x00-\\x1f\\x7f]\")\n _INFO_HASH_PATTERN = re.compile(r\"^(?:[0-9a-fA-F]{40}|[A-Z2-7]{32})$\")\n \n # ── MIME type inference ──────────────────────────────────────────────────\n \n _MIME_MAP: dict[str, str] = {\n     \".mp4\": \"video/mp4\",\n     \".mkv\": \"video/x-matroska\",\n     \".avi\": \"video/x-msvideo\",\n     \".mov\": \"video/quicktime\",\n     \".wmv\": \"video/x-ms-wmv\",\n     \".flv\": \"video/x-flv\",\n     \".webm\": \"video/webm\",\n     \".m4v\": \"video/x-m4v\",\n     \".mp3\": \"audio/mpeg\",\n     \".aac\": \"audio/aac\",\n     \".flac\": \"audio/flac\",\n     \".ogg\": \"audio/ogg\",\n     \".opus\": \"audio/opus\",\n     \".wma\": \"audio/x-ms-wma\",\n     \".m3u8\": \"application/x-mpegURL\",\n     \".ts\": \"video/mp2t\",\n     \".m2ts\": \"video/mp2t\",\n     \".iso\": \"application/x-iso9660-image\",\n }\n _PLAYABLE_MEDIA_MIME_PREFIXES = (\"video/\", \"audio/\")\n _PLAYABLE_MEDIA_MIME_TYPES = {\"application/x-mpegurl\", \"application/x-iso9660-image\"}\n \n # ── Quality inference patterns ───────────────────────────────────────────\n \n _QUALITY_PATTERNS: list[tuple[str, StreamQuality]] = [\n     (\"4k\", StreamQuality.UHD),\n     (\"2160p\", StreamQuality.UHD),\n     (\"1080p\", StreamQuality.FHD),\n     (\"720p\", StreamQuality.HD),\n     (\"480p\", StreamQuality.SD),\n     (\"360p\", StreamQuality.SD),\n     (\"240p\", StreamQuality.SD),\n     (\"audio\", StreamQuality.UNKNOWN),\n ]\n \n \n def infer_quality(filename: str) -> StreamQuality:\n     \"\"\"Infer video quality from a filename.\"\"\"\n     lower = filename.lower()\n     for pattern, quality in _QUALITY_PATTERNS:\n         if pattern in lower:\n             return quality\n     return StreamQuality.UNKNOWN\n \n \n def infer_mime_type(filename: str) -> str | None:\n     \"\"\"Infer MIME type from a file extension.\"\"\"\n     if not filename or \".\" not in filename:\n         return None\n     _, ext = filename.rsplit(\".\", 1)\n     ext = f\".{ext.lower()}\"\n     return _MIME_MAP.get(ext)\n \n \n def is_playable_media(filename: str) -> bool:\n     \"\"\"Return True when a provider file looks like playable audio/video media.\"\"\"\n     mime_type = infer_mime_type(filename)\n     if mime_type is None:\n         return False\n     normalized = mime_type.lower()\n     return normalized.startswith(_PLAYABLE_MEDIA_MIME_PREFIXES) or normalized in _PLAYABLE_MEDIA_MIME_TYPES\n \n \n def parse_datetime(value: Any) -> datetime | None:\n     \"\"\"Parse provider date strings defensively.\"\"\"\n     if value in (None, \"\"):\n         return None\n     if not isinstance(value, str):\n         return None\n     try:\n         return datetime.fromisoformat(value.replace(\"Z\", \"+00:00\"))\n     except (ValueError, TypeError):\n         return None\n \n \n def _number(value: Any, default: float = 0) -> float:\n     \"\"\"Coerce provider numeric fields that may arrive as int/float/string.\"\"\"\n     if value in (None, \"\"):\n         return default\n     if isinstance(value, bool):\n         return default\n     try:\n         number = float(value)\n     except (TypeError, ValueError):\n         return default\n     if not math.isfinite(number):\n         raise ProviderStreamUnavailableError()\n     return number\n \n \n def size_bytes(value: Any) -> int:\n     \"\"\"Coerce provider file sizes into safe non-negative integer bytes.\"\"\"\n     size = int(_number(value, 0))\n     if size < 0 or size > _MAX_FILE_SIZE_BYTES:\n         raise ProviderStreamUnavailableError()\n     return size\n \n \n def safe_filename(value: Any, default: str = \"unknown\") -> str:\n     \"\"\"Normalize provider-controlled filenames for API/UI use.\n \n     Providers can return paths, control characters, or very long names. Keep the\n     final path segment, strip control characters, and bound length so responses\n     remain predictable without leaking path-like metadata into clients/logs.\n     \"\"\"\n     if not isinstance(value, str):\n         return default\n     cleaned = _CONTROL_CHARS_PATTERN.sub(\"\", value).strip()\n     if not cleaned:\n         return default\n     filename = PurePosixPath(cleaned.replace(\"\\\\\", \"/\")).name or default\n     if filename in {\".\", \"..\"}:\n         return default\n     return filename[:_MAX_FILENAME_LENGTH] or default\n \n \n def _is_safe_stream_url(value: str) -> bool:\n     \"\"\"Return True when a provider stream URL is an absolute HTTPS URL.\n \n     Stream URLs are handed back to clients as playable links, so require TLS and\n     reject embedded credentials. Input source URLs may still be HTTP if a\n     provider supports fetching them, but returned playback URLs must be HTTPS.\n     \"\"\"\n     parsed = urlparse(value)\n     if parsed.scheme != \"https\":\n         return False\n     return bool(parsed.netloc and parsed.hostname and not parsed.username and not parsed.password)\n \n \n def stream_url(value: Any) -> str:\n     \"\"\"Extract and validate a provider-returned playable stream URL.\"\"\"\n     if not isinstance(value, str) or not value.strip():\n         raise ProviderStreamUnavailableError()\n     stream_url_val = value.strip()\n     if not _is_safe_stream_url(stream_url_val):\n         raise ProviderStreamUnavailableError()\n     return stream_url_val\n \n \n def _dict(value: Any) -> Mapping[str, Any]:\n     \"\"\"Defensively coerce a value to a dict mapping.\"\"\"\n     if not isinstance(value, dict):\n         raise ValueError(\"Expected dict\")\n     return value\n \n \n def parse_retry_after(header_value: str | None) -> int | None:\n     \"\"\"Parse Retry-After header, returning seconds or None if absent.\n \n     Supports both delta-seconds (integer) and HTTP-date formats.\n     \"\"\"\n     if not header_value:\n         return None\n     try:\n         seconds = int(header_value)\n         return max(1, min(seconds, 300))  # Cap at 5 minutes\n     except ValueError:\n         return None\n \n \n-async def request_json(\n+async def _request(\n     client: httpx.AsyncClient,\n     method: str,\n     url: str,\n     *,\n     retry_once: bool = True,\n     **kwargs: Any,\n-) -> Mapping[str, Any]:\n-    \"\"\"Send a provider request and return JSON with sanitized errors.\n+) -> httpx.Response:\n+    \"\"\"Send a provider request with retry and sanitized error handling.\n \n-    Raw httpx exceptions can include request URLs and auth query strings,\n-    so they are translated here before reaching logs or API responses.\n+    Shared by request_json and request_no_content to avoid duplication.\n     \"\"\"\n     method_upper = method.upper()\n     can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n     attempts = 2 if can_retry else 1\n     response: httpx.Response | None = None\n     for attempt in range(attempts):\n         try:\n             response = await client.request(method, url, **kwargs)\n         except (httpx.TimeoutException, httpx.RequestError):\n             if attempt + 1 < attempts:\n                 await asyncio.sleep(0)\n                 continue\n             raise ProviderStreamUnavailableError() from None\n \n         if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n             await asyncio.sleep(0)\n             continue\n         break\n \n     if response is None:\n         raise ProviderStreamUnavailableError()\n     if response.status_code in (401, 403):\n         raise ProviderStreamUnavailableError()\n     if response.status_code == 429:\n         retry_after_val = parse_retry_after(response.headers.get(\"Retry-After\"))\n         raise ProviderStreamUnavailableError(cooldown_seconds=retry_after_val)\n     if response.status_code >= 500:\n         raise ProviderStreamUnavailableError(cooldown_seconds=30)\n     if response.status_code >= 400:\n         raise ProviderStreamUnavailableError(cooldown_seconds=30)\n+    return response\n \n+\n+async def request_json(\n+    client: httpx.AsyncClient,\n+    method: str,\n+    url: str,\n+    *,\n+    retry_once: bool = True,\n+    **kwargs: Any,\n+) -> Mapping[str, Any]:\n+    \"\"\"Send a provider request and return JSON with sanitized errors.\n+\n+    Raw httpx exceptions can include request URLs and auth query strings,\n+    so they are translated here before reaching logs or API responses.\n+    \"\"\"\n+    response = await _request(client, method, url, retry_once=retry_once, **kwargs)\n+\n     try:\n         data = response.json()\n     except ValueError as exc:\n         raise ProviderStreamUnavailableError() from exc\n     if not isinstance(data, dict):\n         raise ProviderStreamUnavailableError()\n     return data\n \n \n async def request_no_content(\n     client: httpx.AsyncClient,\n     method: str,\n     url: str,\n     *,\n     retry_once: bool = True,\n     **kwargs: Any,\n ) -> None:\n     \"\"\"Send a provider request expecting a no-content (2xx) response.\n \n     Like request_json but for endpoints that return no body (e.g. 204).\n     Raw httpx exceptions are sanitized to avoid credential leakage.\n     \"\"\"\n-    method_upper = method.upper()\n-    can_retry = retry_once and method_upper in _SAFE_RETRY_METHODS\n-    attempts = 2 if can_retry else 1\n-    response: httpx.Response | None = None\n-    for attempt in range(attempts):\n-        try:\n-            response = await client.request(method, url, **kwargs)\n-        except (httpx.TimeoutException, httpx.RequestError):\n-            if attempt + 1 < attempts:\n-                await asyncio.sleep(0)\n-                continue\n-            raise ProviderStreamUnavailableError() from None\n+    response = await _request(client, method, url, retry_once=retry_once, **kwargs)\n \n-        if response.status_code in _RETRYABLE_STATUS_CODES and attempt + 1 < attempts:\n-            await asyncio.sleep(0)\n-            continue\n-        break\n-\n-    if response is None:\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code in (401, 403):\n-        raise ProviderStreamUnavailableError()\n-    if response.status_code == 429:\n-        retry_after_val = parse_retry_after(response.headers.get(\"Retry-After\"))\n-        raise ProviderStreamUnavailableError(cooldown_seconds=retry_after_val)\n-    if response.status_code >= 500:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n-    if response.status_code >= 400:\n-        raise ProviderStreamUnavailableError(cooldown_seconds=30)\n     if response.status_code < 200 or response.status_code >= 300:\n         raise ProviderStreamUnavailableError()\n",
    "additions": 23,
    "deletions": 34,
    "status": "modified"
  },
  {
    "file": "app/modules/user/application/interfaces.py",
    "patch": "Index: app/modules/user/application/interfaces.py\n===================================================================\n--- app/modules/user/application/interfaces.py\t\n+++ app/modules/user/application/interfaces.py\t\n@@ -1,97 +1,97 @@\n \"\"\"User application-layer ports.\n \n Ports expose domain objects, not SQLAlchemy models, so the application layer\n stays independent from persistence details.\n \"\"\"\n \n from datetime import datetime\n from typing import Protocol\n \n from app.modules.user.domain.entities import SessionEntity, UserEntity\n \n \n class PasswordHasher(Protocol):\n     \"\"\"Port for password hashing and verification.\"\"\"\n \n-    def hash(self, password: str) -> str:\n+    async def hash(self, password: str) -> str:\n         \"\"\"Hash a plain password.\"\"\"\n         ...\n \n-    def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n+    async def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n         \"\"\"Verify a plain password and optionally return an upgraded hash.\"\"\"\n         ...\n \n \n class TokenService(Protocol):\n     \"\"\"Port for issuing authentication tokens.\"\"\"\n \n     def create_access_token(self, subject: str) -> str:\n         \"\"\"Create an access token for a subject.\"\"\"\n         ...\n \n \n class UserRepository(Protocol):\n     \"\"\"Port for user persistence.\"\"\"\n \n     async def create(\n         self,\n         *,\n         email: str,\n         username: str,\n         hashed_password: str,\n         role: str,\n         is_active: bool,\n         created_at: datetime,\n         updated_at: datetime,\n     ) -> UserEntity:\n         \"\"\"Create a new user and return the saved domain entity.\"\"\"\n         ...\n \n     async def get_by_id(self, user_id: int) -> UserEntity | None:\n         \"\"\"Get user by ID.\"\"\"\n         ...\n \n     async def get_by_email(self, email: str) -> UserEntity | None:\n         \"\"\"Get user by email.\"\"\"\n         ...\n \n     async def get_by_username(self, username: str) -> UserEntity | None:\n         \"\"\"Get user by username.\"\"\"\n         ...\n \n \n class SessionRepository(Protocol):\n     \"\"\"Port for session persistence.\"\"\"\n \n     async def create(\n         self,\n         *,\n         user_id: int,\n         refresh_token_hash: str,\n         device_name: str | None,\n         user_agent: str | None,\n         ip_address: str | None,\n         expires_at: datetime,\n     ) -> SessionEntity:\n         \"\"\"Create a new user session.\"\"\"\n         ...\n \n     async def get_by_refresh_hash(self, token_hash: str) -> SessionEntity | None:\n         \"\"\"Get session by its refresh token hash.\"\"\"\n         ...\n \n     async def get_valid_session(self, user_id: int, session_id: int) -> SessionEntity | None:\n         \"\"\"Get a non-revoked, non-expired session by user_id and session_id.\"\"\"\n         ...\n \n     async def revoke(self, session_id: int) -> None:\n         \"\"\"Revoke a session.\"\"\"\n         ...\n \n     async def revoke_all_for_user(self, user_id: int, except_session_id: int | None = None) -> None:\n         \"\"\"Revoke all sessions for a user, optionally excluding one.\"\"\"\n         ...\n \n     async def get_user_sessions(self, user_id: int) -> list[SessionEntity]:\n         \"\"\"List all active (non-revoked, non-expired) sessions for a user.\"\"\"\n         ...\n",
    "additions": 2,
    "deletions": 2,
    "status": "modified"
  },
  {
    "file": "app/modules/user/application/use_cases.py",
    "patch": "Index: app/modules/user/application/use_cases.py\n===================================================================\n--- app/modules/user/application/use_cases.py\t\n+++ app/modules/user/application/use_cases.py\t\n@@ -1,185 +1,185 @@\n \"\"\"User application use cases.\"\"\"\n \n import hashlib\n from datetime import UTC, datetime, timedelta\n \n from app.core.config import get_settings\n from app.modules.user.application.interfaces import PasswordHasher, SessionRepository, TokenService, UserRepository\n from app.modules.user.domain.entities import SessionEntity, UserCreateEntity, UserEntity, UserLoginEntity\n from app.modules.user.domain.services import UserService\n \n \n def _hash_token(token: str) -> str:\n     \"\"\"Hash a refresh token for secure storage.\"\"\"\n     return hashlib.sha256(token.encode()).hexdigest()\n \n \n class UserUseCases:\n     \"\"\"Application-layer use cases for user operations.\"\"\"\n \n     def __init__(\n         self,\n         user_repo: UserRepository,\n         password_hasher: PasswordHasher,\n         token_service: TokenService,\n         session_repo: SessionRepository | None = None,\n     ):\n         self.user_repo = user_repo\n         self.password_hasher = password_hasher\n         self.token_service = token_service\n         self.session_repo = session_repo\n \n     async def register(self, data: UserCreateEntity) -> UserEntity:\n         \"\"\"Register a new user.\"\"\"\n         errors = UserService.validate_registration(data)\n         if errors:\n             raise ValueError(\"; \".join(errors))\n \n         if await self.user_repo.get_by_email(data.email):\n             raise ValueError(\"Email already registered\")\n \n         if await self.user_repo.get_by_username(data.username):\n             raise ValueError(\"Username already registered\")\n \n         now = datetime.now(UTC)\n         return await self.user_repo.create(\n             email=data.email,\n             username=data.username,\n-            hashed_password=self.password_hasher.hash(data.password),\n+            hashed_password=await self.password_hasher.hash(data.password),\n             role=\"user\",\n             is_active=True,\n             created_at=now,\n             updated_at=now,\n         )\n \n     async def login(self, data: UserLoginEntity, *, device_name: str | None = None,\n                     user_agent: str | None = None, ip_address: str | None = None) -> dict[str, object]:\n         \"\"\"Authenticate user and return access token + refresh token.\n \n         Args:\n             data: Login credentials.\n             device_name: Optional device name for session tracking.\n             user_agent: Optional User-Agent string for session tracking.\n             ip_address: Optional client IP address for session tracking.\n         \"\"\"\n         user = await self.user_repo.get_by_email(data.email)\n         if not user:\n             raise ValueError(\"Invalid credentials\")\n \n-        is_valid, _ = self.password_hasher.verify(data.password, user.hashed_password)\n+        is_valid, _ = await self.password_hasher.verify(data.password, user.hashed_password)\n         if not is_valid:\n             raise ValueError(\"Invalid credentials\")\n \n         if not user.is_active:\n             raise ValueError(\"Account is deactivated\")\n \n         access_token = self.token_service.create_access_token(subject=str(user.id))\n \n         result: dict[str, object] = {\n             \"access_token\": access_token,\n             \"token_type\": \"bearer\",\n             \"user\": {\n                 \"id\": user.id,\n                 \"email\": user.email,\n                 \"username\": user.username,\n                 \"role\": user.role,\n                 \"is_active\": user.is_active,\n             },\n         }\n \n         # Create refresh token + session if session repo is wired\n         if self.session_repo is not None:\n             from app.core.security import create_refresh_token\n \n             refresh_token = create_refresh_token(subject=str(user.id))\n             settings = get_settings()\n             expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)\n             await self.session_repo.create(\n                 user_id=user.id,\n                 refresh_token_hash=_hash_token(refresh_token),\n                 device_name=device_name,\n                 user_agent=user_agent,\n                 ip_address=ip_address,\n                 expires_at=expires_at,\n             )\n             result[\"refresh_token\"] = refresh_token\n \n         return result\n \n     async def get_by_id(self, user_id: int) -> UserEntity | None:\n         \"\"\"Get user by ID.\"\"\"\n         return await self.user_repo.get_by_id(user_id)\n \n     async def refresh_token(self, refresh_token: str) -> dict[str, str]:\n         \"\"\"Validate a refresh token, rotate it, and return new tokens.\n \n         Returns dict with access_token and refresh_token.\n         \"\"\"\n         if self.session_repo is None:\n             raise ValueError(\"Session management not configured\")\n \n         from jwt.exceptions import InvalidTokenError\n \n         from app.core.security import create_access_token, create_refresh_token, decode_refresh_token\n \n         try:\n             payload = decode_refresh_token(refresh_token)\n         except InvalidTokenError:\n             raise ValueError(\"Invalid or expired refresh token\") from None\n \n         user_id = int(payload[\"sub\"])\n \n         # Look up session by stored hash\n         token_hash = _hash_token(refresh_token)\n         session = await self.session_repo.get_by_refresh_hash(token_hash)\n         if session is None:\n             raise ValueError(\"Session not found\")\n         if session.revoked_at is not None:\n             raise ValueError(\"Session has been revoked\")\n         if session.expires_at <= datetime.now(UTC):\n             raise ValueError(\"Invalid or expired refresh token\")\n \n         # Check user is still active\n         user = await self.user_repo.get_by_id(user_id)\n         if user is None or not user.is_active:\n             raise ValueError(\"Account is deactivated\")\n \n         # Revoke old session (rotation)\n         await self.session_repo.revoke(session.id)\n \n         # Issue new tokens\n         new_access = create_access_token(subject=str(user_id))\n         new_refresh = create_refresh_token(subject=str(user_id))\n         settings = get_settings()\n         expires_at = datetime.now(UTC) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)\n         await self.session_repo.create(\n             user_id=user_id,\n             refresh_token_hash=_hash_token(new_refresh),\n             device_name=session.device_name,\n             user_agent=session.user_agent,\n             ip_address=session.ip_address,\n             expires_at=expires_at,\n         )\n \n         return {\"access_token\": new_access, \"refresh_token\": new_refresh}\n \n     async def logout(self, user_id: int) -> None:\n         \"\"\"Revoke all active sessions for a user.\"\"\"\n         if self.session_repo is None:\n             return\n         await self.session_repo.revoke_all_for_user(user_id)\n \n     async def get_sessions(self, user_id: int) -> list[SessionEntity]:\n         \"\"\"List active sessions for a user.\"\"\"\n         if self.session_repo is None:\n             return []\n         return await self.session_repo.get_user_sessions(user_id)\n \n     async def revoke_session(self, user_id: int, session_id: int) -> None:\n         \"\"\"Revoke a specific session if it belongs to the user.\"\"\"\n         if self.session_repo is None:\n             raise ValueError(\"Session management not configured\")\n         session = await self.session_repo.get_valid_session(user_id, session_id)\n         if session is None:\n             raise ValueError(\"Session not found\")\n         await self.session_repo.revoke(session_id)\n",
    "additions": 2,
    "deletions": 2,
    "status": "modified"
  },
  {
    "file": "tests/modules/addons/test_application_stremio_stream_usecase.py",
    "patch": "Index: tests/modules/addons/test_application_stremio_stream_usecase.py\n===================================================================\n--- tests/modules/addons/test_application_stremio_stream_usecase.py\t\n+++ tests/modules/addons/test_application_stremio_stream_usecase.py\t\n@@ -0,0 +1,315 @@\n+\"\"\"Tests for StremioStreamUseCase.\n+\n+Tests the full pipeline: search -> resolve -> sort/cap -> map to StremioStreamInfo.\n+Uses stub ports (no mocks) for TorrentSearchPort and ProviderStreamResolver.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from collections.abc import Mapping\n+\n+import pytest\n+\n+from app.modules.addons.application.interfaces import TorrentSearchPort, TorrentSearchResult\n+from app.modules.addons.application.stremio_stream_usecase import StremioStreamUseCase\n+from app.modules.addons.domain.value_objects import StremioContentType\n+from app.modules.streaming.application.interfaces import ProviderStreamResolver\n+from app.modules.streaming.domain.value_objects import (\n+    ResolveSource,\n+    ResolveSourceType,\n+    StreamQuality,\n+    StreamResolveResult,\n+)\n+\n+\n+class StubTorrentSearch(TorrentSearchPort):\n+    \"\"\"Stub torrent search that returns pre-configured results.\"\"\"\n+\n+    def __init__(self, results: list[TorrentSearchResult] | None = None):\n+        self.results = results or []\n+        self.last_imdb_id: str | None = None\n+        self.last_title: str | None = None\n+        self.search_count = 0\n+\n+    async def search_by_imdb_id(self, imdb_id: str) -> list[TorrentSearchResult]:\n+        self.last_imdb_id = imdb_id\n+        self.search_count += 1\n+        return self.results\n+\n+    async def search_by_title(self, title: str, year: int | None = None) -> list[TorrentSearchResult]:\n+        self.last_title = title\n+        self.search_count += 1\n+        return self.results\n+\n+\n+class StubResolver(ProviderStreamResolver):\n+    \"\"\"Stub resolver that returns pre-configured StreamResolveResults.\"\"\"\n+\n+    def __init__(\n+        self,\n+        provider: str = \"real_debrid\",\n+        results: list[StreamResolveResult] | None = None,\n+    ):\n+        self.provider = provider\n+        self.results = results or []\n+        self.resolve_calls: list[tuple[ResolveSource, str]] = []\n+\n+    async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n+        self.resolve_calls.append((source, api_key))\n+        return self.results\n+\n+\n+def _make_magnet_source(value: str = \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\") -> ResolveSource:\n+    return ResolveSource.from_string(value)\n+\n+\n+def _make_resolve_result(\n+    quality: StreamQuality = StreamQuality.FHD,\n+    cached: bool = False,\n+    stream_url: str = \"https://cdn.example.com/stream.mkv\",\n+    title: str = \"movie.mkv\",\n+) -> StreamResolveResult:\n+    return StreamResolveResult(\n+        provider=\"real_debrid\",\n+        account_id=1,\n+        source_type=ResolveSourceType.MAGNET,\n+        title=title,\n+        stream_url=stream_url,\n+        filename=title,\n+        size_bytes=1_000_000_000,\n+        mime_type=\"video/x-matroska\",\n+        quality=quality,\n+        cached=cached,\n+    )\n+\n+\n+@pytest.fixture\n+def resolver() -> StubResolver:\n+    return StubResolver()\n+\n+\n+@pytest.fixture\n+def use_case(resolver: StubResolver) -> StremioStreamUseCase:\n+    search = StubTorrentSearch()\n+    resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": resolver}\n+    return StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+\n+class TestStremioStreamUseCase:\n+    \"\"\"Tests for the full Stremio stream resolution pipeline.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_no_torrents_returns_empty(self, use_case: StremioStreamUseCase):\n+        \"\"\"When no torrents found, returns empty list.\"\"\"\n+        streams = await use_case.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        assert streams == []\n+\n+    @pytest.mark.asyncio\n+    async def test_single_stream_mapped_correctly(self, use_case: StremioStreamUseCase, resolver: StubResolver):\n+        \"\"\"Single torrent resolved maps to StremioStreamInfo correctly.\"\"\"\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(\n+                magnet_url=\"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n+                seeds=42,\n+                title=\"Fight Club 1999 1080p\",\n+            ),\n+        ])\n+        resolver.results = [_make_resolve_result(quality=StreamQuality.FHD, cached=True)]\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": resolver}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        assert len(streams) == 1\n+        s = streams[0]\n+        assert s.url == \"https://cdn.example.com/stream.mkv\"\n+        assert s.quality == \"fhd\"\n+        assert s.source == \"real_debrid\"\n+        assert s.behavior_hints is not None\n+        assert s.behavior_hints.get(\"cached\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_cached_streams_sorted_before_uncached(self, use_case: StremioStreamUseCase, resolver: StubResolver):\n+        \"\"\"Cached streams appear before uncached when both exist.\"\"\"\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(magnet_url=f\"magnet:?xt=urn:btih:{i:040x}\", seeds=10)\n+            for i in range(2)\n+        ])\n+        resolver.results = [\n+            _make_resolve_result(quality=StreamQuality.FHD, cached=False, stream_url=\"https://cdn.example.com/not_cached.mkv\"),\n+            _make_resolve_result(quality=StreamQuality.FHD, cached=True, stream_url=\"https://cdn.example.com/cached.mkv\"),\n+        ]\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": resolver}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        assert len(streams) >= 2\n+        assert streams[0].behavior_hints.get(\"cached\") is True\n+\n+    @pytest.mark.asyncio\n+    async def test_higher_quality_sorted_first(self, use_case: StremioStreamUseCase):\n+        \"\"\"UHD streams are sorted before FHD, FHD before HD, etc.\"\"\"\n+        qualities = [StreamQuality.SD, StreamQuality.FHD, StreamQuality.HD, StreamQuality.UHD]\n+\n+        class CyclingResolver(ProviderStreamResolver):\n+            def __init__(self):\n+                self.idx = 0\n+\n+            async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n+                q = qualities[self.idx % len(qualities)]\n+                self.idx += 1\n+                return [_make_resolve_result(quality=q, stream_url=f\"https://cdn.example.com/{q.value}.mkv\")]\n+\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(magnet_url=f\"magnet:?xt=urn:btih:{i:040x}\", seeds=5)\n+            for i in range(4)\n+        ])\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": CyclingResolver()}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        qualities_str = [s.quality for s in streams]\n+        expected = [\"uhd\", \"fhd\", \"hd\", \"sd\"]\n+        assert qualities_str == expected\n+\n+    @pytest.mark.asyncio\n+    async def test_caps_at_max_streams(self, use_case: StremioStreamUseCase, resolver: StubResolver):\n+        \"\"\"Results are capped at _MAX_STREAMS (20).\"\"\"\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(magnet_url=f\"magnet:?xt=urn:btih:{i:040x}\", seeds=5)\n+            for i in range(25)\n+        ])\n+        resolver.results = [\n+            _make_resolve_result(quality=StreamQuality.FHD, stream_url=f\"https://cdn.example.com/stream{i}.mkv\")\n+            for i in range(25)\n+        ]\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": resolver}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        assert len(streams) <= 20\n+\n+    @pytest.mark.asyncio\n+    async def test_falls_back_to_title_search_when_imdb_fails(self):\n+        \"\"\"When IMDB search returns nothing, falls back to title search.\"\"\"\n+        resolver = StubResolver()\n+        resolver.results = [_make_resolve_result()]\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(magnet_url=\"magnet:?xt=urn:btih:bb\", seeds=10, title=\"Test\"),\n+        ])\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": resolver}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"no-imdb-id\",\n+        )\n+        # Should have called search_by_title since no IMDB ID was found\n+        assert search.last_title == \"no-imdb-id\"\n+        assert len(streams) >= 0\n+\n+    @pytest.mark.asyncio\n+    async def test_resolve_error_does_not_crash_pipeline(self):\n+        \"\"\"A failing resolver call doesn't crash the entire pipeline.\"\"\"\n+        # Make first call return results, second call fail\n+        call_count = 0\n+\n+        class FailingResolver(ProviderStreamResolver):\n+            async def resolve(self, source: ResolveSource, api_key: str) -> list[StreamResolveResult]:\n+                nonlocal call_count\n+                call_count += 1\n+                if call_count == 1:\n+                    return [_make_resolve_result()]\n+                return []\n+\n+        search = StubTorrentSearch(results=[\n+            TorrentSearchResult(magnet_url=f\"magnet:?xt=urn:btih:{i:040x}\", seeds=5)\n+            for i in range(2)\n+        ])\n+        resolvers: Mapping[str, ProviderStreamResolver] = {\"real_debrid\": FailingResolver()}\n+        uc = StremioStreamUseCase(torrent_search=search, resolvers=resolvers)\n+\n+        # Should not raise\n+        streams = await uc.get_streams(\n+            api_key=\"test_key\",\n+            content_type=StremioContentType.MOVIE,\n+            item_id=\"tt0137523\",\n+        )\n+        assert len(streams) >= 0\n+\n+\n+class TestExtractImdbId:\n+    \"\"\"Tests for the _extract_imdb_id helper.\"\"\"\n+\n+    def test_standard_imdb_id(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _extract_imdb_id\n+        assert _extract_imdb_id(\"tt0137523\") == \"tt0137523\"\n+\n+    def test_imdb_id_less_than_8_chars(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _extract_imdb_id\n+        assert _extract_imdb_id(\"tt123\") is None\n+\n+    def test_non_imdb_id(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _extract_imdb_id\n+        assert _extract_imdb_id(\"tmdb:550\") is None\n+\n+    def test_empty_string(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _extract_imdb_id\n+        assert _extract_imdb_id(\"\") is None\n+\n+\n+class TestMapToStremioStream:\n+    \"\"\"Tests for the _map_to_stremio_stream helper.\"\"\"\n+\n+    def test_maps_all_fields(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _map_to_stremio_stream\n+        result = StreamResolveResult(\n+            provider=\"real_debrid\",\n+            account_id=1,\n+            source_type=ResolveSourceType.MAGNET,\n+            title=\"test.mkv\",\n+            stream_url=\"https://cdn.example.com/test.mkv\",\n+            filename=\"test.mkv\",\n+            size_bytes=1_000_000_000,\n+            mime_type=\"video/x-matroska\",\n+            quality=StreamQuality.FHD,\n+            cached=True,\n+        )\n+        stream = _map_to_stremio_stream(result)\n+        assert stream.url == \"https://cdn.example.com/test.mkv\"\n+        assert stream.name == \"test.mkv\"\n+        assert stream.quality == \"fhd\"\n+        assert stream.source == \"real_debrid\"\n+        assert stream.behavior_hints[\"cached\"] is True\n+\n+    def test_maps_uncached(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _map_to_stremio_stream\n+        result = _make_resolve_result(cached=False)\n+        stream = _map_to_stremio_stream(result)\n+        assert stream.behavior_hints[\"cached\"] is False\n+\n+    def test_unknown_quality(self):\n+        from app.modules.addons.application.stremio_stream_usecase import _map_to_stremio_stream\n+        result = _make_resolve_result(quality=StreamQuality.UNKNOWN)\n+        stream = _map_to_stremio_stream(result)\n+        assert stream.quality == \"unknown\"\n",
    "additions": 315,
    "deletions": 0,
    "status": "added"
  },
  {
    "file": "tests/modules/addons/test_infrastructure_prowlarr_client.py",
    "patch": "Index: tests/modules/addons/test_infrastructure_prowlarr_client.py\n===================================================================\n--- tests/modules/addons/test_infrastructure_prowlarr_client.py\t\n+++ tests/modules/addons/test_infrastructure_prowlarr_client.py\t\n@@ -0,0 +1,256 @@\n+\"\"\"Tests for ProwlarrClient Torznab API client.\"\"\"\n+\n+from __future__ import annotations\n+\n+import httpx\n+import pytest\n+\n+from app.modules.addons.infrastructure.prowlarr_client import (\n+    ProwlarrClient,\n+    ProwlarrConfig,\n+)\n+\n+\n+def _make_torznab_xml(items: list[dict]) -> str:\n+    \"\"\"Build a minimal Torznab XML response.\"\"\"\n+    item_xmls = \"\"\n+    for item in items:\n+        attrs = \"\".join(\n+            f'<attr name=\"{k}\" value=\"{v}\"/>'\n+            for k, v in item.get(\"attrs\", {}).items()\n+        )\n+        link = item.get(\"link\", \"\")\n+        item_xmls += f\"\"\"<item>\n+            <title>{item.get(\"title\", \"\")}</title>\n+            <size>{item.get(\"size\", 0)}</size>\n+            <link>{link}</link>\n+            {attrs}\n+        </item>\"\"\"\n+    return f\"\"\"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n+<rss version=\"2.0\" xmlns:torznab=\"http://torznab.com/schemas/2015/feed\">\n+  <channel>\n+    <title>Test Indexer</title>\n+    {item_xmls}\n+  </channel>\n+</rss>\"\"\"\n+\n+\n+@pytest.fixture\n+def config() -> ProwlarrConfig:\n+    return ProwlarrConfig(url=\"http://prowlarr:9696\", api_key=\"test_key\")\n+\n+\n+@pytest.fixture\n+def mock_transport(config: ProwlarrConfig) -> tuple[ProwlarrClient, list[dict]]:\n+    \"\"\"Create a ProwlarrClient with mock transport and a list to capture requests.\"\"\"\n+    requests: list[httpx.Request] = []\n+\n+    def handler(request: httpx.Request) -> httpx.Response:\n+        requests.append(request)\n+        return httpx.Response(200, text=_make_torznab_xml([]))\n+\n+    transport = httpx.MockTransport(handler)\n+    client = httpx.AsyncClient(transport=transport)\n+    prowlarr = ProwlarrClient(config=config, http_client=client)\n+    return prowlarr, requests\n+\n+\n+class TestProwlarrConfig:\n+    \"\"\"Tests for configuration handling.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_empty_config_returns_empty(self):\n+        \"\"\"When URL or API key is empty, search returns empty list.\"\"\"\n+        empty_config = ProwlarrConfig(url=\"\", api_key=\"\")\n+        client = ProwlarrClient(config=empty_config)\n+        results = await client.search_by_imdb_id(\"tt0137523\")\n+        assert results == []\n+\n+    @pytest.mark.asyncio\n+    async def test_partial_config_returns_empty(self):\n+        \"\"\"Partial config (missing URL or key) returns empty.\"\"\"\n+        cfg = ProwlarrConfig(url=\"http://localhost\", api_key=\"\")\n+        client = ProwlarrClient(config=cfg)\n+        results = await client.search_by_title(\"Fight Club\")\n+        assert results == []\n+\n+\n+class TestSearchByImdbId:\n+    \"\"\"Tests for search_by_imdb_id.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_successful_search(self, mock_transport):\n+        \"\"\"Search by IMDB ID returns parsed results.\"\"\"\n+        prowlarr, _requests = mock_transport\n+        xml = _make_torznab_xml([\n+            {\n+                \"title\": \"Fight Club (1999) 1080p\",\n+                \"size\": 2147483648,\n+                \"link\": \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n+                \"attrs\": {\"seeders\": \"42\", \"infohash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\", \"imdbid\": \"tt0137523\"},\n+            },\n+        ])\n+        # Override the mock transport for this test\n+        handler = lambda r: httpx.Response(200, text=xml)  # noqa: E731\n+        prowlarr._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))\n+\n+        results = await prowlarr.search_by_imdb_id(\"tt0137523\")\n+        assert len(results) == 1\n+        assert results[0].title == \"Fight Club (1999) 1080p\"\n+        assert results[0].magnet_url.startswith(\"magnet:\")\n+        assert results[0].seeds == 42\n+        assert results[0].info_hash == \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"\n+        assert results[0].imdb_id == \"tt0137523\"\n+\n+    @pytest.mark.asyncio\n+    async def test_no_results(self, mock_transport):\n+        \"\"\"Empty XML response returns empty list.\"\"\"\n+        prowlarr, _ = mock_transport\n+        xml = _make_torznab_xml([])\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+\n+        results = await prowlarr.search_by_imdb_id(\"tt9999999\")\n+        assert results == []\n+\n+    @pytest.mark.asyncio\n+    async def test_filters_items_without_magnet(self, mock_transport):\n+        \"\"\"Items without magnet links are filtered out.\"\"\"\n+        prowlarr, _ = mock_transport\n+        xml = _make_torznab_xml([\n+            {\"title\": \"No Magnet\", \"link\": \"\", \"attrs\": {\"seeders\": \"5\"}},\n+            {\"title\": \"Has Magnet\", \"link\": \"magnet:?xt=urn:btih:aa\", \"attrs\": {\"seeders\": \"10\"}},\n+        ])\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+\n+        results = await prowlarr.search_by_imdb_id(\"tt0137523\")\n+        assert len(results) == 1\n+        assert results[0].title == \"Has Magnet\"\n+\n+    @pytest.mark.asyncio\n+    async def test_malformed_xml_returns_empty(self, mock_transport):\n+        \"\"\"Malformed XML response returns empty list (no crash).\"\"\"\n+        prowlarr, _ = mock_transport\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=\"not xml\"))\n+        )\n+        results = await prowlarr.search_by_imdb_id(\"tt0137523\")\n+        assert results == []\n+\n+    @pytest.mark.asyncio\n+    async def test_http_error_returns_empty(self, mock_transport):\n+        \"\"\"HTTP errors (e.g. 500) return empty list.\"\"\"\n+        prowlarr, _ = mock_transport\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(500))\n+        )\n+        results = await prowlarr.search_by_imdb_id(\"tt0137523\")\n+        assert results == []\n+\n+    @pytest.mark.asyncio\n+    async def test_timeout_returns_empty(self, mock_transport):\n+        \"\"\"Timeout errors return empty list.\"\"\"\n+        prowlarr, _ = mock_transport\n+\n+        async def timeout_handler(_r):\n+            raise httpx.TimeoutException(\"timed out\")\n+\n+        prowlarr._client = httpx.AsyncClient(transport=httpx.MockTransport(timeout_handler))\n+        results = await prowlarr.search_by_imdb_id(\"tt0137523\")\n+        assert results == []\n+\n+\n+class TestSearchByTitle:\n+    \"\"\"Tests for search_by_title.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_search_by_title_with_year(self, mock_transport):\n+        \"\"\"Title search with year appends year to query.\"\"\"\n+        prowlarr, requests = mock_transport\n+        xml = _make_torznab_xml([\n+            {\n+                \"title\": \"Fight Club (1999)\",\n+                \"link\": \"magnet:?xt=urn:btih:bb\",\n+                \"attrs\": {\"seeders\": \"30\"},\n+            },\n+        ])\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+\n+        results = await prowlarr.search_by_title(\"Fight Club\", year=1999)\n+        assert len(results) == 1\n+        assert results[0].title == \"Fight Club (1999)\"\n+\n+    @pytest.mark.asyncio\n+    async def test_search_by_title_without_year(self, mock_transport):\n+        \"\"\"Title search without year uses raw title as query.\"\"\"\n+        prowlarr, _ = mock_transport\n+        xml = _make_torznab_xml([])\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+        results = await prowlarr.search_by_title(\"Fight Club\")\n+        assert results == []\n+\n+    @pytest.mark.asyncio\n+    async def test_results_sorted_by_seeds(self, mock_transport):\n+        \"\"\"Results are sorted by seed count, descending.\"\"\"\n+        prowlarr, _ = mock_transport\n+        xml = _make_torznab_xml([\n+            {\n+                \"title\": \"Low Seeds\",\n+                \"link\": \"magnet:?xt=urn:btih:cc\",\n+                \"attrs\": {\"seeders\": \"5\"},\n+            },\n+            {\n+                \"title\": \"High Seeds\",\n+                \"link\": \"magnet:?xt=urn:btih:dd\",\n+                \"attrs\": {\"seeders\": \"100\"},\n+            },\n+            {\n+                \"title\": \"Medium Seeds\",\n+                \"link\": \"magnet:?xt=urn:btih:ee\",\n+                \"attrs\": {\"seeders\": \"50\"},\n+            },\n+        ])\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+\n+        results = await prowlarr.search_by_title(\"Fight Club\")\n+        seeds = [r.seeds for r in results]\n+        assert seeds == [100, 50, 5]\n+\n+\n+class TestParseTorznabItem:\n+    \"\"\"Tests for parsing individual Torznab items.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_caps_at_max_results(self, mock_transport):\n+        \"\"\"Parser caps at _MAX_RESULTS (50) items.\"\"\"\n+        prowlarr, _ = mock_transport\n+        items = []\n+        for i in range(60):\n+            items.append({\n+                \"title\": f\"Item {i}\",\n+                \"link\": f\"magnet:?xt=urn:btih:{i:040x}\",\n+                \"attrs\": {\"seeders\": str(100 - i)},\n+            })\n+        xml = _make_torznab_xml(items)\n+        prowlarr._client = httpx.AsyncClient(\n+            transport=httpx.MockTransport(lambda r: httpx.Response(200, text=xml))\n+        )\n+\n+        results = await prowlarr.search_by_title(\"test\")\n+        assert len(results) == 50\n+\n+    @pytest.mark.asyncio\n+    async def test_close_cleans_up_client(self):\n+        \"\"\"close() cleans up the HTTP client.\"\"\"\n+        client = ProwlarrClient(config=ProwlarrConfig(url=\"http://localhost\", api_key=\"key\"))\n+        assert client._owns_client is True\n+        await client.close()\n",
    "additions": 256,
    "deletions": 0,
    "status": "added"
  },
  {
    "file": "tests/modules/streaming/test_presentation_routers.py",
    "patch": "Index: tests/modules/streaming/test_presentation_routers.py\n===================================================================\n--- tests/modules/streaming/test_presentation_routers.py\t\n+++ tests/modules/streaming/test_presentation_routers.py\t\n@@ -1,359 +1,359 @@\n \"\"\"Integration tests for stream resolve API endpoints.\"\"\"\n \n from collections.abc import AsyncIterator\n from unittest.mock import patch\n \n import httpx\n import pytest\n from httpx import ASGITransport, AsyncClient\n from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine\n \n from app.core.database import Base, get_async_session\n from app.core.jti_denylist import get_jti_denylist, reset_jti_denylist\n from app.core.security import create_access_token, decode_access_token\n from app.main import create_app\n \n-# Patch crypto at module level (same as debrid test)\n-patch(\"app.modules.debrid.application.use_cases.encrypt_api_key\", return_value=\"encrypted_mock_key\").start()\n-patch(\"app.modules.debrid.infrastructure.repositories.decrypt_api_key\", return_value=\"decrypted_mock_key\").start()\n \n-\n def _resolve_handler(request: httpx.Request) -> httpx.Response:\n     \"\"\"Handle stream resolve API mock requests for all supported providers.\"\"\"\n     url_str = str(request.url)\n     # Real-Debrid: POST /unrestrict/link\n     if \"unrestrict/link\" in url_str:\n         return httpx.Response(200, json={\n             \"id\": \"abc123\",\n             \"filename\": \"movie.1080p.mkv\",\n             \"filesize\": 1234567890,\n             \"link\": \"https://cdn.example.com/stream/movie.mkv\",\n         })\n     # Real-Debrid: POST /torrents/addMagnet\n     if \"addMagnet\" in url_str:\n         return httpx.Response(201, json={\n             \"id\": 12345,\n             \"uri\": \"https://api.real-debrid.com/rest/1.0/torrents/info/12345\",\n         })\n     # Real-Debrid: POST /torrents/selectFiles/{id}\n     if \"selectFiles\" in url_str:\n         return httpx.Response(204)\n     # Real-Debrid: GET /torrents/info/{id}\n     if \"torrents/info\" in url_str:\n         return httpx.Response(200, json={\n             \"id\": 12345,\n             \"filename\": \"movie.mkv\",\n             \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\",\n             \"bytes\": 2000000000,\n             \"status\": \"downloaded\",\n             \"files\": [\n                 {\"id\": 0, \"path\": \"movie.mkv\", \"bytes\": 1500000000, \"selected\": 1},\n             ],\n             \"links\": [\n                 \"https://ddl.real-debrid.com/ddl/abc123\",\n             ],\n             \"added\": \"2024-01-01T00:00:00+00:00\",\n         })\n     # AllDebrid: POST /link/unrestrict\n     if \"alldebrid\" in url_str and \"link/unrestrict\" in url_str:\n         return httpx.Response(200, json={\n             \"status\": \"success\",\n             \"data\": {\n                 \"filename\": \"movie.1080p.mkv\",\n                 \"size\": 1234567890,\n                 \"link\": \"https://cdn.alldebrid.com/stream/movie.mkv\",\n             },\n         })\n     # AllDebrid: POST /magnet/upload\n     if \"alldebrid\" in url_str and \"magnet/upload\" in url_str:\n         return httpx.Response(200, json={\n             \"status\": \"success\",\n             \"data\": {\n                 \"magnets\": [{\"id\": 12345, \"hash\": \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"}],\n             },\n         })\n     # AllDebrid: GET /magnet/status\n     if \"alldebrid\" in url_str and \"magnet/status\" in url_str:\n         return httpx.Response(200, json={\n             \"status\": \"success\",\n             \"data\": {\n                 \"magnets\": [{\n                     \"id\": 12345,\n                     \"filename\": \"movie.1080p.mkv\",\n                     \"size\": 2000000000,\n                     \"links\": [{\"link\": \"https://stream.alldebrid.com/movie.m3u8\", \"filename\": \"movie.1080p.mkv\", \"size\": 1500000000}],\n                 }],\n             },\n         })\n     # Premiumize: POST /torrent/create\n     if \"premiumize\" in url_str and \"torrent/create\" in url_str:\n         return httpx.Response(200, json={\"status\": \"success\", \"id\": \"pm_12345\"})\n     # Premiumize: GET /torrent/browse\n     if \"premiumize\" in url_str and \"torrent/browse\" in url_str:\n         return httpx.Response(200, json={\n             \"status\": \"success\",\n             \"content\": [{\"id\": \"f1\", \"name\": \"movie.1080p.mkv\", \"size\": 1500000000, \"stream_url\": \"https://stream.premiumize.me/movie.m3u8\"}],\n         })\n     # TorBox: POST /torrents/createtorrent\n     if \"torbox\" in url_str and \"createtorrent\" in url_str:\n         return httpx.Response(200, json={\"status\": \"success\", \"data\": {\"torrent_id\": \"tb_12345\"}})\n     # TorBox: GET /torrents/mylist\n     if \"torbox\" in url_str and \"mylist\" in url_str:\n         return httpx.Response(200, json={\n             \"status\": \"success\",\n             \"data\": [{\"id\": \"tb_12345\", \"files\": [{\"url\": \"https://stream.torbox.app/movie.m3u8\", \"name\": \"movie.1080p.mkv\", \"size\": 1500000000}]}],\n         })\n     return httpx.Response(404, json={\"error\": \"Not found\"})\n \n \n @pytest.fixture\n async def client() -> AsyncIterator[AsyncClient]:\n     \"\"\"Provide an async test client with SQLite and mocked streaming resolver HTTP.\"\"\"\n-    engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n-    async with engine.begin() as conn:\n-        await conn.run_sync(Base.metadata.create_all)\n+    with (\n+        patch(\"app.modules.debrid.application.use_cases.encrypt_api_key\", return_value=\"encrypted_mock_key\"),\n+        patch(\"app.modules.debrid.infrastructure.repositories.decrypt_api_key\", return_value=\"decrypted_mock_key\"),\n+    ):\n+        engine = create_async_engine(\"sqlite+aiosqlite:///:memory:\")\n+        async with engine.begin() as conn:\n+            await conn.run_sync(Base.metadata.create_all)\n \n-    session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n+        session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)\n \n-    async def override_session() -> AsyncIterator[AsyncSession]:\n-        async with session_factory() as session:\n-            try:\n-                yield session\n-                await session.commit()\n-            except Exception:\n-                await session.rollback()\n-                raise\n+        async def override_session() -> AsyncIterator[AsyncSession]:\n+            async with session_factory() as session:\n+                try:\n+                    yield session\n+                    await session.commit()\n+                except Exception:\n+                    await session.rollback()\n+                    raise\n \n-    app = create_app()\n-    app.dependency_overrides[get_async_session] = override_session\n+        app = create_app()\n+        app.dependency_overrides[get_async_session] = override_session\n \n-    # Replace resolver HTTP clients with mocked transports.\n-    from app.modules.streaming.presentation.dependencies import ensure_stream_resolvers\n+        # Replace resolver HTTP clients with mocked transports.\n+        from app.modules.streaming.presentation.dependencies import ensure_stream_resolvers\n \n-    stream_resolvers = ensure_stream_resolvers()\n-    mock_transport = httpx.MockTransport(_resolve_handler)\n-    original_transports: dict[str, httpx.AsyncClient] = {}\n-    replacement_clients: list[httpx.AsyncClient] = []\n-    for name, resolver in stream_resolvers.items():\n-        if hasattr(resolver, \"_client\"):\n-            original_transports[name] = resolver._client\n-            replacement = httpx.AsyncClient(transport=mock_transport, timeout=15.0)\n-            replacement_clients.append(replacement)\n-            resolver._client = replacement\n+        stream_resolvers = ensure_stream_resolvers()\n+        mock_transport = httpx.MockTransport(_resolve_handler)\n+        original_transports: dict[str, httpx.AsyncClient] = {}\n+        replacement_clients: list[httpx.AsyncClient] = []\n+        for name, resolver in stream_resolvers.items():\n+            if hasattr(resolver, \"_client\"):\n+                original_transports[name] = resolver._client\n+                replacement = httpx.AsyncClient(transport=mock_transport, timeout=15.0)\n+                replacement_clients.append(replacement)\n+                resolver._client = replacement\n \n-    async with AsyncClient(\n-        transport=ASGITransport(app=app), base_url=\"http://test\"\n-    ) as test_client:\n-        yield test_client\n+        async with AsyncClient(\n+            transport=ASGITransport(app=app), base_url=\"http://test\"\n+        ) as test_client:\n+            yield test_client\n \n-    # Restore original transports\n-    for name, orig_client in original_transports.items():\n-        if name in stream_resolvers and hasattr(stream_resolvers[name], \"_client\"):\n-            stream_resolvers[name]._client = orig_client\n-    for replacement in replacement_clients:\n-        await replacement.aclose()\n-    app.dependency_overrides.clear()\n-    await engine.dispose()\n+        # Restore original transports\n+        for name, orig_client in original_transports.items():\n+            if name in stream_resolvers and hasattr(stream_resolvers[name], \"_client\"):\n+                stream_resolvers[name]._client = orig_client\n+        for replacement in replacement_clients:\n+            await replacement.aclose()\n+        app.dependency_overrides.clear()\n+        await engine.dispose()\n \n \n # Helper to add a debrid account\n async def add_debrid_account(client: AsyncClient, token: str, provider: str = \"real_debrid\", api_key: str = \"test_key_12345\") -> int:\n     \"\"\"Helper to add a debrid account and return its ID.\"\"\"\n     response = await client.post(\n         \"/api/v1/debrid/accounts\",\n         json={\"provider\": provider, \"api_key\": api_key},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     assert response.status_code == 201\n     return response.json()[\"id\"]\n \n \n @pytest.mark.asyncio\n async def test_resolve_requires_auth(client: AsyncClient):\n     \"\"\"Resolve endpoint requires a valid Bearer token.\"\"\"\n     response = await client.post(\"/api/v1/streams/resolve\", json={\"source\": \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\"})\n     assert response.status_code == 401\n     assert response.headers[\"www-authenticate\"] == \"Bearer\"\n \n \n @pytest.mark.asyncio\n async def test_resolve_no_account(client: AsyncClient):\n     \"\"\"Resolve without a linked debrid account returns 404.\"\"\"\n     token = create_access_token(subject=\"1\")\n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\"},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     assert response.status_code == 404\n     body = response.json()\n     assert \"no linked\" in body.get(\"error\", \"\").lower() or \"No linked\" in body.get(\"error\", \"\")\n \n \n @pytest.mark.asyncio\n async def test_resolve_direct_url_success(client: AsyncClient):\n     \"\"\"Direct URL resolves successfully when user has a debrid account.\"\"\"\n     token = create_access_token(subject=\"1\")\n     account_id = await add_debrid_account(client, token)\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\n             \"source\": \"https://example.com/movie.1080p.mkv\",\n             \"account_id\": account_id,\n         },\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     assert response.status_code == 200\n     body = response.json()\n     assert \"streams\" in body\n     assert len(body[\"streams\"]) == 1\n     stream = body[\"streams\"][0]\n     assert stream[\"provider\"] == \"real_debrid\"\n     assert stream[\"account_id\"] == account_id\n     assert stream[\"filename\"] == \"movie.1080p.mkv\"\n \n \n @pytest.mark.asyncio\n async def test_resolve_magnet_success(client: AsyncClient):\n     \"\"\"Magnet link resolves successfully.\"\"\"\n     token = create_access_token(subject=\"1\")\n     account_id = await add_debrid_account(client, token)\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\n             \"source\": \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\",\n             \"account_id\": account_id,\n         },\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     assert response.status_code == 200\n     body = response.json()\n     assert len(body[\"streams\"]) >= 1\n \n \n @pytest.mark.asyncio\n async def test_resolve_other_user_account_denied(client: AsyncClient):\n     \"\"\"Cannot use another user's debrid account.\"\"\"\n     token1 = create_access_token(subject=\"1\")\n     token2 = create_access_token(subject=\"2\")\n     account_id = await add_debrid_account(client, token1)\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\n             \"source\": \"https://example.com/movie.mp4\",\n             \"account_id\": account_id,\n         },\n         headers={\"Authorization\": f\"Bearer {token2}\"},\n     )\n     assert response.status_code == 404\n     body = response.json()\n     assert \"no linked\" in body.get(\"error\", \"\").lower() or \"No linked\" in body.get(\"error\", \"\")\n \n \n @pytest.mark.asyncio\n async def test_resolve_invalid_source(client: AsyncClient):\n     \"\"\"Invalid source returns error (not 500).\"\"\"\n     token = create_access_token(subject=\"1\")\n     await add_debrid_account(client, token)\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": \"\"},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     # Pydantic field validation (min_length=1 on source) returns 422\n     assert response.status_code == 422\n     body = response.json()\n     assert \"detail\" in body\n \n \n @pytest.mark.asyncio\n async def test_resolve_unknown_source_does_not_reflect_sensitive_input(client: AsyncClient):\n     \"\"\"Domain validation errors must not reflect raw sources with embedded secrets.\"\"\"\n     token = create_access_token(subject=\"1\")\n     await add_debrid_account(client, token)\n     sensitive_source = \"not-a-source?api_key=sk_live_should_not_echo\"\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": sensitive_source},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n \n     assert response.status_code == 422\n     body = response.json()\n     assert body[\"error\"] == \"Invalid or unsupported stream source\"\n     assert \"sk_liv...echo\" not in str(body)\n     assert sensitive_source not in str(body)\n \n \n @pytest.mark.asyncio\n async def test_resolve_rejects_non_positive_account_id(client: AsyncClient):\n     \"\"\"Invalid account IDs are rejected at the API boundary.\"\"\"\n     token = create_access_token(subject=\"1\")\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": \"https://example.com/movie.mp4\", \"account_id\": 0},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n \n     assert response.status_code == 422\n \n \n @pytest.mark.asyncio\n async def test_resolve_rejects_oversized_source(client: AsyncClient):\n     \"\"\"Oversized sources are rejected before provider resolution.\"\"\"\n     token = create_access_token(subject=\"1\")\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": \"https://example.com/\" + (\"a\" * 9000), \"account_id\": 1},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n \n     assert response.status_code == 422\n \n \n @pytest.mark.asyncio\n async def test_resolve_api_key_not_leaked(client: AsyncClient):\n     \"\"\"Error responses must not contain API keys.\"\"\"\n     token = create_access_token(subject=\"1\")\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\n             \"source\": \"https://example.com/movie.mp4\",\n             \"account_id\": 99999,  # Non-existent account\n         },\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n     assert response.status_code == 404\n     body = response.json()\n     assert \"error\" in body\n     # Check no API key patterns in response\n     text = str(body)\n     assert \"sk_\" not in text\n \n \n @pytest.mark.asyncio\n async def test_resolve_rejects_denylisted_access_token(client: AsyncClient):\n     \"\"\"Stream resolve rejects an access token after its JTI is denylisted.\"\"\"\n     reset_jti_denylist()\n     token = create_access_token(subject=\"1\")\n     payload = decode_access_token(token)\n     denylist = get_jti_denylist()\n     await denylist.add(payload[\"jti\"], ttl_seconds=60)\n \n     response = await client.post(\n         \"/api/v1/streams/resolve\",\n         json={\"source\": \"magnet:?xt=urn:btih:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0&dn=test\"},\n         headers={\"Authorization\": f\"Bearer {token}\"},\n     )\n \n     assert response.status_code == 401\n     assert response.json()[\"detail\"] == \"Token has been revoked\"\n     reset_jti_denylist()\n",
    "additions": 49,
    "deletions": 49,
    "status": "modified"
  },
  {
    "file": "tests/modules/user/test_application_use_cases.py",
    "patch": "Index: tests/modules/user/test_application_use_cases.py\n===================================================================\n--- tests/modules/user/test_application_use_cases.py\t\n+++ tests/modules/user/test_application_use_cases.py\t\n@@ -1,409 +1,409 @@\n \"\"\"Tests for user application use cases with typed in-memory stubs.\"\"\"\n \n from datetime import UTC, datetime, timedelta\n \n import pytest\n \n from app.core.security import get_password_hash\n from app.modules.user.application.use_cases import UserUseCases\n from app.modules.user.domain.entities import SessionEntity, UserCreateEntity, UserEntity, UserLoginEntity\n \n \n class StubUserRepository:\n     def __init__(self) -> None:\n         self.users: dict[int, UserEntity] = {}\n         self.next_id = 1\n \n     async def create(self, **kwargs) -> UserEntity:\n         user = UserEntity(id=self.next_id, **kwargs)\n         self.users[user.id] = user\n         self.next_id += 1\n         return user\n \n     async def get_by_id(self, user_id: int) -> UserEntity | None:\n         return self.users.get(user_id)\n \n     async def get_by_email(self, email: str) -> UserEntity | None:\n         return next((user for user in self.users.values() if user.email == email), None)\n \n     async def get_by_username(self, username: str) -> UserEntity | None:\n         return next((user for user in self.users.values() if user.username == username), None)\n \n \n def make_user(**overrides) -> UserEntity:\n     now = datetime.now(UTC)\n     data = {\n         \"id\": 1,\n         \"email\": \"test@example.com\",\n         \"username\": \"testuser\",\n         \"hashed_password\": get_password_hash(\"StrongPass123\"),\n         \"role\": \"user\",\n         \"is_active\": True,\n         \"created_at\": now,\n         \"updated_at\": now,\n     }\n     data.update(overrides)\n     return UserEntity(**data)\n \n \n class StubPasswordHasher:\n-    def hash(self, password: str) -> str:\n+    async def hash(self, password: str) -> str:\n         return get_password_hash(password)\n \n-    def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n+    async def verify(self, plain_password: str, hashed_password: str) -> tuple[bool, str | None]:\n         from app.core.security import verify_password\n \n         return verify_password(plain_password, hashed_password)\n \n \n class StubTokenService:\n     def create_access_token(self, subject: str) -> str:\n         return f\"test-token-for-{subject}\"\n \n \n @pytest.fixture\n def repo() -> StubUserRepository:\n     return StubUserRepository()\n \n \n @pytest.fixture\n def use_cases(repo: StubUserRepository) -> UserUseCases:\n     return UserUseCases(repo, StubPasswordHasher(), StubTokenService())\n \n \n async def test_register_success(use_cases: UserUseCases):\n     result = await use_cases.register(\n         UserCreateEntity(email=\"test@example.com\", username=\"testuser\", password=\"StrongPass123\")\n     )\n \n     assert result.id == 1\n     assert result.email == \"test@example.com\"\n     assert result.username == \"testuser\"\n     assert result.role == \"user\"\n     assert result.is_active is True\n \n \n async def test_register_duplicate_email(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user(email=\"test@example.com\", username=\"other\")\n \n     with pytest.raises(ValueError, match=\"Email already registered\"):\n         await use_cases.register(\n             UserCreateEntity(email=\"test@example.com\", username=\"newuser\", password=\"StrongPass123\")\n         )\n \n \n async def test_register_duplicate_username(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user(email=\"other@example.com\", username=\"testuser\")\n \n     with pytest.raises(ValueError, match=\"Username already registered\"):\n         await use_cases.register(\n             UserCreateEntity(email=\"test@example.com\", username=\"testuser\", password=\"StrongPass123\")\n         )\n \n \n async def test_register_short_password_rejected_by_domain_service(use_cases: UserUseCases):\n     with pytest.raises(ValueError, match=\"Password must be at least 8 characters\"):\n         await use_cases.register(\n             UserCreateEntity(email=\"test@example.com\", username=\"testuser\", password=\"short\")\n         )\n \n \n async def test_login_success(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user()\n \n     result = await use_cases.login(UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\"))\n \n     assert \"access_token\" in result\n     assert result[\"token_type\"] == \"bearer\"\n     assert result[\"user\"][\"email\"] == \"test@example.com\"\n \n \n async def test_login_invalid_credentials(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user()\n \n     with pytest.raises(ValueError, match=\"Invalid credentials\"):\n         await use_cases.login(UserLoginEntity(email=\"test@example.com\", password=\"wrongpassword\"))\n \n \n async def test_login_user_not_found(use_cases: UserUseCases):\n     with pytest.raises(ValueError, match=\"Invalid credentials\"):\n         await use_cases.login(UserLoginEntity(email=\"missing@example.com\", password=\"StrongPass123\"))\n \n \n async def test_login_inactive_user(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user(is_active=False)\n \n     with pytest.raises(ValueError, match=\"Account is deactivated\"):\n         await use_cases.login(UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\"))\n \n \n async def test_get_by_id_success(use_cases: UserUseCases, repo: StubUserRepository):\n     repo.users[1] = make_user()\n \n     result = await use_cases.get_by_id(1)\n \n     assert result is not None\n     assert result.id == 1\n \n \n async def test_get_by_id_not_found(use_cases: UserUseCases):\n     result = await use_cases.get_by_id(999)\n     assert result is None\n \n \n # ── Session tests ────────────────────────────────────────────────────────\n \n \n class StubSessionRepository:\n     def __init__(self) -> None:\n         self.sessions: dict[int, SessionEntity] = {}\n         self.next_id = 1\n \n     async def create(\n         self,\n         *,\n         user_id: int,\n         refresh_token_hash: str,\n         device_name: str | None,\n         user_agent: str | None,\n         ip_address: str | None,\n         expires_at: datetime,\n     ) -> SessionEntity:\n         session = SessionEntity(\n             id=self.next_id,\n             user_id=user_id,\n             refresh_token_hash=refresh_token_hash,\n             device_name=device_name,\n             user_agent=user_agent,\n             ip_address=ip_address,\n             expires_at=expires_at,\n             revoked_at=None,\n             created_at=datetime.now(UTC),\n             updated_at=datetime.now(UTC),\n         )\n         self.sessions[self.next_id] = session\n         self.next_id += 1\n         return session\n \n     async def get_by_refresh_hash(self, token_hash: str) -> SessionEntity | None:\n         for s in self.sessions.values():\n             if s.refresh_token_hash == token_hash:\n                 return s\n         return None\n \n     async def get_valid_session(self, user_id: int, session_id: int) -> SessionEntity | None:\n         s = self.sessions.get(session_id)\n         if s and s.user_id == user_id and s.revoked_at is None and s.expires_at > datetime.now(UTC):\n             return s\n         return None\n \n     async def revoke(self, session_id: int) -> None:\n         s = self.sessions.get(session_id)\n         if s:\n             s.revoked_at = datetime.now(UTC)\n \n     async def revoke_all_for_user(self, user_id: int, except_session_id: int | None = None) -> None:\n         for s in list(self.sessions.values()):\n             if s.user_id == user_id and s.revoked_at is None:\n                 if except_session_id is not None and s.id == except_session_id:\n                     continue\n                 s.revoked_at = datetime.now(UTC)\n \n     async def get_user_sessions(self, user_id: int) -> list[SessionEntity]:\n         now = datetime.now(UTC)\n         return [\n             s for s in self.sessions.values()\n             if s.user_id == user_id and s.revoked_at is None and s.expires_at > now\n         ]\n \n \n @pytest.fixture\n def session_repo() -> StubSessionRepository:\n     return StubSessionRepository()\n \n \n @pytest.fixture\n def use_cases_with_sessions(\n     repo: StubUserRepository,\n     session_repo: StubSessionRepository,\n ) -> UserUseCases:\n     return UserUseCases(repo, StubPasswordHasher(), StubTokenService(), session_repo)\n \n \n async def test_login_returns_refresh_token(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"Login with session repo returns refresh_token.\"\"\"\n     repo.users[1] = make_user()\n \n     result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n \n     assert \"access_token\" in result\n     assert \"refresh_token\" in result\n     assert result[\"token_type\"] == \"bearer\"\n \n \n async def test_refresh_token_rotates(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n     session_repo: StubSessionRepository,\n ):\n     \"\"\"Refresh token is rotated: old one revoked, new one issued.\"\"\"\n     repo.users[1] = make_user()\n \n     login_result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n     old_refresh = login_result[\"refresh_token\"]\n \n     # Use the actual refresh function\n     refresh_result = await use_cases_with_sessions.refresh_token(old_refresh)\n \n     assert \"access_token\" in refresh_result\n     assert \"refresh_token\" in refresh_result\n     assert refresh_result[\"refresh_token\"] != old_refresh\n \n \n async def test_old_refresh_token_rejected_after_rotation(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"After rotation, the old refresh token is rejected.\"\"\"\n     repo.users[1] = make_user()\n \n     login_result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n     old_refresh = login_result[\"refresh_token\"]\n \n     # Rotate\n     await use_cases_with_sessions.refresh_token(old_refresh)\n \n     # Old token should now be rejected (session was revoked after rotation)\n     with pytest.raises(ValueError):\n         await use_cases_with_sessions.refresh_token(old_refresh)\n \n \n async def test_logout_revokes_all_sessions(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"Logout revokes all sessions for the user.\"\"\"\n     repo.users[1] = make_user()\n \n     login_result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n     refresh_token = login_result[\"refresh_token\"]\n \n     await use_cases_with_sessions.logout(user_id=1)\n \n     # After logout, the refresh token should be rejected\n     with pytest.raises(ValueError):\n         await use_cases_with_sessions.refresh_token(refresh_token)\n \n \n async def test_refresh_rejects_inactive_user(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"Refresh is rejected if user has been deactivated.\"\"\"\n     repo.users[1] = make_user()\n \n     login_result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n     old_refresh = login_result[\"refresh_token\"]\n \n     # Deactivate user\n     repo.users[1] = make_user(is_active=False)\n \n     with pytest.raises(ValueError, match=\"deactivated\"):\n         await use_cases_with_sessions.refresh_token(old_refresh)\n \n \n async def test_refresh_rejects_expired_persisted_session(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n     session_repo: StubSessionRepository,\n ):\n     \"\"\"DB session expiry is authoritative even if the JWT still decodes.\"\"\"\n     repo.users[1] = make_user()\n \n     login_result = await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n     refresh_token = login_result[\"refresh_token\"]\n     session = next(iter(session_repo.sessions.values()))\n     session.expires_at = datetime.now(UTC) - timedelta(seconds=1)\n \n     with pytest.raises(ValueError, match=\"expired refresh token\"):\n         await use_cases_with_sessions.refresh_token(refresh_token)\n \n \n async def test_get_sessions_lists_active_sessions(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"get_sessions returns only active (non-revoked, non-expired) sessions.\"\"\"\n     repo.users[1] = make_user()\n \n     await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n \n     sessions = await use_cases_with_sessions.get_sessions(user_id=1)\n \n     assert len(sessions) >= 1\n     assert sessions[0].user_id == 1\n     assert sessions[0].revoked_at is None\n \n \n async def test_revoke_session(\n     use_cases_with_sessions: UserUseCases,\n     repo: StubUserRepository,\n     session_repo: StubSessionRepository,\n ):\n     \"\"\"A specific session can be revoked by ID.\"\"\"\n     repo.users[1] = make_user()\n \n     await use_cases_with_sessions.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n \n     sessions = await use_cases_with_sessions.get_sessions(user_id=1)\n     assert len(sessions) >= 1\n     session_id = sessions[0].id\n \n     # Revoke it\n     await use_cases_with_sessions.revoke_session(user_id=1, session_id=session_id)\n \n     # Should be gone from active sessions\n     remaining = await use_cases_with_sessions.get_sessions(user_id=1)\n     assert session_id not in [s.id for s in remaining]\n \n \n async def test_revoke_nonexistent_session_raises(\n     use_cases_with_sessions: UserUseCases,\n ):\n     \"\"\"Revoking a non-existent session raises ValueError.\"\"\"\n     with pytest.raises(ValueError, match=\"Session not found\"):\n         await use_cases_with_sessions.revoke_session(user_id=1, session_id=999)\n \n \n async def test_login_without_session_repo_no_refresh(\n     use_cases: UserUseCases,\n     repo: StubUserRepository,\n ):\n     \"\"\"Login without session_repo does NOT return refresh_token (backward compat).\"\"\"\n     repo.users[1] = make_user()\n \n     result = await use_cases.login(\n         UserLoginEntity(email=\"test@example.com\", password=\"StrongPass123\")\n     )\n \n     assert \"access_token\" in result\n     assert \"refresh_token\" not in result\n",
    "additions": 2,
    "deletions": 2,
    "status": "modified"
  }
]