mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-17 20:23:57 +08:00
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""
|
||
站点相关四张表的查询行为:站点、图标、访问统计、用户数据快照。
|
||
|
||
站点数据快照的 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]
|