Files
MoviePilot/tests/test_db_subscribe_queries.py
T

338 lines
14 KiB
Python

"""
订阅表与订阅历史表的查询行为。
订阅身份由「来源 + 原生 ID + 季 + 剧集组 + 音乐实体」五项共同确定,任意一项在查询里
丢失都会造成误判:判为已存在则新订阅被拒绝,判为不存在则同一部剧被重复订阅。
这些都不会抛异常,只能靠对真实数据的断言暴露。
"""
import asyncio
import time as _time
import pytest
from app.db.models import subscribe as subscribe_module
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.schemas.types import MediaSource, MediaType
TMDB = str(MediaSource.TMDB)
@pytest.fixture(autouse=True)
def _track(db):
"""把订阅与订阅历史表纳入用例级回收。"""
db.watermark(Subscribe, SubscribeHistory)
def _sub(name: str, media_id: str = "9001", season: int = 1, episode_group: str = None,
state: str = "N", username: str = "alice", mtype: str = None,
music_type: str = None, date: str = "2026-08-13 10:00:00") -> Subscribe:
"""构造一条订阅记录。"""
return Subscribe(name=name, type=mtype or MediaType.TV.value, state=state,
media_source=TMDB, media_id=media_id, season=season,
episode_group=episode_group, username=username,
music_type=music_type, date=date)
# --------------------------------------------------------------------------- #
# Subscribe:身份查询
# --------------------------------------------------------------------------- #
def test_exists_distinguishes_season_and_episode_group(db):
"""
同一媒体的不同季、不同剧集组各自是独立订阅身份。
剧集组条件丢失时,主季订阅会命中自定义剧集组的订阅,用户再也加不上第二个组。
"""
db.add(_sub("主季", season=1, episode_group=None),
_sub("剧集组", season=1, episode_group="eg-1"),
_sub("第二季", season=2, episode_group=None))
assert Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=1,
episode_group=None).name == "主季"
assert Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=1,
episode_group="eg-1").name == "剧集组"
assert Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=2,
episode_group=None).name == "第二季"
assert Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=3,
episode_group=None) is None
def test_exists_matches_async_twin(db):
"""
同步与异步的身份判定必须一致,否则 API 与调度任务对「是否已订阅」意见相左。
"""
db.add(_sub("并行", season=1))
sync_found = Subscribe.exists(db.session, MediaSource.TMDB, "9001", season=1)
async_found = asyncio.run(Subscribe.async_exists(
media_source=MediaSource.TMDB, media_id="9001", season=1))
assert sync_found.id == async_found.id
@pytest.mark.parametrize("media_id", [None, "", " "])
def test_exists_rejects_blank_media_id(db, media_id):
"""
媒体 ID 为空时直接返回 None。
否则条件退化,任意一条订阅都会被当成命中,新订阅全部被拒。
"""
db.add(_sub("有订阅"))
assert Subscribe.exists(db.session, MediaSource.TMDB, media_id, season=1) is None
def test_exists_treats_recording_as_matching_null_music_type(db):
"""
单曲订阅要兼容历史上未写 music_type 的行。
老数据的 music_type 为空,若严格相等匹配会被判为不存在,用户会重复订阅同一首歌。
"""
db.add(_sub("老单曲", media_id="mb-1", season=None, music_type=None,
mtype=MediaType.MUSIC.value))
found = Subscribe.exists(db.session, MediaSource.TMDB, "mb-1", music_type="recording")
assert found is not None and found.name == "老单曲"
def test_exists_by_username_scopes_to_owner(db):
"""
按 owner 查询必须限定用户名,且用户名为空时直接返回 None。
"""
db.add(_sub("alice 的", username="alice"), _sub("bob 的", username="bob", media_id="9002"))
assert Subscribe.exists_by_username(db.session, "alice", MediaSource.TMDB,
"9001", season=1).name == "alice 的"
assert Subscribe.exists_by_username(db.session, "bob", MediaSource.TMDB,
"9001", season=1) is None
assert Subscribe.exists_by_username(db.session, "", MediaSource.TMDB,
"9001", season=1) is None
def test_get_by_narrows_with_type_and_optional_season(db):
"""
按类型查询时类型必须参与匹配,季号可选但给出即须生效。
"""
db.add(_sub("剧集", mtype=MediaType.TV.value, season=1),
_sub("电影", mtype=MediaType.MOVIE.value, season=1, media_id="9003"))
assert Subscribe.get_by(db.session, MediaType.TV.value, MediaSource.TMDB,
"9001").name == "剧集"
assert Subscribe.get_by(db.session, MediaType.MOVIE.value, MediaSource.TMDB,
"9001") is None
assert Subscribe.get_by(db.session, MediaType.TV.value, MediaSource.TMDB,
"9001", season=2) is None
def test_list_by_media_identity_returns_all_seasons(db):
"""
按媒体身份列举会跨季返回全部订阅,空身份则短路成空列表。
"""
db.add(_sub("第一季", season=1), _sub("第二季", season=2),
_sub("别的剧", media_id="9009"))
listed = Subscribe.list_by_media_identity(db.session, MediaSource.TMDB, "9001")
assert {s.season for s in listed} == {1, 2}
assert Subscribe.list_by_media_identity(db.session, MediaSource.TMDB, "") == []
# --------------------------------------------------------------------------- #
# Subscribe:列表查询
# --------------------------------------------------------------------------- #
def test_get_by_state_splits_comma_separated_states(db):
"""
状态支持逗号分隔的多值,为空时返回全部。
订阅刷新按状态取任务,多值解析失效会让一部分订阅永远不被处理。
"""
db.add(_sub("待订阅", state="N"), _sub("订阅中", state="R", media_id="9004"),
_sub("已完成", state="P", media_id="9005"))
states = {s.state for s in Subscribe.get_by_state(db.session, "N,R")}
assert states == {"N", "R"}
assert len(Subscribe.get_by_state(db.session, "")) >= 3
assert {s.state for s in asyncio.run(Subscribe.async_get_by_state(state="N,R"))} == {"N", "R"}
def test_get_by_title_optionally_narrows_by_season(db):
"""
按标题查询时季号可选,给出即须生效。
"""
db.add(_sub("同名剧", season=1), _sub("同名剧", season=2))
assert Subscribe.get_by_title(db.session, "同名剧", season=2).season == 2
assert Subscribe.get_by_title(db.session, "同名剧") is not None
assert Subscribe.get_by_title(db.session, "不存在的剧") is None
@pytest.mark.parametrize("state,mtype,expected", [
(None, None, {"剧-N", "剧-R", "影-N"}),
("N", None, {"剧-N", "影-N"}),
(None, MediaType.TV.value, {"剧-N", "剧-R"}),
("N", MediaType.TV.value, {"剧-N"}),
])
def test_list_by_username_covers_all_filter_combinations(db, state, mtype, expected):
"""
按 owner 列举的四种「状态 × 类型」组合都必须正确收窄。
这四条分支是「我的订阅」页面的全部筛选路径,任何一条串了都会展示别人的订阅
或漏掉自己的。
"""
db.add(_sub("剧-N", state="N", mtype=MediaType.TV.value, media_id="9101"),
_sub("剧-R", state="R", mtype=MediaType.TV.value, media_id="9102"),
_sub("影-N", state="N", mtype=MediaType.MOVIE.value, media_id="9103"),
_sub("别人的", state="N", mtype=MediaType.TV.value, media_id="9104",
username="bob"))
listed = Subscribe.list_by_username(db.session, "alice", state=state, mtype=mtype)
assert {s.name for s in listed} == expected
def test_list_by_username_matches_async_twin(db):
"""
四种筛选组合下同步与异步必须返回同一批订阅。
"""
db.add(_sub("剧-N", state="N", mtype=MediaType.TV.value, media_id="9201"),
_sub("影-R", state="R", mtype=MediaType.MOVIE.value, media_id="9202"))
for state, mtype in ((None, None), ("N", None), (None, MediaType.TV.value),
("N", MediaType.TV.value)):
sync_names = sorted(s.name for s in
Subscribe.list_by_username(db.session, "alice", state, mtype))
async_names = sorted(s.name for s in asyncio.run(
Subscribe.async_list_by_username(username="alice", state=state, mtype=mtype)))
assert sync_names == async_names
def test_list_by_type_only_returns_recent_days(db):
"""
按类型取最近 N 天的订阅,超出窗口的不返回。
时间窗口失效会让「最近订阅」把历史全量拉出来,首页直接卡死。
"""
db.add(_sub("最近", mtype=MediaType.TV.value, media_id="9301",
date="2099-01-01 00:00:00"),
_sub("很久以前", mtype=MediaType.TV.value, media_id="9302",
date="2000-01-01 00:00:00"))
names = {s.name for s in Subscribe.list_by_type(db.session, MediaType.TV.value, days=7)}
assert "最近" in names
assert "很久以前" not in names
def test_list_by_type_includes_the_window_start_boundary(db, frozen_now):
"""
时间窗是闭区间起点(``date >= 起点``),正好落在起点的订阅必须在结果里,同步异步一致。
起点由「调用时刻 - N 天」现算,不冻结时钟就摆不到边界上;上面那条用例用的是
2099/2000 两个极端值,比较符改成 ``>`` 照样绿。
"""
now = frozen_now(subscribe_module)
window_start = _time.strftime("%Y-%m-%d %H:%M:%S", _time.localtime(now - 86400 * 7))
one_second_earlier = _time.strftime("%Y-%m-%d %H:%M:%S",
_time.localtime(now - 86400 * 7 - 1))
db.add(_sub("窗口起点上", mtype=MediaType.TV.value, media_id="9303", date=window_start),
_sub("窗口起点前一秒", mtype=MediaType.TV.value, media_id="9304",
date=one_second_earlier))
names = {s.name for s in Subscribe.list_by_type(db.session, MediaType.TV.value, days=7)}
async_names = {s.name for s in asyncio.run(
Subscribe.async_list_by_type(mtype=MediaType.TV.value, days=7))}
assert "窗口起点上" in names and "窗口起点前一秒" not in names
assert "窗口起点上" in async_names and "窗口起点前一秒" not in async_names
def test_delete_by_media_identity_removes_matching_seasons_only(db):
"""
按媒体身份删除时,给出季号只删该季,不给则删全部季。
"""
db.add(_sub("第一季", season=1), _sub("第二季", season=2))
Subscribe().delete_by_media_identity(db.session, TMDB, "9001", season=1)
remaining = Subscribe.list_by_media_identity(db.session, MediaSource.TMDB, "9001")
assert [s.season for s in remaining] == [2]
Subscribe().delete_by_media_identity(db.session, TMDB, "9001")
assert Subscribe.list_by_media_identity(db.session, MediaSource.TMDB, "9001") == []
# --------------------------------------------------------------------------- #
# SubscribeHistory
# --------------------------------------------------------------------------- #
def _history(name: str, mtype: str = MediaType.TV.value, media_id: str = "8001",
season: int = 1, episode_group: str = None,
date: str = "2026-08-13 10:00:00", username: str = "alice") -> SubscribeHistory:
"""构造一条订阅历史记录。"""
return SubscribeHistory(name=name, type=mtype, media_source=TMDB, media_id=media_id,
season=season, episode_group=episode_group, date=date,
username=username)
def test_history_list_by_type_is_newest_first_and_paged(db):
"""
历史按完成时间倒序分页,且只返回指定类型。
"""
db.add(_history("旧", date="2026-08-01 10:00:00", media_id="8101"),
_history("新", date="2026-08-12 10:00:00", media_id="8102"),
_history("电影", mtype=MediaType.MOVIE.value, media_id="8103"))
page = SubscribeHistory.list_by_type(db.session, MediaType.TV.value, page=1, count=10)
assert [h.name for h in page] == ["新", "旧"]
assert [h.name for h in SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=1)] == ["新"]
def test_history_list_by_type_matches_async_twin(db):
"""
同步与异步的历史分页必须返回同一批记录。
"""
db.add(_history("A", date="2026-08-12 10:00:00", media_id="8201"),
_history("B", date="2026-08-11 10:00:00", media_id="8202"))
sync_names = [h.name for h in SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=10)]
async_names = [h.name for h in asyncio.run(SubscribeHistory.async_list_by_type(
mtype=MediaType.TV.value, page=1, count=10))]
assert sync_names == async_names
def test_history_exists_distinguishes_episode_group(db):
"""
历史的存在性判定与订阅同规则:剧集组不同即为不同身份。
判错会让已完成的主季订阅挡住自定义剧集组的新订阅。
"""
db.add(_history("主季历史", season=1, episode_group=None, media_id="8301"),
_history("剧集组历史", season=1, episode_group="eg-1", media_id="8301"))
assert SubscribeHistory.exists(db.session, MediaSource.TMDB, "8301", season=1,
episode_group=None).name == "主季历史"
assert SubscribeHistory.exists(db.session, MediaSource.TMDB, "8301", season=1,
episode_group="eg-1").name == "剧集组历史"
assert SubscribeHistory.exists(db.session, MediaSource.TMDB, "", season=1) is None
def test_history_exists_matches_async_twin(db):
"""
历史存在性判定的同步与异步结果必须一致。
"""
db.add(_history("并行历史", season=1, media_id="8401"))
sync_found = SubscribeHistory.exists(db.session, MediaSource.TMDB, "8401", season=1)
async_found = asyncio.run(SubscribeHistory.async_exists(
media_source=MediaSource.TMDB, media_id="8401", season=1))
assert sync_found.id == async_found.id