Files
MoviePilot/tests/test_subscribe_oper.py

531 lines
19 KiB
Python
Raw 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.
import asyncio
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.application.subscribe import add_subscribe, async_add_subscribe
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.oper.subscribe import SubscribeOper
from app.domain.context import MusicInfo
from app.schemas.types import MediaSource, MediaType
def _add(**kwargs):
"""
经应用层写入路径新增订阅。
媒体翻译住在 app/application/subscribe.py查重与落库仍在 SubscribeOper——本文件
钉的是查重语义(谁被查、查几次、带哪些身份字段),所以从翻译入口进、把不带真会话
的 Oper 注进去,两层的契约一次跑通。
"""
return add_subscribe(subscribe_oper=SubscribeOper(db=object()), **kwargs)
async def _async_add(**kwargs):
"""异步写入路径,与 _add 共用注入方式。"""
return await async_add_subscribe(subscribe_oper=SubscribeOper(db=object()), **kwargs)
def _media(episode_group):
"""构造订阅新增路径所需的稳定 MediaInfo 契约替身。"""
return SimpleNamespace(
title="测试剧",
year="2026",
type=MediaType.TV,
media_source=MediaSource.TMDB,
media_id="987654321",
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):
"""
写入订阅历史前应把布尔开关转为整型,兼容 PostgreSQL 的严格类型检查。
"""
captured = {}
def fake_create(self, _db):
"""
截获待写入模型,避免测试依赖具体数据库方言的类型宽松行为。
"""
captured.update({
"id": self.id,
"best_version": self.best_version,
"best_version_full": self.best_version_full,
"search_imdbid": self.search_imdbid,
})
monkeypatch.setattr(SubscribeHistory, "create", fake_create)
SubscribeOper().add_history(
id=100,
name="Test Movie",
type="电影",
best_version=False,
best_version_full=True,
search_imdbid=False,
unknown_field=True,
)
assert captured == {
"id": None,
"best_version": 0,
"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.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, message = _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()
# 媒体身份的三种残缺形态。守卫写的是 ``not media_source or not media_id``——只测「两者都空」
# 时 ``or`` 与 ``and`` 表现一致,必须把「只缺一半」的两种也测到,否则守卫被改宽也没人知道。
# 注意:真实的 resolve_media_identity 只会返回「两个都有」或「两个都空」,构造不出半残身份,
# 所以这里必须替换掉它才能把守卫本身的契约钉住。
_INCOMPLETE_IDENTITIES = [
pytest.param((None, "987654321"), id="缺来源"),
pytest.param((MediaSource.TMDB, None), id="缺原生ID"),
pytest.param((None, None), id="两者皆缺"),
]
@pytest.mark.parametrize("identity", _INCOMPLETE_IDENTITIES)
def test_add_rejects_incomplete_media_identity(identity):
"""
媒体身份只要缺一半就必须拒绝新增,且不得落库。
身份不全的订阅写进去就是一条永远匹配不上资源的僵尸订阅,后续按身份去重也会失效。
"""
with patch("app.application.subscribe.resolve_media_identity", return_value=identity), \
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
result = _add(mediainfo=_media(None), season=1)
assert result == (0, "媒体身份不完整")
# 守卫必须在查询与建模之前短路,而不是先写进去再补救
subscribe_model.exists.assert_not_called()
subscribe_model.assert_not_called()
@pytest.mark.parametrize("identity", _INCOMPLETE_IDENTITIES)
def test_async_add_rejects_incomplete_media_identity(identity):
"""异步新增与同步路径共用同一道身份守卫,两条链路不能一宽一严。"""
with patch("app.application.subscribe.resolve_media_identity", return_value=identity), \
patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock()
result = asyncio.run(_async_add(
mediainfo=_media(None), season=1))
assert result == (0, "媒体身份不完整")
subscribe_model.async_exists.assert_not_awaited()
subscribe_model.assert_not_called()
def test_add_reports_failure_when_the_new_subscribe_cannot_be_read_back():
"""
创建后回查落空必须如实报「新增订阅失败」,不能把落空当成功返回。
回查落空意味着写入实际没生效(唯一约束冲突、事务回滚等);此时若返回成功,
调用方会继续按订阅已建立往下走,用户看到「订阅成功」却永远等不到资源。
"""
created = SimpleNamespace(create=MagicMock())
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, None]
subscribe_model.return_value = created
result = _add(mediainfo=_media(None), season=1)
assert result == (0, "新增订阅失败")
created.create.assert_called_once()
def test_async_add_reports_failure_when_the_new_subscribe_cannot_be_read_back():
"""异步新增的回查落空路径与同步一致。"""
created = SimpleNamespace(async_create=AsyncMock())
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(side_effect=[None, None])
subscribe_model.return_value = created
result = asyncio.run(_async_add(
mediainfo=_media(None), season=1))
assert result == (0, "新增订阅失败")
created.async_create.assert_awaited_once()
def test_add_reports_existing_subscription_without_creating():
"""
首次查询即命中时返回既有订阅,不再建第二条。
重复建订阅会让同一部剧被两条订阅并行搜索、重复下载。
"""
existing = SimpleNamespace(id=77)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.return_value = existing
result = _add(mediainfo=_media(None), season=1)
assert result == (77, "订阅已存在")
assert subscribe_model.exists.call_count == 1
subscribe_model.assert_not_called()
def test_async_add_reports_existing_subscription_without_creating():
"""异步新增命中既有订阅时同样不建第二条。"""
existing = SimpleNamespace(id=78)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(return_value=existing)
result = asyncio.run(_async_add(
mediainfo=_media(None), season=1))
assert result == (78, "订阅已存在")
assert subscribe_model.async_exists.await_count == 1
subscribe_model.assert_not_called()
def test_music_subscribe_persists_release_cover_as_poster_and_backdrop():
"""音乐订阅应把 MusicBrainz 发行封面写入订阅海报和背景字段。"""
persisted = SimpleNamespace(id=92)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
media_source=MediaSource.MusicBrainz,
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
title="晴天",
cover_url="https://coverartarchive.org/release-group/example/front-500",
)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = _add(mediainfo=media, season=None)
assert sid == 92
payload = subscribe_model.call_args.kwargs
assert payload["poster"] == media.cover_url
assert payload["backdrop"] == media.cover_url
def test_music_subscribe_persists_numeric_year_as_string():
"""音乐识别链路的年份可能是数字,写库前必须转字符串避免 PostgreSQL 类型错误。"""
persisted = SimpleNamespace(id=93)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
media_source=MediaSource.MusicBrainz,
media_id="2af54891-e954-40e8-8b90-b7e98c740f21",
title="心愿",
year=2025,
cover_url="https://coverartarchive.org/release/example/front-500",
)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = _add(mediainfo=media, season=None)
assert sid == 93
payload = subscribe_model.call_args.kwargs
assert payload["year"] == "2025"
def test_music_album_subscription_persists_entity_and_track_count():
"""专辑订阅必须保存实体类型和总曲目数,供搜索校验与完成判定复用。"""
persisted = SimpleNamespace(id=94)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
media_source=MediaSource.MusicBrainz,
media_id="release-group-1",
music_type="album",
title="叶惠美",
album="叶惠美",
total_tracks=11,
)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = _add(mediainfo=media, season=None)
assert sid == 94
payload = subscribe_model.call_args.kwargs
assert payload["music_type"] == "album"
assert payload["total_tracks"] == 11
def test_music_recording_subscription_drops_album_track_count_and_scopes_identity():
"""单曲只持久化实体类型,重复查询也必须携带实体,不能与专辑身份串用。"""
persisted = SimpleNamespace(id=95)
created = SimpleNamespace(create=MagicMock())
media = MusicInfo(
media_source=MediaSource.MusicBrainz,
media_id="recording-1",
music_type="recording",
title="晴天",
album="叶惠美",
total_tracks=11,
)
with patch("app.db.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.side_effect = [None, persisted]
subscribe_model.return_value = created
sid, _ = _add(mediainfo=media, season=None)
assert sid == 95
payload = subscribe_model.call_args.kwargs
assert payload["music_type"] == "recording"
assert payload["total_tracks"] is None
assert all(
call.kwargs["music_type"] == "recording"
for call in subscribe_model.exists.call_args_list
)
@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.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.async_exists = AsyncMock(side_effect=[None, persisted])
subscribe_model.return_value = created
sid, message = asyncio.run(_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.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists_by_username.side_effect = [None, sync_persisted]
subscribe_model.return_value = sync_created
sid, _ = _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.oper.subscribe.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(_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.oper.subscribe.Subscribe") as subscribe_model:
subscribe_model.exists.return_value = SimpleNamespace(id=1)
assert oper.exists(
media_source=MediaSource.TMDB, media_id="100", season=1
) is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exists(
media_source=MediaSource.TMDB,
media_id="100",
season=1,
episode_group="eg-1",
) is True
assert subscribe_model.exists.call_args.kwargs["episode_group"] == "eg-1"
with patch("app.db.oper.subscribe.SubscribeHistory") as history_model:
history_model.exists.return_value = SimpleNamespace(id=2)
assert oper.exist_history(
media_source=MediaSource.TMDB, media_id="100", season=1
) is True
assert history_model.exists.call_args.kwargs["episode_group"] is None
assert oper.exist_history(
media_source=MediaSource.TMDB,
media_id="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()
media_id = str(-(900_000_000 + os.getpid()))
created_ids = []
rows = [
Subscribe(name="主季订阅", type=MediaType.TV.value, state="N",
media_source=MediaSource.TMDB.value, media_id=media_id,
season=1, episode_group=None),
Subscribe(name="剧集组订阅", type=MediaType.TV.value, state="N",
media_source=MediaSource.TMDB.value, media_id=media_id,
season=1, episode_group="eg-1"),
]
try:
for row in rows:
row.create(oper._db)
main_season = Subscribe.exists(
oper._db, media_source=MediaSource.TMDB,
media_id=media_id, season=1, episode_group=None,
)
created_ids.append(main_season.id)
main_name = main_season.name
episode_group = Subscribe.exists(
oper._db, media_source=MediaSource.TMDB,
media_id=media_id, 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,
media_source=MediaSource.TMDB,
media_id=media_id,
season=1,
) is None
finally:
for subscribe_id in created_ids:
Subscribe.delete(oper._db, rid=subscribe_id)
def test_subscribe_exists_distinguishes_music_entities_with_same_source_id():
"""统一来源 ID 相同时,单曲与专辑仍是两条独立订阅身份。"""
oper = SubscribeOper()
media_id = f"music-shared-{os.getpid()}"
created_ids = []
rows = [
Subscribe(
name="同名单曲",
type=MediaType.MUSIC.value,
state="N",
media_source="musicbrainz",
media_id=media_id,
music_type="recording",
),
Subscribe(
name="同名专辑",
type=MediaType.MUSIC.value,
state="N",
media_source="musicbrainz",
media_id=media_id,
music_type="album",
total_tracks=10,
),
]
try:
for row in rows:
row.create(oper._db)
recording = Subscribe.exists(
oper._db,
media_source="musicbrainz",
media_id=media_id,
music_type="recording",
)
created_ids.append(recording.id)
album = Subscribe.exists(
oper._db,
media_source="musicbrainz",
media_id=media_id,
music_type="album",
)
created_ids.append(album.id)
assert recording.name == "同名单曲"
assert album.name == "同名专辑"
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(
media_source=MediaSource.TMDB,
media_id=media.media_id,
music_type=None,
season=1,
episode_group="eg-1",
)