Files
MoviePilot/tests/test_db_site_queries.py

281 lines
11 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.
"""
站点相关四张表的查询行为:站点、图标、访问统计、用户数据快照。
站点数据快照的 get_latest 是这里唯一带子查询与 JOIN 的查询——「每个站点取最新一天」
用普通过滤写不出来,改写时最容易退化成「全表按时间倒序取第一条」,那样多站点场景下
只会剩一个站点的数据,而页面不会报错,只是少了几行。
"""
import asyncio
import pytest
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.models.siteuserdata import SiteUserData
@pytest.fixture(autouse=True)
def _track(db):
"""把站点相关表纳入用例级回收。"""
db.watermark(Site, SiteIcon, SiteStatistic, SiteUserData)
def _site(name: str, domain: str, pri: int = 1, is_active: bool = True) -> Site:
"""构造一条站点记录。"""
return Site(name=name, domain=domain, url=f"https://{domain}/", pri=pri, is_active=is_active)
# --------------------------------------------------------------------------- #
# Site
# --------------------------------------------------------------------------- #
def test_site_get_by_domain_matches_async_twin(db):
"""
按域名取站点的同步、异步结果必须指向同一行。
"""
db.add(_site("站点A", "a.test"), _site("站点B", "b.test"))
assert Site.get_by_domain(db.session, "a.test").name == "站点A"
assert asyncio.run(Site.async_get_by_domain(domain="a.test")).name == "站点A"
assert asyncio.run(Site.async_get_by_name(name="站点B")).domain == "b.test"
def test_site_get_by_domain_returns_none_when_absent(db):
"""
域名不存在时返回 None调用方据此判断站点是否已配置。
"""
assert Site.get_by_domain(db.session, "missing.test") is None
def test_site_get_actives_excludes_disabled_sites(db):
"""
取启用站点必须排除已停用的。
停用站点仍被返回意味着它照样会被搜索和刷流访问,等于停用开关没生效。
"""
db.add(_site("启用1", "on1.test"), _site("启用2", "on2.test"),
_site("停用", "off.test", is_active=False))
assert {s.domain for s in Site.get_actives(db.session)} == {"on1.test", "on2.test"}
assert {s.domain for s in asyncio.run(Site.async_get_actives())} == {"on1.test", "on2.test"}
def test_site_list_order_by_pri_is_ascending(db):
"""
站点列表必须按优先级升序——顺序决定搜索与下载的站点先后。
"""
db.add(_site("", "p3.test", pri=3), _site("", "p1.test", pri=1),
_site("", "p2.test", pri=2))
assert [s.domain for s in Site.list_order_by_pri(db.session)] == \
["p1.test", "p2.test", "p3.test"]
assert [s.domain for s in asyncio.run(Site.async_list_order_by_pri())] == \
["p1.test", "p2.test", "p3.test"]
def test_site_get_domains_by_ids_returns_plain_strings(db):
"""
按 ID 批量取域名必须返回纯字符串列表,且只含请求的那些 ID。
"""
first = db.add(_site("", "d1.test"))
second = db.add(_site("", "d2.test"))
db.add(_site("", "d3.test"))
domains = Site.get_domains_by_ids(db.session, [first.id, second.id])
assert sorted(domains) == ["d1.test", "d2.test"]
assert all(isinstance(item, str) for item in domains)
def test_site_get_domains_by_ids_with_empty_list(db):
"""
ID 列表为空时返回空列表,不能退化成返回全部域名。
"""
db.add(_site("", "e1.test"))
assert Site.get_domains_by_ids(db.session, []) == []
def test_site_reset_empties_the_table(db):
"""
重置会清空站点表——CookieCloud 全量同步依赖它先清场再写入。
"""
db.add(_site("", "r1.test"))
Site.reset(db.session)
assert Site.list_order_by_pri(db.session) == []
# --------------------------------------------------------------------------- #
# SiteIcon / SiteStatistic
# --------------------------------------------------------------------------- #
def test_siteicon_get_by_domain_matches_async_twin(db):
"""
图标按域名查找的同步、异步结果必须一致。
"""
db.add(SiteIcon(name="站点A", domain="icon-a.test", url="https://icon-a.test/f.ico"),
SiteIcon(name="站点B", domain="icon-b.test", url="https://icon-b.test/f.ico"))
assert SiteIcon.get_by_domain(db.session, "icon-a.test").name == "站点A"
assert asyncio.run(SiteIcon.async_get_by_domain(domain="icon-a.test")).name == "站点A"
assert SiteIcon.get_by_domain(db.session, "icon-missing.test") is None
def test_sitestatistic_get_by_domain_matches_async_twin(db):
"""
访问统计按域名查找的同步、异步结果必须一致。
"""
db.add(SiteStatistic(domain="stat-a.test", success=3, fail=1, seconds=2, lst_state=0),
SiteStatistic(domain="stat-b.test", success=1, fail=0, seconds=1, lst_state=0))
assert SiteStatistic.get_by_domain(db.session, "stat-a.test").success == 3
assert asyncio.run(SiteStatistic.async_get_by_domain(domain="stat-a.test")).success == 3
assert SiteStatistic.get_by_domain(db.session, "stat-missing.test") is None
def test_sitestatistic_reset_empties_the_table(db):
"""
重置统计会清空整表,供「重置站点数据」入口使用。
"""
db.add(SiteStatistic(domain="stat-reset.test", success=1, fail=0, seconds=1, lst_state=0))
SiteStatistic.reset(db.session)
assert SiteStatistic.get_by_domain(db.session, "stat-reset.test") is None
# --------------------------------------------------------------------------- #
# SiteUserData
# --------------------------------------------------------------------------- #
def _userdata(domain: str, day: str, time: str, upload: float = 0,
err_msg: str = None) -> SiteUserData:
"""构造一条站点用户数据快照。"""
return SiteUserData(domain=domain, name=domain, username="u", upload=upload,
updated_day=day, updated_time=time, err_msg=err_msg)
def test_userdata_get_by_domain_narrows_with_date_and_time(db):
"""
按域名查询时,日期与时刻参数应逐级收窄结果范围。
"""
db.add(_userdata("ud.test", "2026-08-11", "10:00:00"),
_userdata("ud.test", "2026-08-12", "10:00:00"),
_userdata("ud.test", "2026-08-12", "20:00:00"),
_userdata("other.test", "2026-08-12", "10:00:00"))
assert len(SiteUserData.get_by_domain(db.session, "ud.test")) == 3
assert len(SiteUserData.get_by_domain(db.session, "ud.test", workdate="2026-08-12")) == 2
assert len(SiteUserData.get_by_domain(db.session, "ud.test",
workdate="2026-08-12", worktime="20:00:00")) == 1
def test_userdata_get_by_domain_matches_async_twin(db):
"""
三种收窄组合下同步与异步必须给出同样多的行。
"""
db.add(_userdata("ud2.test", "2026-08-12", "10:00:00"),
_userdata("ud2.test", "2026-08-12", "20:00:00"))
for kwargs in ({}, {"workdate": "2026-08-12"},
{"workdate": "2026-08-12", "worktime": "20:00:00"}):
sync_rows = SiteUserData.get_by_domain(db.session, "ud2.test", **kwargs)
async_rows = asyncio.run(SiteUserData.async_get_by_domain(domain="ud2.test", **kwargs))
assert len(sync_rows) == len(async_rows)
def test_userdata_get_by_date_returns_all_domains_of_that_day(db):
"""
按日期查询应跨站点返回当天全部快照。
"""
db.add(_userdata("day-a.test", "2026-08-12", "10:00:00"),
_userdata("day-b.test", "2026-08-12", "10:00:00"),
_userdata("day-a.test", "2026-08-11", "10:00:00"))
rows = SiteUserData.get_by_date(db.session, "2026-08-12")
assert {r.domain for r in rows} == {"day-a.test", "day-b.test"}
def test_userdata_get_latest_returns_one_day_per_domain(db):
"""
每个站点只返回其最新一天的快照,且跨站点互不影响。
这条正是子查询存在的理由:退化成「全表取最新」时,只会剩下日期最大的那个站点。
"""
db.add(_userdata("late-a.test", "2026-08-10", "10:00:00", upload=1),
_userdata("late-a.test", "2026-08-12", "10:00:00", upload=2),
_userdata("late-b.test", "2026-08-11", "10:00:00", upload=3))
latest = {r.domain: r for r in SiteUserData.get_latest(db.session)
if r.domain in ("late-a.test", "late-b.test")}
assert set(latest) == {"late-a.test", "late-b.test"}
assert latest["late-a.test"].updated_day == "2026-08-12"
assert latest["late-b.test"].updated_day == "2026-08-11"
def test_userdata_get_latest_ignores_failed_snapshots_when_picking_the_day(db):
"""
带错误信息的快照不参与「最新一天」的判定。
抓取失败当天也会留一条记录,若它决定了最新日期,站点数据会显示成空。
"""
db.add(_userdata("err.test", "2026-08-10", "10:00:00", upload=5),
_userdata("err.test", "2026-08-12", "10:00:00", err_msg="登录失败"))
rows = [r for r in SiteUserData.get_latest(db.session) if r.domain == "err.test"]
assert [r.updated_day for r in rows] == ["2026-08-10"]
def test_userdata_get_latest_matches_async_twin(db):
"""
同步与异步的「最新一天」必须选出同一批行。
"""
db.add(_userdata("par.test", "2026-08-10", "10:00:00"),
_userdata("par.test", "2026-08-12", "10:00:00"))
sync_rows = [(r.domain, r.updated_day) for r in SiteUserData.get_latest(db.session)]
async_rows = [(r.domain, r.updated_day) for r in asyncio.run(SiteUserData.async_get_latest())]
assert sorted(sync_rows) == sorted(async_rows)
def test_userdata_delete_before_is_batched_and_bounded(db):
"""
清理旧快照必须分批并遵守上限,且不碰保留期内的数据。
一次性删除大表会长时间持锁SQLite 下直接表现为整个应用卡住。
"""
for index in range(5):
db.add(_userdata("old.test", "2026-01-0%d" % (index + 1), "10:00:00"))
db.add(_userdata("old.test", "2026-08-12", "10:00:00"))
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=2) == 2
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=100) == 3
assert SiteUserData.delete_before(db.session, before_day="2026-08-01", limit=100) == 0
remaining = SiteUserData.get_by_domain(db.session, "old.test")
assert [r.updated_day for r in remaining] == ["2026-08-12"]
def test_userdata_delete_before_keeps_the_row_exactly_at_the_boundary(db):
"""
保留日期当天的快照属于「保留期内」,不能被清理(``updated_day < before_day``)。
上面那条用例的数据离水位有半年之遥,``<`` 写成 ``<=`` 也照样绿;
这里把行压在水位当天,让开闭区间之差可观测——差一天就是少一天的站点数据曲线。
"""
boundary = "2026-05-01"
db.add(_userdata("boundary.test", boundary, "10:00:00"),
_userdata("boundary.test", "2026-04-30", "10:00:00"))
assert SiteUserData.delete_before(db.session, before_day=boundary, limit=100) == 1
remaining = SiteUserData.get_by_domain(db.session, "boundary.test")
assert [r.updated_day for r in remaining] == [boundary]