fix(subscribe): scope duplicate checks by episode group (#6219)

This commit is contained in:
InfinityPacer
2026-08-01 18:40:29 +08:00
committed by GitHub
parent 93761fe7e4
commit 7c3f9629bf
6 changed files with 268 additions and 34 deletions

View File

@@ -933,17 +933,21 @@ class SubscribeChainTest(TestCase):
self.assertEqual(meta.begin_season, 0)
self.assertEqual(meta.type, MediaType.TV)
def test_follow_preserves_shared_special_season_zero(self):
"""follow 分享订阅携带 S0 时,标题规整不能把合法季号覆盖成未指定"""
def test_follow_preserves_shared_special_season_and_episode_group(self):
"""Follow 分享必须保留合法 S0 与自定义剧集组的完整订阅范围"""
added_calls = []
exists_calls = []
history_calls = []
class _SubscribeOper:
"""提供订阅存在性查询,避免依赖真实数据库。"""
def exists(self, *args, **kwargs):
exists_calls.append(kwargs)
return False
def exist_history(self, *args, **kwargs):
history_calls.append(kwargs)
return False
class _SystemConfigOper:
@@ -966,6 +970,7 @@ class SubscribeChainTest(TestCase):
"tmdbid": None,
"doubanid": "12345",
"season": 0,
"episode_group": "eg-special",
"best_version": 0,
"save_path": None,
"search_imdbid": False,
@@ -1003,6 +1008,9 @@ class SubscribeChainTest(TestCase):
self.assertEqual(len(added_calls), 1)
self.assertEqual(added_calls[0]["season"], 0)
self.assertEqual(added_calls[0]["episode_group"], "eg-special")
self.assertEqual(exists_calls[0]["episode_group"], "eg-special")
self.assertEqual(history_calls[0]["episode_group"], "eg-special")
def test_resolve_subscribe_missing_accepts_downloaded_episode_best_version_targets(self):
"""外部完成守卫可按任意已下载版本判定分集洗版目标已满足。"""

View File

@@ -1,5 +1,38 @@
import asyncio
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.subscribe_oper import SubscribeOper
from app.schemas.types import MediaType
def _media(episode_group):
"""构造订阅新增路径所需的稳定 MediaInfo 契约替身。"""
return SimpleNamespace(
title="测试剧",
year="2026",
type=MediaType.TV,
source="themoviedb",
media_source="themoviedb",
media_id="987654321",
mediaid="tmdb:987654321",
tmdb_id=987654321,
imdb_id=None,
tvdb_id=None,
douban_id=None,
bangumi_id=None,
anilist_id=None,
episode_group=episode_group,
vote_average=8.0,
overview="测试简介",
get_poster_image=lambda: None,
get_backdrop_image=lambda: None,
)
def test_add_history_converts_boolean_integer_flags(monkeypatch):
@@ -37,3 +70,176 @@ def test_add_history_converts_boolean_integer_flags(monkeypatch):
"best_version_full": 1,
"search_imdbid": 0,
}
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""同步新增前后都必须按剧集组查询,主季和自定义组不能互相去重。"""
persisted = SimpleNamespace(id=88)
created = SimpleNamespace(create=MagicMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, message = SubscribeOper(db=object()).add(
mediainfo=_media(episode_group),
season=1,
)
assert (sid, message) == (88, "新增订阅成功")
assert subscribe_model.exists.call_count == 2
assert all(
call.kwargs["episode_group"] == episode_group
for call in subscribe_model.exists.call_args_list
)
created.create.assert_called_once()
@pytest.mark.parametrize("episode_group", [None, "eg-1"])
def test_async_add_scopes_duplicate_lookup_by_episode_group(episode_group):
"""异步新增与同步路径使用相同的剧集组身份契约。"""
persisted = SimpleNamespace(id=89)
created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(side_effect=[None, persisted])
subscribe_model.return_value = created
sid, message = asyncio.run(SubscribeOper(db=object()).async_add(
mediainfo=_media(episode_group),
season=1,
))
assert (sid, message) == (89, "新增订阅成功")
assert subscribe_model.async_exists.await_count == 2
assert all(
call.kwargs["episode_group"] == episode_group
for call in subscribe_model.async_exists.await_args_list
)
created.async_create.assert_awaited_once()
def test_owner_scoped_add_forwards_episode_group_sync_and_async():
"""按 owner 去重的同步与异步新增也必须使用同一剧集组身份。"""
media = _media("eg-owner")
sync_persisted = SimpleNamespace(id=90)
sync_created = SimpleNamespace(create=MagicMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists_by_username.side_effect = [None, sync_persisted]
subscribe_model.return_value = sync_created
sid, _ = SubscribeOper(db=object()).add(
mediainfo=media,
season=1,
username="alice",
owner_scope=True,
)
assert sid == 90
assert all(
call.kwargs["episode_group"] == "eg-owner"
for call in subscribe_model.exists_by_username.call_args_list
)
async_persisted = SimpleNamespace(id=91)
async_created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.async_exists_by_username = AsyncMock(
side_effect=[None, async_persisted]
)
subscribe_model.return_value = async_created
sid, _ = asyncio.run(SubscribeOper(db=object()).async_add(
mediainfo=media,
season=1,
username="alice",
owner_scope=True,
))
assert sid == 91
assert all(
call.kwargs["episode_group"] == "eg-owner"
for call in subscribe_model.async_exists_by_username.await_args_list
)
def test_exists_defaults_to_main_season_episode_group():
"""省略剧集组时按主季查询,显式剧集组按对应范围查询。"""
oper = SubscribeOper(db=object())
with patch("app.db.subscribe_oper.Subscribe") as subscribe_model:
subscribe_model.exists.return_value = SimpleNamespace(id=1)
assert oper.exists(tmdbid=100, season=1) is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exists(tmdbid=100, season=1, episode_group="eg-1") is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] == "eg-1"
with patch("app.db.subscribe_oper.SubscribeHistory") as history_model:
history_model.exists.return_value = SimpleNamespace(id=2)
assert oper.exist_history(tmdbid=100, season=1) is True
assert history_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exist_history(tmdbid=100, season=1, episode_group="eg-1") is True
assert history_model.exists.call_args.kwargs["episode_group"] == "eg-1"
def test_subscribe_exists_distinguishes_same_season_episode_groups():
"""同一媒体同一季的主季、自定义剧集组应分别命中各自订阅。"""
oper = SubscribeOper()
tmdbid = -(900_000_000 + os.getpid())
created_ids = []
rows = [
Subscribe(name="主季订阅", type=MediaType.TV.value, state="N",
tmdbid=tmdbid, season=1, episode_group=None),
Subscribe(name="剧集组订阅", type=MediaType.TV.value, state="N",
tmdbid=tmdbid, season=1, episode_group="eg-1"),
]
try:
for row in rows:
row.create(oper._db)
main_season = Subscribe.exists(
oper._db, tmdbid=tmdbid, season=1, episode_group=None,
)
created_ids.append(main_season.id)
main_name = main_season.name
episode_group = Subscribe.exists(
oper._db, tmdbid=tmdbid, season=1, episode_group="eg-1",
)
created_ids.append(episode_group.id)
episode_group_name = episode_group.name
assert main_name == "主季订阅"
assert episode_group_name == "剧集组订阅"
Subscribe.delete(oper._db, rid=created_ids.pop(0))
assert Subscribe.exists(oper._db, tmdbid=tmdbid, season=1) is None
finally:
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_chain_exists_forwards_episode_group():
"""订阅前置存在性检查必须查询当前剧集组,不能退回主季范围。"""
from app.chain.subscribe import SubscribeChain
media = _media("eg-1")
meta = SimpleNamespace(begin_season=1)
with patch("app.chain.subscribe.SubscribeOper") as subscribe_oper_cls:
subscribe_oper_cls.return_value.exists.return_value = True
assert SubscribeChain.exists(media, meta) is True
subscribe_oper_cls.return_value.exists.assert_called_once_with(
tmdbid=media.tmdb_id,
doubanid=media.douban_id,
bangumiid=media.bangumi_id,
anilistid=media.anilist_id,
media_source="themoviedb",
media_id=str(media.tmdb_id),
season=1,
episode_group="eg-1",
)