Files
MoviePilot/tests/test_subscribe_write_path.py

423 lines
16 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.
"""
订阅的写入路径app/application/subscribe.py 的 add_subscribe / async_add_subscribe。
这两个函数是订阅表的唯一写入口,把 MediaInfo / MusicInfo 翻译成一行订阅:
标题、年份、类型、海报背景、评分简介、剧集组、音乐实体与曲目数,再叠上
持久化类型强转(布尔开关转整型、年份转字符串)。字段映射错了不会报错,
只会让订阅静静地记错——而搜索、洗版、完成判定、去重全都读这张表。
因此这里断言的是「落库后每个字段的实际值」,不是「调用了什么」:
同目录的 test_subscribe_oper.py 用替身钉的是查重语义(谁被查、查几次),
证明不了写进去的到底是什么。两者互补,缺一不可。
唯一的例外是**持久化类型强转**(年份转字符串、布尔开关转整型)。这两步是
为 PostgreSQL 的严格类型检查而存在的,而测试库是 SQLite——SQLite 的类型
亲和会在写入时把 ``2003`` 悄悄转成 ``'2003'``、把 ``True`` 转成 ``1``
落库后的值对「写入路径自己有没有转」完全无感(已用变异验证确认:删掉
``_normalize_year`` 后按落库值断言的用例全部照过)。所以这两类契约必须在
建模那一刻、即 ``Subscribe(**kwargs)`` 的入参上断言,见 ``payloads`` 夹具。
强转跟着订阅表的列走,因此仍留在 ``app/db/oper/subscribe.py``,夹具也就
仍然钉在那个模块的 ``Subscribe`` 上。
同步与异步两条链路是两份逐字复制的实现,任何一条改了另一条没跟上都属于
真实缺陷,故每个字段契约都在两条链路上各断言一次。
"""
import asyncio
import pytest
from app.application.subscribe import add_subscribe, async_add_subscribe
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
from app.domain.context import MediaInfo, MusicInfo
from app.schemas.types import MediaSource, MediaType
@pytest.fixture(autouse=True)
def _track(db):
"""把订阅表纳入用例级回收。"""
db.watermark(Subscribe)
def _media_id(tag: str) -> str:
"""给每个用例分配独立媒体 ID避免共用测试库时互相去重。"""
return f"wp-{tag}"
def _mediainfo(media_id: str, title: str = "识别标题",
mtype: MediaType = MediaType.TV, year: str = "2026",
episode_group: str = None) -> MediaInfo:
"""构造带完整展示字段的识别结果。"""
media = MediaInfo()
media.type = mtype
media.title = title
media.year = year
media.media_source = MediaSource.TMDB
media.media_id = media_id
media.episode_group = episode_group
media.vote_average = 8.5
media.overview = "测试简介"
media.poster_path = "https://image.tmdb.org/t/p/original/poster.jpg"
media.backdrop_path = "https://image.tmdb.org/t/p/original/backdrop.jpg"
return media
def _musicinfo(media_id: str, music_type: str, **kwargs) -> MusicInfo:
"""构造音乐订阅所需的标准音乐信息。"""
return MusicInfo(media_source=MediaSource.MusicBrainz, media_id=media_id,
music_type=music_type, **kwargs)
def _add(oper: SubscribeOper, is_async: bool, **kwargs):
"""按链路分派到同步或异步新增,让同一份字段契约跑两遍。"""
if is_async:
return asyncio.run(async_add_subscribe(subscribe_oper=oper, **kwargs))
return add_subscribe(subscribe_oper=oper, **kwargs)
def _row(db, subscribe_id: int) -> Subscribe:
"""按主键读回落库的订阅行。"""
db.session.expire_all()
return Subscribe.get(db.session, subscribe_id)
class _SubscribeSpy:
"""记录建模入参并转交真实模型,保持写入路径仍然真的落库。"""
def __init__(self, recorded: list):
self._recorded = recorded
def __call__(self, **kwargs):
"""截获 ``Subscribe(**kwargs)`` 的入参后构造真实模型实例。"""
self._recorded.append(dict(kwargs))
return Subscribe(**kwargs)
def __getattr__(self, name):
"""查重用的类方法exists / async_exists 等)原样透传给真实模型。"""
return getattr(Subscribe, name)
@pytest.fixture
def payloads(monkeypatch):
"""
捕获写入路径建模时的原始 kwargs即落库**前**的值与类型。
只用于持久化类型强转这一类契约SQLite 的类型亲和会在写入时替写入路径
把类型「修好」,落库后的值证明不了转换真的发生过;而这两步转换恰恰是为
PostgreSQL 而写的,漏了只会在生产库上炸。
"""
recorded: list = []
monkeypatch.setattr("app.db.oper.subscribe.Subscribe", _SubscribeSpy(recorded))
return recorded
# 每个字段契约都在同步与异步两条链路上跑一遍:两份实现是逐字复制的,
# 只测一条等于放任另一条漂移
_BOTH_PATHS = pytest.mark.parametrize(
"is_async", [pytest.param(False, id="sync"), pytest.param(True, id="async")]
)
# --------------------------------------------------------------------------- #
# 展示字段的翻译
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_maps_every_display_field_onto_the_row(db, is_async):
"""
识别结果的展示字段必须完整落库。
标题、年份、类型、评分、简介都是订阅列表和通知的唯一数据来源,
错一项用户就看到一条张冠李戴的订阅。
"""
oper = SubscribeOper()
media_id = _media_id(f"display-{is_async}")
sid, message = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)
assert message == "新增订阅成功"
row = _row(db, sid)
assert row.name == "识别标题"
assert row.year == "2026"
assert row.type == MediaType.TV.value
assert row.media_source == MediaSource.TMDB.value
assert row.media_id == media_id
assert row.season == 1
assert row.vote == 8.5
assert row.description == "测试简介"
@_BOTH_PATHS
def test_add_persists_poster_and_backdrop_from_media(db, is_async):
"""
海报与背景取自识别结果的图片接口,而不是原始路径字段。
接口会把 original 尺寸换成 w500直接存 poster_path 会让列表页
每张卡片都去拉原图。
"""
oper = SubscribeOper()
media_id = _media_id(f"image-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1)
row = _row(db, sid)
assert row.poster == "https://image.tmdb.org/t/p/w500/poster.jpg"
assert row.backdrop == "https://image.tmdb.org/t/p/w500/backdrop.jpg"
@_BOTH_PATHS
def test_add_persists_episode_group(db, is_async):
"""
剧集组必须来自识别结果并落库。
订阅按剧集组去重、搜索也按剧集组匹配集数,丢了它主季与自定义组会互相顶替。
"""
oper = SubscribeOper()
media_id = _media_id(f"eg-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_mediainfo(media_id, episode_group="eg-1"), season=1)
assert _row(db, sid).episode_group == "eg-1"
@_BOTH_PATHS
def test_add_stamps_creation_date(db, is_async):
"""
新增时间由写入路径盖戳,调用方传入的值不作数。
订阅列表默认按 date 排序、过期清理也读它,留空会让这条订阅永远排在最后。
"""
oper = SubscribeOper()
media_id = _media_id(f"date-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
date="1970-01-01 00:00:00")
date = _row(db, sid).date
assert date is not None
assert date != "1970-01-01 00:00:00"
# 形如 2026-08-14 12:34:56
assert len(date) == 19 and date[4] == "-" and date[13] == ":"
# --------------------------------------------------------------------------- #
# 持久化类型强转
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_converts_boolean_flags_to_integers(db, payloads, is_async):
"""
历史兼容的布尔开关建模前必须转成整型。
PostgreSQL 的整型列拒收布尔值,不转会让新增订阅在 PG 上直接抛类型错误。
断言落在建模入参上而非落库值SQLite 会替我们把 True 存成 1
按落库值断言的话删掉转换也照样通过。
"""
oper = SubscribeOper()
media_id = _media_id(f"flags-{is_async}")
_add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
best_version=True, best_version_full=False, manual_total_episode=True)
payload = payloads[-1]
for field, expected in (("best_version", 1), ("best_version_full", 0),
("manual_total_episode", 1)):
assert payload[field] == expected
assert type(payload[field]) is int, f"{field} 仍是 {type(payload[field])}"
@_BOTH_PATHS
@pytest.mark.parametrize(
"supplied, expected",
[
pytest.param(True, 1, id=""),
pytest.param(False, 0, id=""),
pytest.param(None, 0, id="缺省"),
],
)
def test_add_normalizes_search_imdbid_to_zero_or_one(db, payloads, is_async,
supplied, expected):
"""
search_imdbid 无论传什么都归一到整型 0/1。
这一列参与搜索分支判定,存进 None 或 True 会让「是否用 imdbid 搜」
在不同订阅上表现不一致,在 PG 上还会直接拒写。
"""
oper = SubscribeOper()
media_id = _media_id(f"imdb-{is_async}-{supplied}")
_add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
search_imdbid=supplied)
payload = payloads[-1]
assert payload["search_imdbid"] == expected
assert type(payload["search_imdbid"]) is int
@_BOTH_PATHS
def test_add_converts_numeric_year_to_string(db, payloads, is_async):
"""
音乐链路的年份是数字,而 year 列是字符串,建模前必须转换。
不转在 PostgreSQL 上直接抛类型错误。同样只能在建模入参上验证:
SQLite 的 TEXT 亲和会把整数 2003 自动存成 '2003',读回来看不出差别。
"""
oper = SubscribeOper()
media_id = _media_id(f"year-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="叶惠美", year=2003))
assert payloads[-1]["year"] == "2003"
assert type(payloads[-1]["year"]) is str
# 落库值也要对得上,转换不能只发生在建模而在写入时被改回去
assert _row(db, sid).year == "2003"
@_BOTH_PATHS
def test_add_keeps_missing_year_as_null(db, payloads, is_async):
"""
年份缺失时留空,不能变成字符串 "None"
转字符串离无脑 str() 只有一步之遥,写成 "None" 后年份筛选会命中一个
不存在的年份,而这条订阅从此在按年份筛选的界面里凭空消失。
"""
oper = SubscribeOper()
media_id = _media_id(f"noyear-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="无年份专辑"))
assert payloads[-1]["year"] is None
assert _row(db, sid).year is None
# --------------------------------------------------------------------------- #
# 音乐字段
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_persists_album_entity_and_track_count(db, is_async):
"""
专辑订阅要落实体类型和总曲目数。
整专完成判定拿 total_tracks 当分母,缺了这条订阅永远判不到完成。
"""
oper = SubscribeOper()
media_id = _media_id(f"album-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="叶惠美", total_tracks=11))
row = _row(db, sid)
assert row.type == MediaType.MUSIC.value
assert row.music_type == "album"
assert row.total_tracks == 11
@_BOTH_PATHS
def test_add_drops_track_count_for_single_recording(db, is_async):
"""
单曲订阅只留实体类型,专辑曲目数必须丢弃。
单曲带着专辑的 total_tracks 会让完成判定把一首歌当整专等,永远不完成。
"""
oper = SubscribeOper()
media_id = _media_id(f"recording-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "recording", title="晴天", total_tracks=11))
row = _row(db, sid)
assert row.music_type == "recording"
assert row.total_tracks is None
@_BOTH_PATHS
def test_add_clears_music_fields_for_non_music_media(db, is_async):
"""
非音乐媒体的音乐字段一律置空,调用方传进来的也要被覆盖。
影视订阅带上 music_type 会被音乐去重逻辑当成音乐实体,造成串号。
"""
oper = SubscribeOper()
media_id = _media_id(f"nonmusic-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
music_type="album", total_tracks=99)
row = _row(db, sid)
assert row.music_type is None
assert row.total_tracks is None
@_BOTH_PATHS
def test_add_persists_music_cover_as_poster_and_backdrop(db, is_async):
"""
音乐订阅的海报与背景都用发行封面。
音乐没有独立背景图,留空会让订阅卡片在列表里显示成一块空白。
"""
oper = SubscribeOper()
media_id = _media_id(f"cover-{is_async}")
cover = "https://coverartarchive.org/release-group/example/front-500"
sid, _ = _add(oper, is_async,
mediainfo=_musicinfo(media_id, "album", title="封面专辑", cover_url=cover))
row = _row(db, sid)
assert row.poster == cover
assert row.backdrop == cover
# --------------------------------------------------------------------------- #
# 调用方字段的保留
# --------------------------------------------------------------------------- #
@_BOTH_PATHS
def test_add_keeps_caller_supplied_subscription_settings(db, is_async):
"""
调用方传入的订阅设置要原样保留,不被媒体翻译覆盖。
写入路径只负责翻译媒体身份与展示字段;把用户填的保存路径、过滤词、
总集数一并覆盖掉,等于用户每次新增订阅的设置都白填。
"""
oper = SubscribeOper()
media_id = _media_id(f"settings-{is_async}")
sid, _ = _add(oper, is_async, mediainfo=_mediainfo(media_id), season=1,
username="alice", save_path="/media/tv", keyword="关键字",
include="内嵌", exclude="预告", total_episode=12,
start_episode=3, downloader="qbittorrent", state="R")
row = _row(db, sid)
assert row.username == "alice"
assert row.save_path == "/media/tv"
assert row.keyword == "关键字"
assert (row.include, row.exclude) == ("内嵌", "预告")
assert (row.total_episode, row.start_episode) == (12, 3)
assert row.downloader == "qbittorrent"
assert row.state == "R"
@_BOTH_PATHS
def test_add_overrides_caller_supplied_media_fields(db, is_async):
"""
媒体身份与展示字段以识别结果为准,调用方传的同名值要被覆盖。
否则上游一个陈旧的 name/year 就能让订阅记成另一部剧,而去重按身份走、
发现不了这种错位。
"""
oper = SubscribeOper()
media_id = _media_id(f"override-{is_async}")
sid, _ = _add(oper, is_async,
mediainfo=_mediainfo(media_id, title="正确标题", year="2026"),
season=1, name="错误标题", year="1999", type="电影")
row = _row(db, sid)
assert row.name == "正确标题"
assert row.year == "2026"
assert row.type == MediaType.TV.value