Files
MoviePilot/tests/test_servarr_series_add.py
Aqr-K 951cdc5b27 fix: arbitrate subscription identity and restore workflow state (#7147)
* fix: arbitrate subscription identity and restore workflow state

* test: preserve legacy empty-owner subscription transactions

* fix: align shared atomic configuration method with lifecycle prerequisite

---------

Co-authored-by: jxxghp <jxxghp@gmail.com>
2026-10-23 08:14:15 +08:00

282 lines
8.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Seerr 的 Sonarr 兼容端点回归测试。
Seerr 提交剧集请求时,POST /api/v3/series 请求体不携带 tmdbId、只携带 tvdbId,
且季列表由 lookup 接口返回的季列表构造;lookup 未返回季信息时请求体季列表为空。
本测试保证新增剧集订阅在以上两种情况下都能正常创建订阅,且不会静默成功。
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from app.api.servarr import arr_add_movie, arr_add_series, arr_series_lookup
from app.application.servarr import ServarrSubscriptionService
from app.db.adapters.subscription import SessionSubscriptionRepository
from app.db.models.subscribe import Subscribe
from app.schemas.servarr import RadarrMovie, SonarrSeason, SonarrSeries
from app.schemas.types import MediaSource, MediaType
_TVDB_ID = 454898
_TMDB_ID = 236534
def _fake_mediainfo(tmdb_id=_TMDB_ID, seasons=None):
"""
构造最小可用的媒体识别结果。
"""
return SimpleNamespace(
tmdb_id=tmdb_id,
title="Tales of Herding Gods",
year="2024",
imdb_id=None,
seasons=seasons or {1: [1, 2, 3]},
get_poster_image=lambda: None,
)
def _series(tmdb_id=None, seasons=None):
"""
构造 Seerr 风格的剧集请求体,默认不携带 tmdbId。
"""
return SonarrSeries(
id=None,
title="Tales of Herding Gods",
tvdbId=_TVDB_ID,
tmdbId=tmdb_id,
year="2024",
seasons=seasons or [],
)
def _run_add(tv, subscriptions, batch_writer):
"""
直接调用新增剧集订阅处理函数。
"""
return asyncio.run(
arr_add_series(
tv=tv,
_="api-token",
subscriptions=subscriptions,
batch_writer=batch_writer,
)
)
def _run_add_movie(movie, subscriptions):
"""
直接调用新增电影订阅处理函数。
"""
return asyncio.run(
arr_add_movie(
movie=movie,
_="api-token",
subscriptions=subscriptions,
)
)
def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")):
"""
统一 patch 媒体链、批量订阅链与订阅查询。
"""
media_chain = MagicMock()
media_chain.tvdb_info.return_value = {
"name": "Tales of Herding Gods",
"seasons": [{"type": {"id": "default"}, "number": 1}],
"defaultSeasonType": "default",
}
media_chain.recognize_by_meta.return_value = mediainfo
subscribe_chain = MagicMock()
subscribe_chain.async_add_batch = AsyncMock(return_value=add_result)
subscriptions = MagicMock()
subscriptions.exists = AsyncMock(return_value=bool(exists))
subscriptions.get_existing = AsyncMock(return_value=exists)
batch_writer = MagicMock()
return patch(
"app.api.servarr.MediaChain",
return_value=media_chain,
), patch(
"app.api.servarr.SubscribeChain",
return_value=subscribe_chain,
), subscriptions, subscribe_chain, batch_writer
def test_add_series_without_tmdbid_resolves_identity_via_tvdbid():
"""
Seerr 请求体不携带 tmdbId 时,应按 tvdbId 补全媒体身份并创建订阅。
"""
tv = _series(seasons=[SonarrSeason(seasonNumber=1, monitored=True)])
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
mediainfo=_fake_mediainfo()
)
with media_patch, chain_patch:
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 123
subscribe_chain.async_add_batch.assert_awaited_once_with(
title="Tales of Herding Gods",
year="2024",
seasons=[1],
batch_writer=batch_writer,
media_source=MediaSource.TMDB,
media_id=str(_TMDB_ID),
mtype=MediaType.TV,
username="Seerr",
)
def test_add_movie_converts_year_for_subscription_contract():
"""
Servarr 电影请求的年份应转换为订阅写入端口要求的字符串。
"""
subscriptions = MagicMock()
subscriptions.list_by_media_identity = AsyncMock(return_value=[])
subscribe_chain = MagicMock()
subscribe_chain.async_add = AsyncMock(return_value=(123, ""))
movie = RadarrMovie(title="A Movie", year=2024, tmdbId=42)
with patch("app.api.servarr.SubscribeChain", return_value=subscribe_chain):
result = _run_add_movie(movie, subscriptions)
assert result.id == 123
subscribe_chain.async_add.assert_awaited_once()
assert subscribe_chain.async_add.await_args.kwargs["year"] == "2024"
def test_add_movie_rejects_missing_title_or_year():
"""
缺少电影标题或年份时应拒绝请求,避免向订阅端口传入空值。
"""
subscriptions = MagicMock()
subscriptions.list_by_media_identity = AsyncMock(return_value=[])
movie = RadarrMovie(tmdbId=42)
with pytest.raises(HTTPException) as excinfo:
_run_add_movie(movie, subscriptions)
assert excinfo.value.status_code == 422
def test_add_series_with_empty_seasons_falls_back_to_all_seasons():
"""
请求体季列表为空时不应静默成功,应兜底订阅已识别的全部季。
"""
tv = _series()
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
mediainfo=_fake_mediainfo(seasons={1: [1, 2, 3], 2: [1]})
)
subscribe_chain.async_add_batch.return_value = (101, "")
with media_patch, chain_patch:
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 101
subscribe_chain.async_add_batch.assert_awaited_once()
assert subscribe_chain.async_add_batch.await_args.kwargs["seasons"] == [1, 2]
def test_add_series_already_subscribed_returns_existing():
"""
全部请求季已存在订阅时,返回已有标识且不重复创建。
"""
tv = _series(
tmdb_id=_TMDB_ID,
seasons=[SonarrSeason(seasonNumber=1, monitored=True)],
)
media_patch, chain_patch, subscriptions, subscribe_chain, batch_writer = _patch_chains(
exists=SimpleNamespace(id=9)
)
with media_patch, chain_patch:
result = _run_add(tv, subscriptions, batch_writer)
assert result.id == 9
subscribe_chain.async_add_batch.assert_not_awaited()
def test_add_series_existing_subscription_uses_async_repository(db):
"""
真实请求仓储查到已有季订阅时,兼容端点应幂等返回而不抛异常。
"""
existing = db.add(
Subscribe(
name="已存在的 Servarr 订阅",
type=MediaType.TV.value,
media_source=MediaSource.TMDB.value,
media_id=str(_TMDB_ID),
season=1,
)
)
async def execute(session):
"""
通过真实请求级仓储执行新增剧集检查。
"""
service = ServarrSubscriptionService(
async_repository=SessionSubscriptionRepository(session),
sync_repository=SessionSubscriptionRepository(db.session),
)
return await arr_add_series(
tv=_series(
tmdb_id=_TMDB_ID,
seasons=[SonarrSeason(seasonNumber=1, monitored=True)],
),
_="api-token",
subscriptions=service,
batch_writer=MagicMock(),
)
result = db.run_async_session(execute)
assert result.id == existing.id
def test_add_series_identity_resolution_failure_returns_500():
"""
媒体身份补全失败时返回 500,避免 Seerr 误判请求已成功。
"""
tv = _series()
media_patch, chain_patch, subscriptions, _, batch_writer = _patch_chains(
mediainfo=None
)
with media_patch, chain_patch:
with pytest.raises(HTTPException) as excinfo:
_run_add(tv, subscriptions, batch_writer)
assert excinfo.value.status_code == 500
def test_series_lookup_falls_back_to_tmdb_seasons():
"""
TVDB 未提供可用季信息时,lookup 应按 TMDB 季集兜底返回季列表。
"""
media_chain = MagicMock()
media_chain.tvdb_info.return_value = {
"name": "Tales of Herding Gods",
"seasons": [],
"defaultSeasonType": "default",
}
media_chain.recognize_by_meta.return_value = _fake_mediainfo(
seasons={1: [1, 2, 3], 2: [1]}
)
media_chain.media_exists.return_value = False
subscriptions = MagicMock()
subscriptions.list_by_media_identity_sync.return_value = []
with patch(
"app.api.servarr.TvdbChain",
return_value=MagicMock(get_tvdbid_by_name=MagicMock(return_value=[_TVDB_ID])),
), patch(
"app.api.servarr.MediaChain",
return_value=media_chain,
):
result = arr_series_lookup(
term=f"tvdb:{_TVDB_ID}",
_="api-token",
subscriptions=subscriptions,
)
assert len(result) == 1
assert [season.seasonNumber for season in result[0].seasons] == [1, 2]
assert all(not season.monitored for season in result[0].seasons)