Files
MoviePilot/tests/test_db_subscribe_queries.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

442 lines
17 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.
"""
订阅表与订阅历史表的查询行为。
订阅身份由「来源 + 原生 ID + 季 + 剧集组 + 音乐实体」五项共同确定,任意一项在查询里
丢失都会造成误判:判为已存在则新订阅被拒绝,判为不存在则同一部剧被重复订阅。
这些都不会抛异常,只能靠对真实数据的断言暴露。
"""
import asyncio
import time as _time
import pytest
from app.db import base as db_base
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.db.oper.subscribe import SubscribeOper
from app.db.session import async_session_scope
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 = db.run_async_session(
lambda session: Subscribe.async_exists(
session, media_source=MediaSource.TMDB, media_id="9001", season=1
)
)
assert sync_found.id == async_found.id
def test_subscribe_oper_filters_paginates_and_counts_in_database(db):
"""
订阅 owner 与状态筛选必须先进入 SQL,再按同一条件计数和分页。
"""
first = db.add(_sub("分页一", media_id="page-1", username="alice", state="N"))
second = db.add(_sub("分页二", media_id="page-2", username="alice", state="N"))
db.add(
_sub("其他用户", media_id="page-3", username="bob", state="N"),
_sub("其他状态", media_id="page-4", username="alice", state="R"),
)
async def query(session):
"""
在同一异步会话中执行订阅计数和第二页查询。
"""
oper = SubscribeOper(session)
total = await oper.async_count(state="N", username="alice")
rows = await oper.async_list_by_username(
"alice",
state="N",
page=2,
count=1,
)
return total, [item.id for item in rows]
total, ids = db.run_async_session(query)
assert total == 2
assert ids == [second.id]
assert first.id < second.id
def test_history_queries_reuse_explicit_sessions(db, monkeypatch):
"""
订阅历史同步/异步查询必须复用调用方会话。
"""
row = db.add(_history("显式历史", media_id="8501"))
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
)
assert SubscribeHistory.list_by_type(
db.session, MediaType.TV.value, page=1, count=10
)[0].id == row.id
assert SubscribeHistory.exists(
db.session, MediaSource.TMDB, "8501", season=1
).id == row.id
async def check() -> None:
"""
验证异步订阅历史查询复用显式 AsyncSession。
"""
async with async_session_scope() as session:
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
)
assert await SubscribeHistory.async_list_by_type(
session, MediaType.TV.value, page=1, count=10
)
assert await SubscribeHistory.async_list_by_type_and_username(
session, MediaType.TV.value, "alice", page=1, count=10
)
assert await SubscribeHistory.async_count_by_type(
session,
MediaType.TV.value,
) == 1
assert await SubscribeHistory.async_count_by_type_and_username(
session,
MediaType.TV.value,
"alice",
) == 1
assert await SubscribeHistory.async_count_by_type_and_username(
session,
MediaType.TV.value,
"bob",
) == 0
assert await SubscribeHistory.async_exists(
session, MediaSource.TMDB, "8501", season=1
) is not None
asyncio.run(check())
@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 db.run_async_session(
lambda session: Subscribe.async_get_by_state(session, 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 db.run_async_session(
lambda session: Subscribe.async_list_by_username(
session, 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 db.run_async_session(
lambda session: Subscribe.async_list_by_type(
session, mtype=MediaType.TV.value, days=7
)
)}
assert "窗口起点上" in names and "窗口起点前一秒" not in names
assert "窗口起点上" in async_names and "窗口起点前一秒" not in async_names
# --------------------------------------------------------------------------- #
# 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 db.run_async_session(
lambda session: SubscribeHistory.async_list_by_type(
session, 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 = db.run_async_session(
lambda session: SubscribeHistory.async_exists(
session,
media_source=MediaSource.TMDB,
media_id="8401",
season=1,
)
)
assert sync_found.id == async_found.id